diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 7140616..7fb88c1 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -79,6 +79,17 @@ jobs: # The smoke test drives Chromium, so the browser has to be present. A # static build passes even when islands fail to hydrate — only this # proves the diagrams draw. + # The browser download is the slowest step on a cold runner and the only + # variable one (20s usually, minutes when the CDN stalls). The key tracks + # the lockfile, so a dependency bump refreshes it and every other run hits + # the cache; `--with-deps` stays because it is a no-op once the image has + # the system libraries, and Chromium will not launch without them. + - name: Cache Chromium + uses: actions/cache@v6 + with: + path: ~/.cache/ms-playwright + key: playwright-${{ runner.os }}-${{ hashFiles('bun.lock') }} + - name: Install Chromium run: bunx playwright install --with-deps chromium @@ -130,5 +141,20 @@ jobs: # builds. The playground imports `dist/` from the repository, so it cannot # catch a broken `files` field, a missing `dist/` file, or an `exports` # subpath that does not resolve. + # + # The run ends on the consumer sites in Chromium: a static build passes even + # when every island fails to hydrate (dev served React's CommonJS + # `react/compiler-runtime` raw), so the browser is the only proof that a + # diagram draws. Same cache key as the smoke job above, so whichever runs + # second downloads nothing. + - name: Cache Chromium + uses: actions/cache@v6 + with: + path: ~/.cache/ms-playwright + key: playwright-${{ runner.os }}-${{ hashFiles('bun.lock') }} + + - name: Install Chromium + run: bunx playwright install --with-deps chromium + - name: Build a real Starlight site from the packed package run: bun run test:site diff --git a/AGENTS.md b/AGENTS.md index 9bfe710..f90f15c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,7 +1,9 @@ ## Development -Bun project. The package source is TypeScript in `src/`; `playground/` is a -small Starlight site that consumes it, for developing without a host site. The +Bun project. The package source is TypeScript in `src/`; there are two host +sites. `playground/` is a small Starlight site that consumes it, and +`examples/astro/` is the same thing in **plain Astro** (no Starlight) — both +import the package by name and resolve it through Node's self-reference. The wiki in `docs/wiki/` is the published documentation — keep it in step with the code. It is grouped into `guide/` (using the plugin) and `contributing/` (changing it), each with a `README.md` index, plus `Troubleshooting.md` and @@ -9,20 +11,29 @@ code. It is grouped into `guide/` (using the plugin) and `contributing/` ``` bun run build:lib # bundle src/ -> dist/ (+ .d.ts, grammar data) -bun run dev # build:lib, then the playground dev server on :4321 +bun run dev # build:lib, then the Starlight playground on :4321 +bun run dev:astro # build:lib, then the plain-Astro example on :4322 bun run build # build:lib, then the playground static build +bun run build:astro # build:lib, then the plain-Astro static build bun run test # vitest: pure helpers, the Langium scanner, the grammar bun run test:network # + a live check against likec4/likec4 -bun run test:site # pack the tarball and build a consumer Starlight site -bun run check # astro check (playground and src/) + tsc for scripts +bun run test:site # pack + install the tarball into a copy of playground/ and + # a copy of examples/astro/, then build both, run the full + # smoke suite against the Starlight copy, and check the + # plain one hydrates in Chromium +bun run check # astro check (both sites and src/) + tsc for scripts bun run grammar:check # the committed grammar snapshot matches its derivation ``` -`dev`, `build`, `check` and `test:site` all run `build:lib` first, because the -playground imports the plugin from `dist/` — the artifact a site installs. -Importing `../src/index.ts` there instead puts the plugin inside the Vite -module runner that loads `astro.config.mjs`, and its dynamic `import()` of +`dev`, `build`, `check` and `test:site` all run `build:lib` first, because both +host sites import the plugin from `dist/` — the artifact a site installs. +Importing `../src/index.ts` there instead puts the plugin inside the Vite module +runner that loads `astro.config.mjs`, and its dynamic `import()` of `@astrojs/markdown-satteri` then dies with "module runner has been closed". +`examples/astro/astro.config.mjs` imports `@purview-dev/astroc4/integration` and +its page imports `@purview-dev/astroc4/components/LikeC4View.astro`; inside this +repository those resolve the way an installed site resolves them, through +`package.json` `exports`. The playground scripts `cd playground` first rather than using `astro … --root playground`: with `--root`, `astro preview` can't find @@ -57,6 +68,21 @@ For highlighting, the smoke test's "distinct colours per block" check is the proof that the grammar reached Expressive Code: the playground has no `ec.config.mjs` on purpose. +The plain-Astro path has its own two proofs: `bun run check` runs `astro check` +over `examples/astro` (where `App.Locals` has no `t`, so a Starlight-ism in the +components fails there first), and `bun run dev:astro` plus +`bun scripts/hydration.ts http://localhost:4322` shows the island drawing with +the fallback palette. `bun run test:site` covers both host sites from the tarball, +and runs the **full smoke suite** against the packed copy of the playground — the +same assertions the local loop uses, but on the installed package rather than +`dist/` next to the repo. + +Hydration has its own dev-only failure mode: Vite served React's CommonJS +`react/compiler-runtime` raw, and every island threw "does not provide an export +named 'c'" while `astro build` stayed green. `src/index.ts` now adds it to +`vite.optimizeDeps.include`; `bun run test:site` (Chromium) and +`bun scripts/hydration.ts ` are what catch a regression, not the build. + For the model, `bun x likec4 export json playground/src/likec4 -o .tmp-model.json` reports node/edge counts per view in ~2s; a view that lays out to 0 nodes (e.g. a deployment view that only includes `node.**`) shows up there first. @@ -79,10 +105,38 @@ reports node/edge counts per view in ~2s; a view that lays out to 0 nodes `root`, and `updateConfig({ expressiveCode })` to reach Expressive Code. `updateConfig` merges shallowly, so merge existing options yourself — `withLikeC4Language` does. -- **No Tailwind, no host aliases.** Components are styled with plain CSS over - Starlight's `--sl-color-*` tokens. A host's Tailwind doesn't scan this - package, so utility classes would silently be unstyled. `--sl-color-white` - is semantic (highest-contrast text), not literal white. +- **Integration-time output goes through Astro's logger**, never `console`: the + logger honours the CLI log level and prefixes the message with the + integration's name. `registerFences` forks it to `likec4-fences` so the label + matches what `Troubleshooting.md` quotes. `console` belongs to browser code + (`render-icon.tsx`, `ErrorBoundary.tsx`) and to `scripts/`. +- **`keywords` is release metadata, not decoration.** Astro's integrations + library indexes npm by keyword: `astro-integration` (and `withastro`) put the + package in the library, and a category keyword (`ui`) decides where. The + listing reads `name`, `description`, `repository` and `homepage`. See + [Repository CI/CD](docs/wiki/contributing/Repository-CI-CD.md#astros-integrations-library). +- **The integration adds one thing to a site's config: `@astrojs/react`.** The + islands need a React renderer, so `ensureReactRenderer` pushes it through + `updateConfig({ integrations })` when it is missing (Astro runs hooks of + integrations appended during `astro:config:setup`). MDX and Expressive Code are + deliberately left alone — they change the host's content types and code blocks. + Related: ordering between `astroc4()` and `astroExpressiveCode()` does not + matter (Astro applies both mdast plugins and `markdown.rehypePlugins`); + `examples/astro` lists Expressive Code first so that stays true. +- **No Tailwind, no host aliases.** Components are styled with plain CSS over a + `--likec4-*` layer that defaults to Starlight's `--sl-color-*` tokens, with + scheme-neutral fallbacks for plain Astro (an undefined `var()` with no fallback + is invalid at computed-value time, so every token needs one). A host's Tailwind + doesn't scan this package, so utility classes would silently be unstyled. + `--sl-color-white` is semantic (highest-contrast text), not literal white. +- **Two entry points, one implementation.** `src/index.ts` is the Starlight + plugin and `src/integration.ts` is the `AstroIntegration`; the plugin calls the + integration. Anything both hosts need belongs in `integration.ts`, never in the + plugin — and `Astro.locals.t` is read as `unknown` because `App.Locals` is + empty outside Starlight. The plugin object also exposes the integration's + `astro:config:setup`, because `astro add` writes `integrations: [astroc4()]` + from the default export; the two hook sets are disjoint, so carrying both is + safe (and it warns when a Starlight site ends up with the wrong one). - **Strings go through `src/translations.ts`.** Astro components translate with `getTranslator(Astro.locals.t)` and pass islands plain strings (`IslandLabels`) — `t()` doesn't exist in the browser. Placeholders are diff --git a/CHANGELOG.md b/CHANGELOG.md index b6b2f3d..09ddb02 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,10 +4,78 @@ All notable changes to this project are documented here. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [Unreleased] +## [1.0.0-prerelease.5] - 2026-11-05 ### Added +- **The integration works in plain Astro, not only Starlight.** The diagrams were + always built on Astro APIs; what was missing was an entry point and three + Starlight assumptions. `@purview-dev/astroc4/integration` is now an + `AstroIntegration` — the same `LikeC4VitePlugin`, fence transform, Windows + preload and `react/compiler-runtime` fix the Starlight plugin installs, because + both call the new `src/integration.ts`: + + ```js + import astroc4 from '@purview-dev/astroc4/integration'; + export default defineConfig({ integrations: [astroc4(), expressiveCode(), mdx(), react()] }); + ``` + + Order matters and is documented: `astroc4()` first (Expressive Code needs the + Sätteri processor it installs), `expressiveCode()` before `mdx()`. + - `Astro.locals.t` is read as `unknown`. `App.Locals` is an empty interface + without Starlight's global augmentation, so `Astro.locals.t` is + `TS2339: Property 't' does not exist` for a site that type-checks the + components without Starlight — which `examples/astro` now does, and which is + how the cast is kept honest. The runtime already fell back to English. + - The components' colours moved to a `--likec4-*` layer that defaults to + `--sl-color-*` and to scheme-neutral fallbacks, so a plain Astro page gets a + legible frame instead of borders and backgrounds that resolve to nothing. + - `examples/astro/` is a runnable plain-Astro site (`bun run dev:astro`, port + 4322) with a project view, an inline fence and `ec.config.mjs` highlighting; + `docs/wiki/guide/Plain-Astro.md` documents what the site supplies and what it + gives up (translations, the `highlighting` option). + - **`astro add` now wires a working site.** The CLI reads only npm metadata — + the `astro-integration` keyword and the package's default export — and writes + `integrations: [astroc4()]`. The default export therefore carries the Astro + protocol as well: Starlight calls only `config:setup`/`i18n:setup`, Astro + calls only `astro:*`, so one object serves both and the CLI's wiring produces + diagrams instead of a silently inert plugin. On a Starlight site that shape + cannot run the plugin half, so it warns and names + `starlight({ plugins: [astroc4()] })`. `@astrojs/starlight` is now an + optional peer, since a plain Astro site never loads it. + - **The integration supplies the React renderer itself.** The islands are + `client:only="react"`, so a site without `@astrojs/react` built fine and then + drew nothing (Astro's "no renderer" error names React, not this package). + `astroc4()` now adds `@astrojs/react` when `integrations` does not already + list it — Astro runs the hooks of integrations added during + `astro:config:setup`, the mechanism Starlight uses for Expressive Code — and + logs that it did. It adds nothing else on the site's behalf: `@astrojs/mdx` + (a new content type) and `astro-expressive-code` (every code block on the + site) stay the site's decision. + - **The ordering rule was wrong, and is gone.** `astroc4()` and + `astroExpressiveCode()` can be listed in either order: Astro applies a + processor's mdast plugins *and* the site's `markdown.rehypePlugins`, which is + how Expressive Code attaches itself when it runs before a processor exists. + The example site now lists Expressive Code **first** and still builds with + diagrams and `c4` highlighting, so the claim is asserted rather than + asserted-in-prose. The only real rule is `astroExpressiveCode()` before + `mdx()`, which Expressive Code enforces with its own error. + - A fence in a `.md` page no longer says "rename it to `.mdx`" on a site + without `@astrojs/mdx` — advice that cannot work. It says to add MDX first. + - `test:site` now installs the tarball into **copies of both host sites** — + `playground/` and `examples/astro/` — and runs `astro check`, `astro build`, + the **full smoke suite** against the Starlight copy (on its preview server), + a dev-server hydration check against the plain-Astro copy, and a rebuild of + that copy with the exact config `astro add` writes. The smoke suite is the + one that covers hydration, icons, highlighting colours, the loading + placeholder and both locales, so those are now proven against the artifact a + user installs rather than against `dist/` sitting beside the repository. + +- **`bun scripts/hydration.ts `** checks one diagram in a real browser: it + waits for a LikeC4 element node, fails while the `.likec4-loading` + placeholder is still visible, and fails on any console error. `test:site` + runs it against the packed consumer site's own dev server, and the smoke test + now asserts the placeholder is gone too. - **The skills and the guide point at LikeC4's own DSL skill.** Astroc4 covers the integration — fences, components, highlighting — and not LikeC4's language, so both `skills/` files name `likec4.dev@likec4-dsl` as a companion and @@ -16,8 +84,60 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). `npx skills add https://likec4.dev/` (Agent Skills Discovery protocol), and the same page documents the MCP server for querying a model in natural language. +### Fixed + +- **`` hydrates in `astro dev` again.** On a site configured the way + the README says to configure one, every diagram stayed on "Loading + architecture diagram…" while `astro build` exited 0, with one console line: + + ``` + [astro-island] Error hydrating …/LikeC4ProjectView.tsx + SyntaxError: The requested module '/node_modules/react/compiler-runtime.js' + does not provide an export named 'c' + ``` + + `likec4/react` is compiled with the React Compiler and imports `c` from React + 19's CommonJS `react/compiler-runtime`. The island's source is in this + package, so dev served that file raw instead of pre-bundling it into an ES + module; `astro build` bundled it correctly, which is why only a browser could + see it. The plugin now adds `react/compiler-runtime` to + `vite.optimizeDeps.include` — only entries the site does not already have, + because Astro merges `vite` with Vite's `mergeConfig`, which concatenates + arrays — so no site needs a workaround. + + It applies to **React 19.x** with **LikeC4 1.59.4 and later**, the versions + whose `likec4/react` imports the compiler runtime; the production build was + never affected. `test:site` now starts the packed consumer site's dev server + and asserts in Chromium that the island draws nodes with no console errors; + [Troubleshooting](docs/wiki/Troubleshooting.md#diagrams-stay-blank-while-the-build-passes) + records the error and the manual `optimizeDeps.include` fix older releases + need. + ### Changed +- **The package follows Astro's integration conventions from end to end.** + - `keywords` now carries `withastro` (the discoverability keyword Astro's + community npm search filters on) and `ui` (the `CSS + UI` category), because + the [integrations library](https://docs.astro.build/en/guides/integrations/#integrations-library) + only files a package under a category when a category keyword is present — + otherwise it is listed as "Uncategorized". `astro-integration`, + `description`, `repository` and `homepage`, which the library reads, were + already there. + - The warning about a ` ```likec4 ` fence in a `.md` page now goes through + Astro's logger (`logger.fork('likec4-fences').warn(…)`) instead of + `console.warn`, so it respects the CLI log level and keeps the + `[likec4-fences]` label the Troubleshooting page quotes. `likec4Fences()` + takes an optional reporter; `console` remains the fallback for use outside + an Astro config. + - The README and Getting Started describe the two registrations, including the + fact that `astro add` writes the plain-Astro shape and must be moved into + `plugins` under Starlight. + + Hooks and config were already the documented shapes — `updateConfig` (never + mutating `config`), `addIntegration` for the nested LikeC4 integration, a + scoped unique integration `name`, `astro` among the `peerDependencies`, + `"type": "module"`, and `exports` with `types` beside each entry point — so + those are unchanged. - **The wiki is hierarchical.** `docs/wiki/` is now a landing `README.md` plus `guide/` (using the plugin: getting started, fences, components, highlighting, icons, i18n, options), `contributing/` (the module map, the local workflow, CI diff --git a/Justfile b/Justfile index 69ea6b1..d1ba609 100644 --- a/Justfile +++ b/Justfile @@ -18,11 +18,21 @@ install: dev: bun run dev +# Plain-Astro example (no Starlight) at http://localhost:4322 +[group('Develop')] +dev-astro: + bun run dev:astro + # Static build of the playground [group('Build')] build: bun run build +# Static build of the plain-Astro example +[group('Build')] +build-astro: + bun run build:astro + # Serve the built playground [group('Build')] preview: @@ -43,7 +53,9 @@ test: test-network: bun run test:network -# Pack the tarball and build a throwaway Starlight site from it +# Pack the tarball, install it into copies of playground/ and examples/astro/, +# then build both, run the full smoke suite against the Starlight copy and check +# the plain-Astro one hydrates in Chromium [group('Verify')] test-site: bun run test:site @@ -53,6 +65,11 @@ test-site: smoke url="http://localhost:4321": bun scripts/smoke.ts {{url}} +# Check that one diagram hydrates, against a running site (start `just dev` first) +[group('Verify')] +hydration url="http://localhost:4321": + bun scripts/hydration.ts {{url}} + # Fail if the committed grammar snapshot is stale [group('Grammar')] grammar-check: @@ -69,7 +86,8 @@ likec4-validate: bun run likec4:validate # The shared gate's chain (`WebBuildCommand` then `WebTestCommand`), plus the -# packed consumer site — i.e. what a PR must pass. +# packed consumer sites — i.e. what a PR must pass. `test-site` ends in Chromium, +# so run `bunx playwright install chromium` once. [group('Verify')] verify: grammar-check check build test test-site @echo "All checks passed." diff --git a/README.md b/README.md index 932fedb..e92cbd0 100644 --- a/README.md +++ b/README.md @@ -2,17 +2,23 @@ # astroc4 -A Starlight plugin for interactive [LikeC4](https://likec4.dev) architecture -diagrams. +Interactive [LikeC4](https://likec4.dev) architecture diagrams for Astro and +Starlight. - `` — render a view from the site's `.c4` model. - ` ```likec4 ` fences in `.mdx` — either inline DSL, compiled and laid out at build time, or an empty fence with `view="…"` pointing at the model. - LikeC4 syntax highlighting for Expressive Code (`.c4` files, ` ```c4 `), derived at build time from the Langium grammar in - [likec4/likec4](https://github.com/likec4/likec4) — no `ec.config.mjs`. + [likec4/likec4](https://github.com/likec4/likec4) — no `ec.config.mjs` under + Starlight. - Every UI string translatable through Starlight's i18n. +It works in **plain Astro** as well: `@purview-dev/astroc4/integration` is an +`AstroIntegration` that adds the same model compiler and fence transform. See +[Plain Astro](docs/wiki/guide/Plain-Astro.md) and the runnable example in +[`examples/astro`](examples/astro). + Full documentation lives in [`docs/wiki`](docs/wiki/README.md), grouped into `guide/` and `contributing/`. @@ -25,8 +31,21 @@ bun add @purview-dev/astroc4@prerelease # 1.0.0-prerelease.N Published to npm under the `prerelease` dist-tag, so `bun add @purview-dev/astroc4` keeps resolving the last stable release. Astroc4 declares its peer dependencies, so Bun and npm install them with it; under pnpm add them explicitly (`astro`, -`@astrojs/starlight`, `@astrojs/react`, `@astrojs/markdown-satteri`, `likec4`, -`@likec4/core`, `react`, `react-dom`, `shiki`). +`@astrojs/react`, `@astrojs/markdown-satteri`, `likec4`, `@likec4/core`, +`react`, `react-dom`, `shiki`, and `@astrojs/starlight` if you use the plugin — +Starlight is an optional peer, because plain Astro does not need it). + +`astro add @purview-dev/astroc4` wires the **plain-Astro** shape for you: it +installs the package (and the non-optional peers) and writes +`integrations: [astroc4()]`, which works because the default export implements +the Astro integration protocol too. In a Starlight site the CLI cannot know +better — it writes the same thing — so move that call into +`starlight({ plugins: [astroc4()] })`; left in `integrations`, the diagrams still +render but the translated UI and LikeC4 highlighting do not, and the plugin warns +about it. + +(`astro`'s integrations library indexes npm by keyword, and this package carries +`astro-integration` with `ui` for a category.) Working on astroc4 itself? `bun add @purview-dev/astroc4@file:../path/to/astroc4` copies the package into `node_modules`, so run `bun install` in the site again after @@ -34,6 +53,8 @@ changing the plugin. ## Use +### With Starlight + ```js // astro.config.mjs import starlight from '@astrojs/starlight'; @@ -41,7 +62,7 @@ import react from '@astrojs/react'; import astroc4 from '@purview-dev/astroc4'; export default defineConfig({ - integrations: [starlight({ plugins: [astroc4()] }), react()], + integrations: [starlight({ plugins: [astroc4()] }), react()], }); ``` @@ -51,6 +72,37 @@ import LikeC4View from '@purview-dev/astroc4/components/LikeC4View.astro'; ``` +### With Astro (no Starlight) + +```js +// astro.config.mjs +import { defineConfig } from 'astro/config'; +import mdx from '@astrojs/mdx'; +import expressiveCode from 'astro-expressive-code'; +import astroc4 from '@purview-dev/astroc4/integration'; + +export default defineConfig({ + // astroExpressiveCode() must come before mdx() — its own requirement. + integrations: [astroc4(), expressiveCode(), mdx()], +}); +``` + +The page is the same `` import. Two things the site supplies: +`@astrojs/mdx` (` ```likec4 ` fences only become diagrams in `.mdx`), and +`astro-expressive-code` plus an `ec.config.mjs` that registers `likec4Language` +when you want ` ```c4 ` highlighted — Starlight has an `expressiveCode` option for +the plugin to fill in, Astro does not. + +`react()` is **not** on that list: `astroc4()` adds `@astrojs/react` when the +site has no React renderer, because the islands are `client:only="react"` and +nothing renders without one. [Plain Astro](docs/wiki/guide/Plain-Astro.md) has +the full walkthrough, and [`examples/astro`](examples/astro) is a working site +with exactly this config (`bun run dev:astro`). + +`astro add @purview-dev/astroc4` writes the `astroc4()` line for you in this +shape; you still add the other integrations (`astro add mdx +astro-expressive-code`) and the `ec.config.mjs`. + The plugin registers `LikeC4VitePlugin`, which compiles every `likec4.config.json` project under the site root. Keep exactly the projects you mean to render there: the first one found is the default for `` @@ -63,12 +115,12 @@ silently replace your own. | --- | --- | --- | | `vitePlugin` | `{}` | Passed to `LikeC4VitePlugin`, e.g. `{ workspace: 'diagrams' }`. | | `fences` | `true` | Turn ` ```likec4 ` fences into diagrams. | -| `highlighting` | `true` | Highlight LikeC4 source. See below. | +| `highlighting` | `true` | Highlight LikeC4 source. Starlight only — a plain Astro site uses `ec.config.mjs`. See below. | ### Syntax highlighting -The plugin reads `packages/language-server/src/like-c4.langium` from -`likec4/likec4`, derives a TextMate grammar from it, and registers that with +Under Starlight the plugin reads `packages/language-server/src/like-c4.langium` +from `likec4/likec4`, derives a TextMate grammar from it, and registers that with the site's Expressive Code — so ` ```c4 ` fences, ` ```likec4 ` fences and `` all highlight with no configuration. Highlighting follows the language: when upstream adds a shape or a keyword, the next build @@ -112,7 +164,7 @@ Fetched grammars are cached per `repo@ref`, so switching `ref` never thrashes and repeated builds do not touch the network. A site that already has an `ec.config.mjs` keeps working, and can pin the -bundled grammar there instead: +bundled grammar there instead — which is also what a plain Astro site does: ```js // ec.config.mjs @@ -152,20 +204,51 @@ absolute URL, or a file under the site's `public/` referenced by a path relative to the `.c4` file. Don't use LikeC4's `imageAliases`: the compiler bakes a build-machine `file://` path into the client bundle. +## Troubleshooting + +**A green `astro build` is not proof that a diagram renders.** The islands are +`client:only` React, so a build can succeed while every frame stays on "Loading +architecture diagram…" — open the page in `astro dev`, or run +`bun scripts/smoke.ts` / `bun scripts/hydration.ts ` against it. + +| Symptom | Cause | Fix | +| --- | --- | --- | +| `The requested module '/node_modules/react/compiler-runtime.js' does not provide an export named 'c'` in the console, diagrams stuck on the loading placeholder | `likec4/react` (1.59.4 and later) is compiled with the React Compiler and imports `c` from React 19's CommonJS `react/compiler-runtime`; in dev Vite serves it unoptimized, so the island never mounts. Production builds bundle it, which is why the build still passes. | Fixed in `1.0.0-prerelease.5`: the plugin adds `react/compiler-runtime` to `vite.optimizeDeps.include`. On an older version, or to confirm by hand: `vite: { optimizeDeps: { include: ['react/compiler-runtime'] } }`. | +| Build fails with `could not fetch the LikeC4 grammar from https://raw.githubusercontent.com/…` | The grammar is resolved while the config loads, and the machine cannot reach GitHub. | `highlighting: { offline: true }` (bundled snapshot) or `onError: 'warn'`; `ASTROC4_OFFLINE=1` in CI. | +| `` shows "Unknown LikeC4 projectId" | Every `.c4` project under the site root is compiled and the first is the default, so a vendored model can take over. | Keep only the projects you render, pass `projectId`, or point `vitePlugin.workspace` at one directory. | +| `Cannot find module …/@purview-dev/astroc4/dist/index.js` | That version shipped without its build output (only `1.0.0-prerelease.3` did). | Move to `1.0.0-prerelease.4` or later. | + +Everything else — highlighting that produces one colour, fences left as code, +Windows module-runner crashes — is in +[Troubleshooting](docs/wiki/Troubleshooting.md). + ## Develop ```sh bun install -bun run dev # playground at http://localhost:4321 (English + French) +bun run dev # Starlight playground at http://localhost:4321 (English + French) +bun run dev:astro # plain-Astro example at http://localhost:4322 bun run test # hermetic unit suite: Langium parsing, grammar, fences, plugin bun run test:network # the same, plus a check against the live likec4/likec4 -bun run test:site # pack the tarball and build a real Starlight site from it -bun run check # astro check over playground/ and src/ +bun run test:site # pack the tarball, install it into copies of playground/ and + # examples/astro/, build both, run the full smoke suite against + # the Starlight copy, and check the plain one hydrates +bun run check # astro check over playground/, examples/astro/ and src/ bun run grammar:check # fail if the committed grammar snapshot is stale bun run build && bun run preview +bun run build:astro # static build of the plain-Astro example bun scripts/smoke.ts http://localhost:4321 # headless: hydration, icons, highlighting, i18n +bun scripts/hydration.ts http://localhost:4321 # headless: one diagram, nothing else ``` +`playground/` is the Starlight development site; `examples/astro/` is the +plain-Astro one, and `test:site` copies it so the checked-in example is what the +suite builds. Both import the package by name — Node's self-reference resolves +it from the repository, which *is* the package. + +`test:site` and the two browser scripts need Chromium +(`bunx playwright install chromium`). + `Justfile` wraps each of those (`just verify` runs what the PR workflow runs). `src/grammar/likec4.tmLanguage.json` and `src/grammar/like-c4.langium` are generated — `bun run grammar:sync [--ref ]` refreshes them from upstream, diff --git a/bun.lock b/bun.lock index 6b7a17a..5c79dc9 100644 --- a/bun.lock +++ b/bun.lock @@ -7,12 +7,14 @@ "devDependencies": { "@astrojs/check": "^0.9.12", "@astrojs/markdown-satteri": "^0.4.6", + "@astrojs/mdx": "^8.1.2", "@astrojs/react": "^7.1.2", "@astrojs/starlight": "^0.42.8", "@likec4/core": "^1.59.4", "@types/react": "^19.3.0", "@types/react-dom": "^19.3.0", "astro": "^7.4.4", + "astro-expressive-code": "^0.44.3", "likec4": "^1.59.4", "playwright": "^1.63.0", "react": "^19.3.0", @@ -31,7 +33,11 @@ "likec4": ">=1.59.4", "react": ">=19.0.0", "react-dom": ">=19.0.0", + "shiki": ">=1.0.0", }, + "optionalPeers": [ + "@astrojs/starlight", + ], }, }, "packages": { diff --git a/docs/wiki/README.md b/docs/wiki/README.md index 047ea69..ea97004 100644 --- a/docs/wiki/README.md +++ b/docs/wiki/README.md @@ -28,6 +28,12 @@ That is the whole setup: the site's `.c4` model is compiled by LikeC4's Vite plugin, ` ```likec4 ` fences become diagrams, and LikeC4 source is highlighted by Expressive Code with no `ec.config.mjs`. +It also works without Starlight. `@purview-dev/astroc4/integration` is a plain +`AstroIntegration` that adds the same model compiler and fence transform; the +site supplies `@astrojs/react`, `@astrojs/mdx` and — only for highlighting — +`astro-expressive-code` with an `ec.config.mjs`. See +[Plain Astro](guide/Plain-Astro.md). + ## What it gives you | | | @@ -55,7 +61,9 @@ four files. ## Requirements -Astro 7, Starlight 0.42.8, React 19, LikeC4 1.59.4 and Node 22.19 (or Bun -1.4.4). The peer ranges are declared in `package.json`; the Starlight floor is -load-bearing, because the plugin relies on a `config:setup` hook argument and an -`updateConfig` key that earlier versions do not have. +Astro 7, React 19, LikeC4 1.59.4 and Node 22.19 (or Bun 1.4.4). Starlight +0.42.8 is required for the plugin entry point (`@purview-dev/astroc4`), not for +`@purview-dev/astroc4/integration`. The peer ranges are declared in +`package.json`; the Starlight floor is load-bearing, because the plugin relies on +a `config:setup` hook argument and an `updateConfig` key that earlier versions do +not have. diff --git a/docs/wiki/Troubleshooting.md b/docs/wiki/Troubleshooting.md index e6e98ce..2dd7d1a 100644 --- a/docs/wiki/Troubleshooting.md +++ b/docs/wiki/Troubleshooting.md @@ -2,6 +2,81 @@ Each entry is a failure that actually happens, and what it means. +## Diagrams stay blank while the build passes + +The frame renders, the page answers 200, `astro build` exits 0 — and every +diagram still shows "Loading architecture diagram…". In the browser console: + +``` +[astro-island] Error hydrating /node_modules/@purview-dev/astroc4/src/components/LikeC4ProjectView.tsx +SyntaxError: The requested module '/node_modules/react/compiler-runtime.js?v=…' +does not provide an export named 'c' +``` + +`likec4/react` (LikeC4 1.59.4 and later) is compiled with the React Compiler, +so it imports `c` from `react/compiler-runtime`. React 19 ships that entry point +as CommonJS: + +```js +// node_modules/react/compiler-runtime.js +module.exports = require('./cjs/react-compiler-runtime.development.js'); // exports.c = … +``` + +The island is `client:only="react"` and its source is in this package, so in +`astro dev` Vite can hand the browser `/node_modules/react/compiler-runtime.js` +as-is instead of pre-bundling it into an ES module with a named `c` export. The +island then throws during hydration and stays on its +`slot="fallback"` placeholder. **`astro build` bundles the dependency +correctly**, which is why this is invisible to a build, to `astro check` and to +`likec4 validate` — a green build is not proof that a diagram renders. + +### Fixed versions + +The plugin now adds `react/compiler-runtime` to the site's +`vite.optimizeDeps.include` (merged with whatever the site sets — only missing +entries are added, because Astro's `updateConfig` concatenates Vite arrays). +That is everything a site needs: no manual Vite configuration. It matters for +**React 19.x** (any version whose `react/compiler-runtime` is CommonJS) together +with **LikeC4 1.59.4 and later**; older LikeC4 builds do not import the compiler +runtime at all. + +On a release before the fix, or to check the diagnosis by hand, add the entry +yourself: + +```js +// astro.config.mjs +export default defineConfig({ + vite: { + optimizeDeps: { include: ['react/compiler-runtime'] }, + }, + integrations: [starlight({ plugins: [astroc4()] }), react()], +}); +``` + +Then delete `node_modules/.vite` (and any `.vite` cache under the site root) and +restart `astro dev`; the cache is why the failure can appear to come and go. + +Do not treat the other workarounds people reach for as fixes: `client:only` is +already correct, pinning an older React or LikeC4 only hides the bug, and +`onError: 'warn'` is the grammar-fetch option — it has nothing to do with +hydration. + +### Proving it before and after + +Only a browser on a dev server shows this. Either one does it: + +```sh +bun scripts/hydration.ts http://localhost:4321 # a running site +bun run test:site # the packed consumer site +``` + +`hydration.ts` waits for a `[data-likec4-node], .react-flow__node` element +(LikeC4 renders into a shadow root, which Playwright locators pierce), fails if +the `.likec4-loading` placeholder is still visible, and fails on any console +error. `bun run test:site` runs exactly that against a throwaway Starlight site +built from the tarball, on that site's own dev server, which is the regression +test for this bug. + ## The build fails and the log mentions the grammar ``` diff --git a/docs/wiki/contributing/Architecture.md b/docs/wiki/contributing/Architecture.md index 7dff660..555054d 100644 --- a/docs/wiki/contributing/Architecture.md +++ b/docs/wiki/contributing/Architecture.md @@ -8,6 +8,7 @@ Vite plugin, plus the fence transform) and the highlighting (the grammar). ``` src/ TypeScript sources — bundled into dist/ by build:lib index.ts the Starlight plugin: hooks, options, Expressive Code injection + integration.ts the AstroIntegration both entry points install fences.ts ```likec4 → component; compiles and lays out inline DSL expressive-code.ts `likec4Language` (bundled) + `loadLikeC4Language(options)` translations.ts the UI strings injected into Starlight's i18n @@ -21,7 +22,7 @@ src/ TypeScript sources — bundled into dist/ components/ shipped as source: the site compiles these LikeC4View.astro author-facing wrapper for a project view LikeC4Fence.astro target of the fence transform - DiagramShell.astro collapsible shell, plain CSS over Starlight tokens + DiagramShell.astro collapsible shell, plain CSS over the --likec4-* tokens DiagramFrame.astro the sized container LoadingPlaceholder.astro pre-hydration placeholder (carries the height) LikeC4ProjectView.tsx island: loads a project from `likec4:react/` @@ -33,6 +34,29 @@ src/ TypeScript sources — bundled into dist/ utils.ts small shared helpers ``` +Two entry points, one implementation: + +| `exports` key | Module | Installed by | +| --- | --- | --- | +| `.` | `src/index.ts` | `starlight({ plugins: [astroc4()] })` | +| `./integration` | `src/integration.ts` | `integrations: [astroc4(), …]` | + +`index.ts` calls `likec4Integration()` and adds what only Starlight has: the +`i18n:setup` injection, the `expressiveCode` option, and Starlight's plugin +context. Everything the diagrams need — the Vite plugin, the fence transform, +the preload, the `optimizeDeps` fix — lives in `integration.ts`, so the two hosts +cannot drift. + +The default export carries **both** hook sets: the Starlight plugin hooks and, +via spread, the integration's `astro:config:setup`. That is what makes +`astro add`'s automatic wiring work — the CLI reads the package's +`astro-integration` keyword and its default export, then writes +`integrations: [astroc4()]`, where only `astro:*` hooks ever run. Starlight never +calls `astro:*` hooks on a plugin and Astro never calls `config:setup`, so the +two protocols do not collide; `warnIfMiswired` covers the one bad case (a +Starlight site that got the CLI's shape, where the translations and highlighting +cannot run). + ## Data flow **A diagram renders in three steps.** @@ -65,6 +89,12 @@ Expressive Code integration, which is what makes the injection possible: | `withLikeC4Language` merges, never replaces | Starlight's `updateConfig` merges shallowly, so replacing `expressiveCode` would delete a site's themes and plugins. | | The grammar is plain JSON | Expressive Code JSON-round-trips integration options before the `` component renders them. | | `ssr.noExternal: ['@purview-dev/astroc4']` | The components ship as `.astro`/`.tsx` source, so Vite has to transform them instead of handing them to Node. | +| `react/compiler-runtime` is added to `vite.optimizeDeps.include` | `likec4/react` is React-Compiler-built and imports `c` from React 19's CommonJS runtime. Unoptimized, dev serves it raw and every island dies with "does not provide an export named 'c'". `astro build` hides it by bundling correctly. Only missing entries are sent, because Astro merges `vite` with Vite's `mergeConfig`, which concatenates arrays. | +| `@astrojs/react` is added when the site has not | The islands are `client:only="react"`, and without a renderer a site builds and then draws nothing. Pushing it through `updateConfig({ integrations })` works because Astro's config loop reads integrations appended while it runs (`runHookConfigSetup`) — the same mechanism Starlight uses to add Expressive Code. Only that one: MDX and Expressive Code change the host's content types and code blocks, so they stay the site's decision. | +| Integration ordering is not ours to impose | `astroc4()` and `astroExpressiveCode()` work in either order, because Astro applies the processor's mdast plugins *and* `markdown.rehypePlugins` (Expressive Code's fallback when it runs before a processor exists). The example lists Expressive Code first so that stays true. Only Expressive-Code-before-MDX matters, and Expressive Code enforces it itself. | +| Integration-time warnings go through Astro's `logger`, never `console` | The logger honours the CLI log level and labels the message with the integration's name; `logger.fork('likec4-fences')` keeps the `[likec4-fences]` label the Troubleshooting page quotes. `console` is only for browser code (`render-icon.tsx`, `ErrorBoundary.tsx`) and `scripts/`. | +| Colours are a `--likec4-*` layer over `--sl-color-*` | Starlight defines the tokens; plain Astro does not, and an undefined `var()` with no fallback is invalid at computed-value time. Each component declares the tokens it uses with a scheme-neutral fallback (`rgba(128,128,128,…)`, `inherit`), which is why the same components frame a diagram on either host. | +| `keywords` carries `astro-integration`, `withastro` and a category (`ui`) | Astro's [integrations library](https://docs.astro.build/en/guides/integrations/#integrations-library) indexes npm by those keywords and reads `name`, `description`, `repository` and `homepage` from `package.json`. Without a category keyword the listing lands under "Uncategorized". | ## Tests @@ -73,4 +103,5 @@ Expressive Code integration, which is what makes the injection possible: | Pure helpers, scanner, generator, snapshot parity, fetch/cache policy, hook wiring | `src/**/__tests__` (Vitest, hermetic) | | Live grammar | `network.test.ts`, opt-in with `ASTROC4_NETWORK=1` | | Rendering, hydration, icons, i18n | `scripts/smoke.ts` (Playwright against the playground) | -| The shipped package | `scripts/test-site.ts` (packs the tarball, builds a consumer site) | +| One diagram hydrating, on either host | `scripts/hydration.ts` (Playwright; used by `test:site` against each consumer's dev server) | +| The shipped package | `scripts/test-site.ts` (packs the tarball, builds a Starlight site and a plain-Astro site — the latter copied from `examples/astro` — then checks hydration on both dev servers) | diff --git a/docs/wiki/contributing/Local-Development.md b/docs/wiki/contributing/Local-Development.md index 08e1c4b..5bd2a08 100644 --- a/docs/wiki/contributing/Local-Development.md +++ b/docs/wiki/contributing/Local-Development.md @@ -2,27 +2,33 @@ ```sh bun install -bun run dev # playground at http://localhost:4321 +bun run dev # Starlight playground at http://localhost:4321 +bun run dev:astro # plain-Astro example at http://localhost:4322 ``` `playground/` is a small Starlight site (English and French) that consumes the package through its own `exports`, so it behaves like an installed copy without a -pack step. +pack step. `examples/astro/` is the same thing without Starlight — one page, one +model, `ec.config.mjs` for highlighting — and it is what `test:site` copies and +builds, so it cannot rot. ## Scripts | Script | What it does | | --- | --- | -| `bun run build:lib` | Bundle `src/` into `dist/` (Bun for the JS, `tsc` for the `.d.ts`, plus the two grammar data files). | +| `bun run build:lib` | Bundle `src/` into `dist/` (Bun for the JS — three entry points — `tsc` for the `.d.ts`, plus the two grammar data files). | | `bun run dev` | `build:lib`, then the playground dev server on port 4321. | +| `bun run dev:astro` | `build:lib`, then the plain-Astro example on port 4322. | | `bun run build` | `build:lib`, then a static build of the playground. | +| `bun run build:astro` | `build:lib`, then a static build of `examples/astro`. | | `bun run preview` | Serve the built playground. | -| `bun run check` | `astro check` over `playground/` and `src/`, then `tsc` over `scripts/`. | -| `bun run test` | Vitest: the scanner, the generator, the snapshot, the fetch policy, the plugin hooks, the fence transform. Hermetic. | +| `bun run check` | `astro check` over `playground/`, `examples/astro/` and `src/`, then `tsc` over `scripts/`. | +| `bun run test` | Vitest: the scanner, the generator, the snapshot, the fetch policy, the plugin hooks, the integration hook, the fence transform. Hermetic. | | `bun run test:watch` | The same, in watch mode. | | `bun run test:network` | Adds the live check against `likec4/likec4`. The only test that needs the network. | -| `bun run test:site` | `build:lib`, `npm pack`, then build a throwaway Starlight site from the tarball. Needs the registry. | +| `bun run test:site` | `build:lib`, `npm pack`, then install the tarball into two throwaway sites — a copy of `playground/` and a copy of `examples/astro/` — and run `astro check`, `astro build`, the **full smoke suite** against the Starlight copy, and a hydration check against the plain-Astro copy. Needs the registry and Chromium (`bunx playwright install chromium`). | | `bun run test:smoke` | `scripts/smoke.ts` against a running server (Playwright). | +| `bun scripts/hydration.ts ` | `scripts/hydration.ts` against a running site: one diagram, one browser, no other assertions. | | `bun run grammar:sync` | Regenerate `src/grammar/like-c4.langium` + `likec4.tmLanguage.json` from `main`. `--ref ` for a release. | | `bun run grammar:check` | Fail if the committed snapshot is stale. | | `bun run likec4:validate` | `likec4 validate` over the playground model. | @@ -46,7 +52,13 @@ resolve, the fences became diagrams and the highlighting reached Expressive Code For anything that touches packaging — `exports`, `files`, a peer dependency, how `dist/` is laid out — also run `bun run test:site`. The playground imports -`dist/index.js` from the repository, so it cannot see a broken tarball. +`dist/index.js` from the repository, so it cannot see a broken tarball. That +script installs the tarball into copies of both host sites and runs the full +smoke suite against the Starlight one, because the React Compiler runtime failure +in +[Troubleshooting](../Troubleshooting.md#diagrams-stay-blank-while-the-build-passes) +only exists in a browser: a green `astro build` bundles `react/compiler-runtime` +correctly and leaves every diagram blank. ## Working on the grammar diff --git a/docs/wiki/contributing/Repository-CI-CD.md b/docs/wiki/contributing/Repository-CI-CD.md index 4bfd267..194e929 100644 --- a/docs/wiki/contributing/Repository-CI-CD.md +++ b/docs/wiki/contributing/Repository-CI-CD.md @@ -11,7 +11,7 @@ here. | `Build and test` | Calls `purview-dev/build/.github/workflows/purview-build.yml@main` — the organisation gate. `.config/purview-build.json` declares `ProjectType: Web`, so it installs with Bun, runs the build command below, then the test command. `run-pack: false` and `validate-pack: false`: this repository ships no NuGet package. | | `Live grammar` | `bun run test:network` — the only test that talks to likec4/likec4. | | `Browser smoke test` | Builds the playground, starts `astro preview`, runs `scripts/smoke.ts` against it with Chromium installed. Uploads `preview.log` on failure. | -| `Packed consumer site` | `bun run test:site`: pack the tarball, install it into a throwaway Starlight site and build that. | +| `Packed consumer site` | `bun run test:site`: pack the tarball, install it into two throwaway sites — a copy of `playground/` and a copy of `examples/astro/` — build both, run the full smoke suite against the Starlight copy on its preview server, and hydration checks on the plain-Astro copy's dev server and the `astro add` config shape. | ### `.config/purview-build.json` @@ -251,6 +251,23 @@ That is the whole process: the workflow tests, publishes, tags and writes the release notes. Nothing else needs to be bumped, and no file is generated at release time. +### Astro's integrations library + +Nothing is submitted: the library is rebuilt weekly from npm, pulling in every +package published with the `astro-integration`, `astro-component` or `withastro` +keyword and reading `name`, `description`, `repository` and `homepage` from +`package.json`. Ours carries all three of the useful ones: + +| Keyword | Purpose | +| --- | --- | +| `astro-integration` | What actually gets the package into the library. | +| `withastro` | The other keyword Astro recommends for discoverability; it is what the community npm search (`keywords:astro-component,withastro`) filters on. | +| `ui` | A category (`CSS + UI`). Without one of the category keywords, a listing is "Uncategorized". | + +The avatar and any override of the name, description or homepage are requested +by issue at [withastro/astro.build](https://github.com/withastro/astro.build/issues/new/choose), +once, after the first publish — not from this repository. + ## Local equivalents | Workflow job | Locally | diff --git a/docs/wiki/guide/Components.md b/docs/wiki/guide/Components.md index b80debd..30933ad 100644 --- a/docs/wiki/guide/Components.md +++ b/docs/wiki/guide/Components.md @@ -46,9 +46,26 @@ that is always a plugin bug and should be loud. ## Styling -Components use plain CSS over Starlight's design tokens (`--sl-color-*`), with no -utility classes and no Tailwind: a host's Tailwind does not scan this package, so -utility classes would silently be unstyled. Override with CSS targeting: +Components use plain CSS, with no utility classes and no Tailwind: a host's +Tailwind does not scan this package, so utility classes would silently be +unstyled. + +Colours come from a small `--likec4-*` layer that defaults to Starlight's design +tokens and to scheme-neutral fallbacks (translucent greys, `inherit`) when those +tokens are absent — which is the plain Astro case. Override either layer: + +| Token | Default | Used for | +| --- | --- | --- | +| `--likec4-border` | `var(--sl-color-gray-5, rgba(128, 128, 128, 0.35))` | Shell, header and frame borders | +| `--likec4-surface` | `var(--sl-color-bg, transparent)` | Header, badge and placeholder background | +| `--likec4-surface-alt` | `var(--sl-color-bg-nav, rgba(128, 128, 128, 0.06))` | Shell background | +| `--likec4-text` | `var(--sl-color-white, inherit)` | Shell title | +| `--likec4-text-muted` | `var(--sl-color-gray-2, rgba(128, 128, 128, 0.95))` | Description, badge, loading label | +| `--likec4-accent` | `var(--sl-color-text-accent, inherit)` | The hint line | +| `--likec4-skeleton` | `var(--sl-color-gray-6, rgba(128, 128, 128, 0.18))` | Loading lines | +| `--likec4-danger`, `--likec4-danger-soft`, `--likec4-danger-text` | Starlight's `--sl-color-red*`, else a red palette | The error boundary | + +Or target the classes directly: | Class | Element | | --- | --- | @@ -60,7 +77,8 @@ utility classes would silently be unstyled. Override with CSS targeting: | `likec4-loading` | The pre-hydration placeholder | | `likec4-error` | The error boundary's output | -`class="not-content"` on the shell is Starlight's opt-out from prose styles. +`class="not-content"` on the shell is Starlight's opt-out from prose styles; on a +plain Astro site it is inert. ## Client islands diff --git a/docs/wiki/guide/Configuration-Reference.md b/docs/wiki/guide/Configuration-Reference.md index 0980e9f..7ddead4 100644 --- a/docs/wiki/guide/Configuration-Reference.md +++ b/docs/wiki/guide/Configuration-Reference.md @@ -1,8 +1,9 @@ # Configuration Reference -Everything is configured through one call. +Everything is configured through one call, and that call is one of two: ```js +// Starlight: the plugin entry point starlight({ plugins: [ astroc4({ @@ -14,13 +15,23 @@ starlight({ }); ``` +```js +// Plain Astro: the integration entry point +integrations: [astroc4(), expressiveCode(), mdx(), react()]; +``` + +`highlighting` exists only on the Starlight plugin, because it is an injection +into Starlight's `expressiveCode` option; a plain Astro site configures +Expressive Code itself ([Plain Astro](Plain-Astro.md#highlighting)). Everything +else is shared, because both entry points call the same integration. + ## Top-level options | Option | Type | Default | Meaning | | --- | --- | --- | --- | | `fences` | `boolean` | `true` | Turn ` ```likec4 ` fences into diagrams. `false` leaves them as code blocks. | | `vitePlugin` | `object` | `{}` | Passed straight to LikeC4's `LikeC4VitePlugin`, e.g. `{ workspace: 'diagrams' }` to look for `.c4` files somewhere other than the site root. | -| `highlighting` | `true` \| `false` \| `HighlightingOptions` | `true` | Highlight LikeC4 source. `false` leaves Expressive Code entirely to the site. | +| `highlighting` | `true` \| `false` \| `HighlightingOptions` | `true` | **Starlight only.** Highlight LikeC4 source. `false` leaves Expressive Code entirely to the site. | ## Highlighting options diff --git a/docs/wiki/guide/Getting-Started.md b/docs/wiki/guide/Getting-Started.md index 4eac02b..714b7a1 100644 --- a/docs/wiki/guide/Getting-Started.md +++ b/docs/wiki/guide/Getting-Started.md @@ -13,6 +13,12 @@ install them explicitly: `astro`, `@astrojs/starlight`, `@astrojs/react`, `@astrojs/markdown-satteri`, `likec4`, `@likec4/core`, `react`, `react-dom`, `shiki`. +It is a Starlight plugin, not a plain Astro integration, so the config file is +the next step. (`astro add @purview-dev/astroc4` gets you the package and writes +`integrations: [astroc4()]`, which is the plain-Astro shape; under Starlight move +that call into `plugins` — otherwise the diagrams render, but the translated UI +and the highlighting do not, and the plugin says so.) + ## 2. Register the plugin ```js @@ -29,6 +35,10 @@ export default defineConfig({ React is required: the diagrams are React islands (`likec4/react`). +Not using Starlight? `@purview-dev/astroc4/integration` is a plain +`AstroIntegration`, and [Plain Astro](Plain-Astro.md) has the full config — +including what a site has to add itself, from `@astrojs/mdx` to `ec.config.mjs`. + ## 3. Write a model ``` @@ -107,8 +117,6 @@ the directory of the nearest config file (`likec4.config.json`, `.like4rc`); and ## Check it worked -## Check it worked - ```sh bun x astro build ``` diff --git a/docs/wiki/guide/Plain-Astro.md b/docs/wiki/guide/Plain-Astro.md new file mode 100644 index 0000000..d1be21c --- /dev/null +++ b/docs/wiki/guide/Plain-Astro.md @@ -0,0 +1,130 @@ +# Plain Astro (no Starlight) + +Astroc4 ships two entry points that share one implementation: + +| Import | Use it in | What it is | +| --- | --- | --- | +| `@purview-dev/astroc4` | `starlight({ plugins: [astroc4()] })` | The Starlight plugin: the integration below **plus** UI-string injection and LikeC4 syntax highlighting. | +| `@purview-dev/astroc4/integration` | `integrations: [astroc4(), …]` | An `AstroIntegration`: the `.c4` compiler, the fence transform, and the dev-mode fixes. | + +Both add the same things to a site — `LikeC4VitePlugin`, the ` ```likec4 ` fence +transform on Astro's Markdown processor, the build-time preload, and +`react/compiler-runtime` in `vite.optimizeDeps.include` — because both call the +file that implements them. Nothing Starlight-specific leaks into a plain site. + +A runnable example lives in [`examples/astro`](https://github.com/purview-dev/astroc4/tree/main/examples/astro): +one page, one model, no Starlight. `bun run dev:astro` serves it on +. + +## The whole configuration + +```js +// astro.config.mjs +import { defineConfig } from 'astro/config'; +import mdx from '@astrojs/mdx'; +import expressiveCode from 'astro-expressive-code'; + +import astroc4 from '@purview-dev/astroc4/integration'; + +export default defineConfig({ + integrations: [astroc4(), expressiveCode(), mdx()], +}); +``` + +`astro add @purview-dev/astroc4` saves you that first line — it writes +`integrations: [astroc4()]`, importing the package's **default export**, which +implements the Astro integration protocol as well as the Starlight plugin one. +`@purview-dev/astroc4/integration` is the same thing without the compatibility +layer, and is what the rest of this page uses. + +Three things are the site's to provide, not the integration's: + +| Integration | Why | +| --- | --- | +| `@astrojs/mdx` | A ` ```likec4 ` fence only becomes a diagram in an `.mdx` page — Astro's rule, not this plugin's. | +| `astro-expressive-code` | Only if you want ` ```c4 ` highlighted. Starlight ships it; plain Astro does not. | +| `@astrojs/markdown-satteri` | Astro 7's default processor, and what the fence transform plugs into. It is a peer dependency, so Bun and npm install it with the package. | + +`@astrojs/react` is not on that list because `astroc4()` adds it: the islands are +`client:only="react"`, so a site without a React renderer builds and then draws +nothing. The integration checks `integrations` for `@astrojs/react` and, if it is +absent, adds it — Astro runs the hooks of integrations added during +`astro:config:setup`, which is the same mechanism Starlight uses for Expressive +Code. Add `react()` yourself if you prefer the config explicit; it will not be +added twice. If the package is not installed (it is a peer dependency, so it +should be), the log says so instead of failing on the first diagram. + +### Ordering + +Only one rule is real: **`astroExpressiveCode()` before `mdx()`**, which +`astro-expressive-code` enforces itself with an error message. The order of +`astroc4()` relative to either does not matter — Astro applies a processor's +mdast plugins and the site's `markdown.rehypePlugins` (which is how Expressive +Code attaches itself when it runs before a processor exists), so highlighting and +fences work in either order. `examples/astro` is built and asserted with +Expressive Code listed *first* to keep that honest. + +## Highlighting + +Starlight has an `expressiveCode` option that the plugin fills in for you. Astro +does not, so a plain site gives Expressive Code the grammar itself: + +```js +// ec.config.mjs +import { defineEcConfig } from 'astro-expressive-code'; +import { likec4Language } from '@purview-dev/astroc4/expressive-code'; + +export default defineEcConfig({ + shiki: { langs: [likec4Language] }, +}); +``` + +`likec4Language` is the grammar snapshot bundled with the package, so this needs +no network. `await loadLikeC4Language({ ref: 'v1.60.0' })` fetches a different +one when the config is asynchronous, and +[Syntax Highlighting](Syntax-Highlighting.md#pinning-the-grammar) covers the +trade-off. + +## What you give up + +- **Translations.** `Astro.locals.t` is Starlight's; without it the components + render English. They are built for that — `getTranslator()` falls back to + English rather than throwing — so nothing breaks, it just does not translate. +- **The `highlighting` option.** There is no `expressiveCode` option to inject + into; use `ec.config.mjs` above. + +`astro add` is not on that list: it writes `integrations: [astroc4()]` from the +package's default export, which works here. It cannot add the other integrations +or the `ec.config.mjs`, so the config above is still what you end up with — and in +a *Starlight* site it writes the wrong shape, which the plugin warns about. + +## Styling without Starlight's tokens + +The components are styled from `--sl-color-*` where it exists, and from their own +`--likec4-*` fallbacks otherwise — translucent greys and `inherit`, so the frame +is legible on light and dark pages alike. On Starlight nothing changes; on a plain +site the chrome is neutral until you override it: + +```css +.likec4-shell { + --likec4-border: #d4d4d8; + --likec4-surface: #ffffff; + --likec4-surface-alt: #f6f6f7; + --likec4-text: #18181b; + --likec4-text-muted: #52525b; + --likec4-accent: #0046be; +} +``` + +[Components](Components.md#styling) lists every class and token. + +## Proving it works + +`bun run test:site` packs the tarball and builds **two** consumer sites from it: +a Starlight one and a plain-Astro one copied from `examples/astro`. Both go +through `astro check`, `astro build`, and a browser on their own dev server that +waits for a rendered element node, fails if the loading placeholder survives, and +fails on any console error. A green `astro build` alone is not proof that a +diagram renders — see [Troubleshooting](../Troubleshooting.md#diagrams-stay-blank-while-the-build-passes). + +Back to the [guide index](README.md). diff --git a/docs/wiki/guide/README.md b/docs/wiki/guide/README.md index 9f19180..a5f0372 100644 --- a/docs/wiki/guide/README.md +++ b/docs/wiki/guide/README.md @@ -5,6 +5,7 @@ Everything you need to put diagrams on a page, then to shape them. | Page | What it answers | | --- | --- | | [Getting Started](Getting-Started.md) | What do I type to see a diagram? Four files, then four ways to check it worked. | +| [Plain Astro](Plain-Astro.md) | No Starlight: the `@purview-dev/astroc4/integration` entry point, and the four things the site supplies. | | [Fences](Fences.md) | The ` ```likec4 ` forms, their meta options, and what the errors mean. | | [Components](Components.md) | ``: props, styling, the client islands behind it. | | [Syntax Highlighting](Syntax-Highlighting.md) | How the grammar is fetched, derived, cached and validated — and how to pin it. | diff --git a/examples/astro/astro.config.mjs b/examples/astro/astro.config.mjs new file mode 100644 index 0000000..b05c4a3 --- /dev/null +++ b/examples/astro/astro.config.mjs @@ -0,0 +1,36 @@ +// astro.config.mjs +// path: examples/astro/astro.config.mjs +// Plain Astro, no Starlight: the smallest site that renders a LikeC4 diagram, +// kept as a runnable example and as the thing `bun run check` type-checks. +// +// bun run dev:astro # http://localhost:4322 +// bun run build:astro +// +// Every specifier here is the one an installed site writes. Inside this +// repository they resolve through Node's package self-reference, because the +// repository root *is* the package. +// +// What the site adds itself: +// - `@astrojs/mdx` — ```likec4 fences only become diagrams in `.mdx` pages; +// - `astro-expressive-code` + `ec.config.mjs` — LikeC4 source highlighting. +// Starlight has an `expressiveCode` option the plugin fills in; Astro does +// not, so the site configures Expressive Code itself. +// +// There is no `react()` here on purpose: `astroc4()` adds `@astrojs/react` when +// the site has no React renderer, because the islands are `client:only="react"` +// and nothing renders without one. Add it yourself if you prefer it explicit. +// +// `astroExpressiveCode()` must come before `mdx()` — that is Expressive Code's +// own requirement, and it says so if you get it wrong. Its position relative to +// `astroc4()` does not matter, so this example puts it *first*: the harder order +// is the one worth building on every run. + +import { defineConfig } from 'astro/config'; +import mdx from '@astrojs/mdx'; +import expressiveCode from 'astro-expressive-code'; + +import astroc4 from '@purview-dev/astroc4/integration'; + +export default defineConfig({ + integrations: [expressiveCode(), astroc4(), mdx()], +}); diff --git a/examples/astro/ec.config.mjs b/examples/astro/ec.config.mjs new file mode 100644 index 0000000..95f0bf4 --- /dev/null +++ b/examples/astro/ec.config.mjs @@ -0,0 +1,14 @@ +// ec.config.mjs +// path: examples/astro/ec.config.mjs +// Expressive Code's own config file — the plain-Astro replacement for +// Starlight's `expressiveCode` option, where the Starlight plugin injects the +// LikeC4 grammar for you. `likec4Language` is the bundled snapshot, so this +// needs no network; `loadLikeC4Language({ ref })` fetches a different one. + +import { defineEcConfig } from 'astro-expressive-code'; + +import { likec4Language } from '@purview-dev/astroc4/expressive-code'; + +export default defineEcConfig({ + shiki: { langs: [likec4Language] }, +}); diff --git a/examples/astro/src/likec4/likec4.config.json b/examples/astro/src/likec4/likec4.config.json new file mode 100644 index 0000000..a8e0306 --- /dev/null +++ b/examples/astro/src/likec4/likec4.config.json @@ -0,0 +1,4 @@ +{ + "name": "astro-example", + "title": "astroc4 — plain Astro example" +} diff --git a/examples/astro/src/likec4/model.c4 b/examples/astro/src/likec4/model.c4 new file mode 100644 index 0000000..b523dfe --- /dev/null +++ b/examples/astro/src/likec4/model.c4 @@ -0,0 +1,19 @@ +// model.c4 +// path: examples/astro/src/likec4/model.c4 +// The model behind the page's . LikeC4's Vite plugin +// compiles every .c4 project under the site root into `likec4:model`. + +specification { + element system + element person +} + +model { + reader = person 'Reader' + docs = system 'Docs site' + reader -> docs 'reads' +} + +views { + view index { include * } +} diff --git a/examples/astro/src/pages/index.mdx b/examples/astro/src/pages/index.mdx new file mode 100644 index 0000000..432f710 --- /dev/null +++ b/examples/astro/src/pages/index.mdx @@ -0,0 +1,48 @@ +--- +title: LikeC4 in plain Astro +description: The astroc4 integration with no Starlight in the site. +--- + +import LikeC4View from '@purview-dev/astroc4/components/LikeC4View.astro'; + +# LikeC4 in plain Astro + +This page is a real site: `astro.config.mjs` has `astroc4()` from +`@purview-dev/astroc4/integration`, `@astrojs/react`, `@astrojs/mdx` and +`astro-expressive-code`. There is no Starlight here — the diagrams, the fence +transform and the highlighting are the same code the Starlight plugin uses. + +## A view from the model + +`src/likec4/model.c4` is compiled by LikeC4's Vite plugin, which the integration +adds for you. + + + +## A fence, compiled at build time + +` ```likec4 ` becomes a diagram in any `.mdx` page — the fence carries its own +model, so no project is needed. + +```likec4 title="Inline, from the fence" height=240 +specification { element box } +model { + a = box 'Alpha' + b = box 'Beta' + a -> b 'calls' +} +views { view index { include * } } +``` + +## LikeC4 source stays source + +` ```c4 ` is highlighted by Expressive Code with the grammar from `ec.config.mjs`. +Outside Starlight there is no `expressiveCode` option for the plugin to fill in, +so that file is the one thing the site does itself. + +```c4 +specification { + color brand #0046BE + element system { style { shape agent color brand } } +} +``` diff --git a/examples/astro/tsconfig.json b/examples/astro/tsconfig.json new file mode 100644 index 0000000..a27515a --- /dev/null +++ b/examples/astro/tsconfig.json @@ -0,0 +1,17 @@ +{ + "extends": "astro/tsconfigs/strict", + "compilerOptions": { + "strictNullChecks": true, + "noUncheckedIndexedAccess": true, + // LikeC4's renderer is React 19, so React is the only JSX runtime. + "jsx": "react-jsx", + "jsxImportSource": "react", + "resolveJsonModule": true + }, + // `../../src` so this site's `astro check` also type-checks the package: it is + // the only place the components are checked *without* Starlight's `App.Locals` + // augmentation, which is what a plain Astro consumer sees. `test:site` strips + // these two entries when it copies the example to a temporary site. + "include": [".astro/types.d.ts", "**/*", "../../src/components/**/*", "../../src/types.d.ts"], + "exclude": ["dist"] +} diff --git a/package.json b/package.json index b30f2b6..12ba51c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@purview-dev/astroc4", - "version": "1.0.0-prerelease.4", + "version": "1.0.0-prerelease.5", "description": "Interactive LikeC4 architecture diagrams for Astro and Starlight: , ```likec4 fences, and LikeC4 syntax highlighting derived from the live Langium grammar.", "license": "MIT", "author": { @@ -21,8 +21,10 @@ "architecture-as-code", "architecture-diagrams", "diagrams", + "ui", "astro", "astro-integration", + "withastro", "starlight", "starlight-plugin", "expressive-code", @@ -36,6 +38,10 @@ "types": "./dist/index.d.ts", "import": "./dist/index.js" }, + "./integration": { + "types": "./dist/integration.d.ts", + "import": "./dist/integration.js" + }, "./expressive-code": { "types": "./dist/expressive-code.d.ts", "import": "./dist/expressive-code.js" @@ -56,7 +62,9 @@ "dev": "bun run build:lib && cd playground && astro dev", "build": "bun run build:lib && cd playground && astro build", "preview": "cd playground && astro preview", - "check": "bun run build:lib && cd playground && astro check && cd .. && bunx tsc -p tsconfig.scripts.json --noEmit", + "dev:astro": "bun run build:lib && cd examples/astro && astro dev", + "build:astro": "bun run build:lib && cd examples/astro && astro build", + "check": "bun run build:lib && cd playground && astro check && cd ../examples/astro && astro check && cd ../.. && bunx tsc -p tsconfig.scripts.json --noEmit", "test": "vitest run", "test:watch": "vitest", "test:network": "ASTROC4_NETWORK=1 vitest run src/grammar/__tests__/network.test.ts", @@ -77,15 +85,22 @@ "react-dom": ">=19.0.0", "shiki": ">=1.0.0" }, + "peerDependenciesMeta": { + "@astrojs/starlight": { + "optional": true + } + }, "devDependencies": { "@astrojs/check": "^0.9.12", "@astrojs/markdown-satteri": "^0.4.6", + "@astrojs/mdx": "^8.1.2", "@astrojs/react": "^7.1.2", "@astrojs/starlight": "^0.42.8", "@likec4/core": "^1.59.4", "@types/react": "^19.3.0", "@types/react-dom": "^19.3.0", "astro": "^7.4.4", + "astro-expressive-code": "^0.44.3", "likec4": "^1.59.4", "playwright": "^1.63.0", "react": "^19.3.0", diff --git a/scripts/build.ts b/scripts/build.ts index 2e582f4..f5a6ec2 100644 --- a/scripts/build.ts +++ b/scripts/build.ts @@ -33,13 +33,14 @@ function run(command: string, args: string[]): void { await rm(DIST, { recursive: true, force: true }); -// 1. Bundle. Two entry points, ESM, Node target. `--packages external` keeps +// 1. Bundle. Three entry points, ESM, Node target. `--packages external` keeps // every dependency out of the bundle — they are peer dependencies, so they must // resolve from the site, not from here. `import.meta.url` survives bundling // into `dist/index.js`, which is why the data files land in `dist/` below. run('bun', [ 'build', 'src/index.ts', + 'src/integration.ts', 'src/expressive-code.ts', '--root', 'src', @@ -71,10 +72,13 @@ for (const file of DATA) { await readFile(join(DIST, file)); } -const bundle = await readFile(join(DIST, 'index.js'), 'utf8'); +// A `.ts` specifier left in a bundle only fails when a site's config imports it. +for (const entry of ['index.js', 'integration.js', 'expressive-code.js']) { + const bundle = await readFile(join(DIST, entry), 'utf8'); -if (/from ['"]\.\/[^'"]+\.ts['"]/.test(bundle)) { - throw new Error('the bundle still imports a .ts specifier — it would fail under Node'); + if (/from ['"]\.\/[^'"]+\.ts['"]/.test(bundle)) { + throw new Error(`${entry} still imports a .ts specifier — it would fail under Node`); + } } console.log('built dist/ (bundle, declarations, grammar data)'); diff --git a/scripts/hydration.ts b/scripts/hydration.ts new file mode 100644 index 0000000..9f404bf --- /dev/null +++ b/scripts/hydration.ts @@ -0,0 +1,177 @@ +// hydration.ts +// path: scripts/hydration.ts +// Browser check for the one thing a static build cannot prove: that a +// `` island hydrates and draws nodes. +// +// bun scripts/hydration.ts http://localhost:4321 +// bun scripts/hydration.ts http://localhost:4321 /fences/ +// +// `scripts/test-site.ts` points this at a throwaway Starlight site's *dev* +// server on purpose: the failure it guards against +// +// The requested module '/node_modules/react/compiler-runtime.js' does not +// provide an export named 'c' +// +// only happens in dev, where Vite serves an unoptimized CommonJS dependency +// raw. `astro build` bundles it correctly, so a green build is not proof that a +// diagram renders — only a browser is. + +import { resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { chromium } from 'playwright'; + +/** + * A LikeC4 element node. LikeC4 renders into a shadow root, which Playwright + * locators pierce and `querySelectorAll` does not. + */ +export const NODE = '[data-likec4-node], .react-flow__node'; + +/** The server-rendered stand-in that hydration replaces. */ +export const LOADING = '.likec4-loading'; + +/** Dev-server chatter that is not a defect. */ +export const IGNORED_CONSOLE = [/Outdated Optimize Dep/i, /\[vite\] (connecting|connected)/i]; + +/** Everything {@link checkHydration} accepts. */ +export interface HydrationOptions { + /** Path to load, relative to the site root. Defaults to `/`. */ + path?: string; + /** Exact number of diagram frames the page should contain. Defaults to "at least one". */ + expected?: number; + /** Element labels that must be readable after hydration. */ + labels?: string[]; + /** + * Also assert the diagram's own chrome is styled — a non-transparent border on + * the frame's shell. That is what tells a plain Astro site (no Starlight + * tokens) apart from one whose styles silently resolved to nothing. + */ + styling?: boolean; + /** Milliseconds to wait for the first node — a cold dev compile is slow. */ + timeout?: number; +} + +/** What {@link checkHydration} found. */ +export interface HydrationResult { + /** Element nodes drawn into the first frame; `0` means it never hydrated. */ + nodes: number; + /** Human-readable problems; empty means every frame hydrated cleanly. */ + failures: string[]; +} + +/** + * Load a page, wait for its diagram to draw, and report what could not be + * proven. Console errors fail the check: the React Compiler runtime error is + * only ever reported there, never as a server error. + */ +export async function checkHydration( + baseUrl: string, + options: HydrationOptions = {}, +): Promise { + const { path = '/', expected, labels = [], styling = false, timeout = 45_000 } = options; + const failures: string[] = []; + + const browser = await chromium.launch(); + + try { + const page = await browser.newPage(); + + page.on('pageerror', (error) => failures.push(`${path}: pageerror: ${error.message}`)); + page.on('console', (message) => { + if (message.type() !== 'error') return; + + const text = message.text(); + if (IGNORED_CONSOLE.some((pattern) => pattern.test(text))) return; + + failures.push(`${path}: console.error: ${text}`); + }); + + await page.goto(new URL(path, baseUrl).href, { waitUntil: 'networkidle' }); + + const frames = page.locator('.likec4-frame'); + const frameCount = await frames.count(); + + if (expected === undefined && frameCount === 0) { + failures.push(`${path}: no diagram frame at all — the component never rendered`); + } else if (expected !== undefined && frameCount !== expected) { + failures.push(`${path}: ${frameCount} diagram frame(s), expected ${expected}`); + } + + const frame = frames.first(); + let nodes = 0; + + try { + await frame.locator(NODE).first().waitFor({ state: 'attached', timeout }); + nodes = await frame.locator(NODE).count(); + } catch { + failures.push( + `${path}: no LikeC4 element node rendered within ${timeout}ms — the island never hydrated`, + ); + } + + // The placeholder is the island's `slot="fallback"`: if it is still + // painted, the browser is looking at the server-rendered stand-in. + const placeholder = frame.locator(LOADING); + if ((await placeholder.count()) > 0 && (await placeholder.first().isVisible())) { + failures.push(`${path}: the loading placeholder is still visible — the island never hydrated`); + } + + for (const label of labels) { + try { + await frame.getByText(label).first().waitFor({ state: 'visible', timeout: 15_000 }); + } catch { + failures.push(`${path}: the element label "${label}" never rendered`); + } + } + + if (styling) { + // The shell is the frame's `section`; a Starlight site styles it from + // `--sl-color-*`, a plain Astro site from the components' fallbacks — + // either way a transparent border means the styles never applied. + const border = await frame.evaluate((element) => { + const target = element.closest('section') ?? element; + + return getComputedStyle(target).borderTopColor; + }); + + if (/transparent|rgba\(0, 0, 0, 0\)/.test(border)) { + failures.push(`${path}: the diagram frame is unstyled (border ${border})`); + } + } + + return { nodes, failures }; + } finally { + await browser.close(); + } +} + +// ── CLI ───────────────────────────────────────────────────────────────────── +// `bun scripts/hydration.ts [path]`, for poking at a running site. +async function main(): Promise { + const [baseUrl = 'http://localhost:4321', path = '/'] = process.argv.slice(2); + + let result: HydrationResult; + + try { + result = await checkHydration(baseUrl, { path }); + } catch (error) { + // A missing browser is a setup problem, not a broken site; say which. + console.error(`\n✗ could not run a browser against ${baseUrl}: ${(error as Error).message}`); + console.error(' Is Chromium installed? `bunx playwright install chromium`'); + process.exit(1); + } + + if (result.failures.length > 0) { + console.error(`\n✗ ${path} did not hydrate:`); + for (const failure of result.failures) console.error(` - ${failure}`); + process.exit(1); + } + + console.log(`✓ ${path} hydrated and drew ${result.nodes} element node(s)`); +} + +const entry = process.argv[1]; + +if (entry && resolve(entry) === fileURLToPath(import.meta.url)) { + await main(); +} diff --git a/scripts/smoke.ts b/scripts/smoke.ts index 0e92997..ea8854f 100644 --- a/scripts/smoke.ts +++ b/scripts/smoke.ts @@ -10,12 +10,11 @@ import { chromium } from 'playwright'; +import { IGNORED_CONSOLE, NODE } from './hydration.js'; + const baseUrl = process.argv[2] ?? 'http://localhost:4321'; const failures = []; -/** Dev-server noise, not defects. */ -const IGNORED_CONSOLE = [/Outdated Optimize Dep/i, /\[vite\] (connecting|connected)/i]; - const browser = await chromium.launch(); const page = await browser.newPage(); @@ -30,8 +29,6 @@ page.on('console', (message) => { failures.push(`console.error: ${text}`); }); -const NODE = '[data-likec4-node], .react-flow__node'; - let packIconCount = 0; const imageIconSrcs = new Set(); @@ -69,6 +66,14 @@ async function checkDiagrams(pathname: string, { expected }: { expected: number continue; } + // Nodes with the island's `slot="fallback"` still painted would mean the + // frame is showing the server-rendered stand-in, not the diagram. + const placeholder = frame.locator('.likec4-loading'); + if ((await placeholder.count()) > 0 && (await placeholder.first().isVisible())) { + failures.push(`${pathname} — "${label}" still shows the loading placeholder`); + continue; + } + // LikeC4 renders into a shadow root; locators pierce it, querySelectorAll does not. packIconCount += await frame.locator('svg').count(); diff --git a/scripts/test-site.ts b/scripts/test-site.ts index e143ce0..393372a 100644 --- a/scripts/test-site.ts +++ b/scripts/test-site.ts @@ -1,35 +1,54 @@ -// test-site.mjs -// path: scripts/test-site.mjs -// Builds a *consumer* Starlight site from the packed tarball: `npm pack`, a -// throwaway site outside this repository, `bun install` from the registry, -// `astro build`, then assertions on the generated HTML. +// test-site.ts +// path: scripts/test-site.ts +// Packs the package and exercises it the way a user installs it: `npm pack`, two +// throwaway sites outside this repository — a Starlight one copied from +// `playground/`, a plain-Astro one copied from `examples/astro/` — `bun install` +// from the registry, `astro check`, `astro build`, then a browser. // -// The playground proves the plugin works from source. This proves the thing -// that actually ships works once installed — the package boundary (exports, -// `files`, declaration files, peer dependencies) is exactly where "works in the -// repo" and "works for a user" diverge. +// The playground proves the plugin works from source. This proves the thing that +// actually ships works once installed — the package boundary (`exports`, `files`, +// declaration files, peer dependencies, both entry points) is exactly where +// "works in the repo" and "works for a user" diverge. // -// bun run test:site # build and assert -// bun run test:site --keep # keep the temp site for debugging -// bun run test:site --no-check # skip the consumer `astro check` +// The Starlight copy exists so the **full smoke suite** (`scripts/smoke.ts`: +// hydration, drawn nodes, icons, highlighting colours, the loading placeholder, +// both locales) runs against the tarball on a preview server, not against the +// repository's self-reference. The plain-Astro copy covers the second entry +// point: `Astro.locals.t` falling back to English, chrome from the components' +// own palette, `ec.config.mjs` highlighting, and the config `astro add` writes. // -// Network: the site installs packages and the plugin resolves the grammar from +// A green `astro build` is not proof that a diagram renders: `astro build` +// bundles `react/compiler-runtime` correctly, while dev served it raw and left +// every island throwing "does not provide an export named 'c'". Only a browser +// catches that, which is why every site here ends in Chromium. +// +// bun run test:site # pack, build and smoke both consumer sites +// bun run test:site --keep # keep the temp sites for debugging +// bun run test:site --no-check # skip the consumers' `astro check` +// bun run test:site --no-hydrate # skip the browser checks (smoke + hydration) +// +// Network: the sites install packages and the plugin resolves the grammar from // likec4/likec4, so this is not hermetic. Set ASTROC4_OFFLINE=1 to use the // bundled grammar instead (CI does not, on purpose: the fetch is part of what -// is being tested). +// is being tested). The browser checks need Chromium: +// `bunx playwright install chromium`. -import { execFileSync } from 'node:child_process'; +import { execFileSync, spawn } from 'node:child_process'; import { cpSync, existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync, mkdirSync } from 'node:fs'; +import { createServer } from 'node:net'; import { tmpdir } from 'node:os'; import { dirname, join, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; +import { checkHydration } from './hydration.js'; + const root = resolve(fileURLToPath(new URL('..', import.meta.url))); const repo = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8')); const args = process.argv.slice(2); const keep = args.includes('--keep'); const runCheck = !args.includes('--no-check'); +const hydrate = !args.includes('--no-hydrate'); const failures: string[] = []; const step = (message: string): void => console.log(`\n» ${message}`); @@ -61,6 +80,100 @@ function astroCli(where: string): string { return join(directory, typeof bin === 'string' ? bin : bin.astro); } +/** A running dev server, and the way to stop it again. */ +interface DevServer { + url: string; + stop: () => Promise; +} + +/** An unused port, so this never fights the developer's own dev server. */ +function freePort(): Promise { + return new Promise((done, fail) => { + const probe = createServer(); + + probe.on('error', fail); + probe.listen(0, '127.0.0.1', () => { + const address = probe.address(); + const port = typeof address === 'object' && address ? address.port : 0; + + probe.close(() => (port > 0 ? done(port) : fail(new Error('could not find a free port')))); + }); + }); +} + +/** + * Start one of the consumer site's own servers and wait until it answers. + * + * `dev` is the only way to see the failure the hydration check guards against: + * `astro build` bundles `react/compiler-runtime` correctly, so a green build + * proves nothing about the browser. `preview` serves the built output, which is + * what the smoke suite runs against — the artifact, not a dev transform. + */ +async function startServer( + where: string, + { mode = 'dev', timeoutMs = 180_000 }: { mode?: 'dev' | 'preview'; timeoutMs?: number } = {}, +): Promise { + const port = await freePort(); + const child = spawn('node', [astroCli(where), mode, '--port', String(port), '--host', '127.0.0.1'], { + cwd: where, + stdio: ['ignore', 'pipe', 'pipe'], + env: { ...process.env, NO_COLOR: '1', FORCE_COLOR: '0' }, + windowsHide: true, + }); + + // The server's own output is the only explanation for a timeout. + const state = { exited: null as number | null }; + let log = ''; + const record = (chunk: Buffer): void => { + log += chunk.toString(); + }; + + child.stdout?.on('data', record); + child.stderr?.on('data', record); + child.on('exit', (code) => { + state.exited = code ?? -1; + }); + + const stop = async (): Promise => { + if (state.exited !== null) return; + + child.kill(); + + // Windows takes a moment to release the port and the temp directory. + await new Promise((done) => { + const timer = setTimeout(done, 10_000); + + child.once('exit', () => { + clearTimeout(timer); + done(); + }); + }); + }; + + const url = `http://127.0.0.1:${port}`; + const deadline = Date.now() + timeoutMs; + + while (Date.now() < deadline) { + if (state.exited !== null) { + throw new Error(`the dev server exited (code ${state.exited}) before answering:\n${log}`); + } + + try { + // The first request is what makes Astro compile the page and LikeC4 + // compile the model, so this can take a while. + const response = await fetch(`${url}/`, { signal: AbortSignal.timeout(15_000) }); + if (response.ok) return { url, stop }; + } catch { + // Not listening yet. + } + + await new Promise((done) => setTimeout(done, 500)); + } + + await stop(); + throw new Error(`the dev server did not answer on ${url} within ${timeoutMs / 1000}s:\n${log}`); +} + // ── Pack ──────────────────────────────────────────────────────────────────── step(`Toolchain: node ${process.version}, bun ${execFileSync('bun', ['--version'], { encoding: 'utf8' }).trim()}`); @@ -89,13 +202,32 @@ if (!tarballName) throw new Error('npm pack produced no tarball name'); const tarball = join(packed, tarballName); done(`packed ${tarballName} (${readFileSync(tarball).length} bytes)`); -// ── Scaffold a consumer site ──────────────────────────────────────────────── +// ── A packed Starlight consumer: the playground, installed from the tarball ── +// Copying `playground/` rather than hand-writing a page means the *full* smoke +// suite — diagrams, icons, highlighting, placeholder, i18n — runs against what a +// user installs, not against this repository's self-reference. const site = join(work, 'site'); -const src = join(site, 'src'); -for (const directory of [src, join(src, 'content', 'docs'), join(src, 'likec4')]) { - mkdirSync(directory, { recursive: true }); -} +cpSync(join(root, 'playground'), site, { + recursive: true, + // Build output, generated types and the dev cache, if the playground ran here. + filter: (source) => !/[\\/](node_modules|dist|\.astro)([\\/]|$)/.test(source), +}); + +// The playground imports `../dist/index.js` — the built file beside it. A +// consumer imports the package name, and so must the copy. +const siteConfig = join(site, 'astro.config.mjs'); +writeFileSync( + siteConfig, + readFileSync(siteConfig, 'utf8').replace( + "import starlightLikeC4 from '../dist/index.js';", + `import starlightLikeC4 from '${repo.name}';`, + ), +); + +// ...and its tsconfig type-checks `../src`, which has no counterpart in a copy. +const siteTsconfig = join(site, 'tsconfig.json'); +writeFileSync(siteTsconfig, readFileSync(siteTsconfig, 'utf8').replace(/,\s*"\.\.\/src\/\*\*\/\*"/, '')); // The peer range the package publishes is what a user would install; take the // versions this repository itself builds against, so the test cannot drift. @@ -119,115 +251,6 @@ const siteManifest = { writeFileSync(join(site, 'package.json'), `${JSON.stringify(siteManifest, null, 2)}\n`); -writeFileSync( - join(site, 'tsconfig.json'), - `${JSON.stringify( - { - extends: 'astro/tsconfigs/strict', - compilerOptions: { jsx: 'react-jsx', jsxImportSource: 'react', resolveJsonModule: true }, - include: ['.astro/types.d.ts', '**/*'], - exclude: ['dist'], - }, - null, - 2, - )}\n`, -); - -writeFileSync( - join(site, 'astro.config.mjs'), - `import { defineConfig } from 'astro/config'; -import starlight from '@astrojs/starlight'; -import react from '@astrojs/react'; - -import astroc4 from '${repo.name}'; - -// No ec.config.mjs: zero-configuration highlighting is the claim under test. -export default defineConfig({ - integrations: [starlight({ title: 'astroc4 site test', plugins: [astroc4()] }), react()], -}); -`, -); - -writeFileSync( - join(src, 'content.config.ts'), - `import { defineCollection } from 'astro:content'; -import { docsLoader } from '@astrojs/starlight/loaders'; -import { docsSchema } from '@astrojs/starlight/schema'; - -export const collections = { docs: defineCollection({ loader: docsLoader(), schema: docsSchema() }) }; -`, -); - -writeFileSync( - join(src, 'likec4', 'demo.c4'), - `specification { - element system - element person -} -model { - reader = person 'Reader' - docs = system 'Docs site' - reader -> docs 'reads' -} -views { - view index { include * } -} -`, -); - -writeFileSync( - join(src, 'likec4', 'likec4.config.json'), - `{ "name": "site-test", "title": "astroc4 site test" }\n`, -); - -// Nothing here is imported at runtime; it exists so the consumer's `astro -// check` resolves the declaration file for `@purview-dev/astroc4/expressive-code` (and the -// `shiki` types it refers to) from an installed package. -writeFileSync( - join(src, 'types-probe.ts'), - `import { likec4Language } from '${repo.name}/expressive-code'; - -export const probe: string = likec4Language.name; -`, -); - -// The page exercises every entry point: a component island, an inline fence, a -// project view fence, and code that must stay code. -writeFileSync( - join(src, 'content', 'docs', 'index.mdx'), - `--- -title: Consumer site test -description: A real Starlight site built against the packed astroc4 tarball. ---- - -import { Code } from '@astrojs/starlight/components'; -import LikeC4View from '${repo.name}/components/LikeC4View.astro'; - - - -\`\`\`likec4 title="Inline" height=200 -specification { element box } -model { - a = box 'Alpha' - b = box 'Beta' - a -> b 'calls' -} -views { view index { include * } } -\`\`\` - -\`\`\`likec4 view="index" title="From the model" height=200 -\`\`\` - -\`\`\`c4 -specification { - color brand #0046BE - element system { style { shape agent color brand } } -} -\`\`\` - - -`, -); // ── Install and build ─────────────────────────────────────────────────────── step('Installing the consumer site (registry + packed tarball)'); @@ -242,6 +265,8 @@ step('Checking the installed package'); for (const file of [ 'dist/index.js', 'dist/index.d.ts', + 'dist/integration.js', + 'dist/integration.d.ts', 'dist/expressive-code.js', 'dist/expressive-code.d.ts', 'dist/likec4.tmLanguage.json', @@ -275,8 +300,8 @@ run('node', [astroCli(site), 'build'], { cwd: site }); const html = readFileSync(join(site, 'dist', 'index.html'), 'utf8'); /** Distinct Shiki colour variables inside a `
` block. */
-function coloursOf(language: string): number | undefined {
-	const block = new RegExp(`
]*>([\\s\\S]*?)
`).exec(html); +function coloursOf(source: string, language: string): number | undefined { + const block = new RegExp(`
]*>([\\s\\S]*?)
`).exec(source); if (!block) return undefined; const body = block[1] ?? ''; @@ -287,40 +312,223 @@ function coloursOf(language: string): number | undefined { // Astro scopes component styles, so the class attribute carries a hash: // match the class *list*, not `class="likec4-frame"`, and not the CSS, which // also contains the name. -const frames = (html.match(/class="[^"]*\blikec4-frame\b[^"]*"/g) ?? []).length; -if (frames !== 3) failures.push(`expected 3 diagrams (one island, two fences), found ${frames}`); -else done(`the island and both fences rendered (${frames} diagrams)`); - -// The ```likec4 fence must not survive as a highlighted block; the only -// `likec4`-language block on the page is the explicit . -const likec4Blocks = (html.match(/
 block, found ${likec4Blocks}`);
-else done('```likec4 fences became diagrams, not code blocks');
-
-const c4Colours = coloursOf('c4');
-if (!c4Colours || c4Colours < 4) failures.push(`\`\`\`c4 highlighted in ${c4Colours ?? 0} colours, expected ≥ 4`);
-else done(`\`\`\`c4 highlighted (${c4Colours} colours)`);
-
-const likec4Colours = coloursOf('likec4');
-if (!likec4Colours || likec4Colours < 4) {
-	failures.push(` highlighted in ${likec4Colours ?? 0} colours, expected ≥ 4`);
+const framesIn = (source: string): number =>
+	(source.match(/class="[^"]*\blikec4-frame\b[^"]*"/g) ?? []).length;
+
+const frames = framesIn(html);
+if (frames !== 5) failures.push(`expected the playground's 5 diagrams on /, found ${frames}`);
+else done(`the packed site's home page has ${frames} diagrams`);
+
+const fenceFrames = framesIn(readFileSync(join(site, 'dist', 'fences', 'index.html'), 'utf8'));
+if (fenceFrames !== 3) failures.push(`expected 3 diagrams on /fences/, found ${fenceFrames}`);
+else done(`/fences/ has ${fenceFrames} diagrams`);
+
+// ── The smoke suite, against the packed site ────────────────────────────────
+// Everything above is build output, and a green build is not proof that a
+// diagram renders. This is the browser — hydration, node drawing, icons,
+// highlighting colours, the loading placeholder, both locales — running against
+// the *installed* package on a preview server, i.e. what a user gets.
+if (hydrate) {
+	step('Running the smoke suite against the packed site');
+
+	let smokeServer: DevServer | undefined;
+
+	try {
+		smokeServer = await startServer(site, { mode: 'preview' });
+		run('bun', ['scripts/smoke.ts', smokeServer.url], { cwd: root });
+		done('the smoke suite passed against the packed tarball');
+	} catch (error) {
+		// `smoke.ts` has already printed which assertions failed.
+		failures.push(`smoke suite against the packed site: ${(error as Error).message.split('\n')[0]}`);
+	} finally {
+		await smokeServer?.stop();
+	}
 } else {
-	done(` highlighted (${likec4Colours} colours)`);
+	console.log('· skipped the smoke suite (--no-hydrate)');
 }
 
-for (const label of ['Alpha', 'Beta', 'calls']) {
-	// The fence embeds the layouted model as a serialised island prop, so the
-	// label reaches the HTML escaped — match the text, not the quoting.
-	if (!html.includes(label)) failures.push(`the inline fence's model is missing the "${label}" node`);
+// ── A plain-Astro consumer, with no Starlight at all ────────────────────────
+// `examples/astro` is copied in, installed and built: the checked-in example is
+// literally the thing under test, so it cannot rot. This is the fixture for the
+// `@purview-dev/astroc4/integration` entry point — components whose
+// `Astro.locals.t` falls back to English, and chrome styled from the components'
+// own fallbacks rather than Starlight's `--sl-color-*` tokens.
+const plain = join(work, 'astro-site');
+
+step('Scaffolding the plain-Astro consumer (no Starlight)');
+
+cpSync(join(root, 'examples', 'astro'), plain, {
+	recursive: true,
+	// Build output and generated types, if the example was run in the repo.
+	filter: (source) => !/[\\/](dist|\.astro)([\\/]|$)/.test(source),
+});
+
+// The example's `tsconfig.json` also type-checks the package source (see the
+// comment there). Those paths do not exist beside a copy, so drop them.
+const plainTsconfig = join(plain, 'tsconfig.json');
+writeFileSync(
+	plainTsconfig,
+	readFileSync(plainTsconfig, 'utf8').replace(
+		/, "..\/..\/src\/components\/\*\*\/\*", "..\/..\/src\/types\.d\.ts"/,
+		'',
+	),
+);
+
+writeFileSync(
+	join(plain, 'package.json'),
+	`${JSON.stringify(
+		{
+			name: 'astroc4-astro-site-test',
+			private: true,
+			type: 'module',
+			dependencies: {
+				'@astrojs/markdown-satteri': repo.devDependencies['@astrojs/markdown-satteri'],
+				'@astrojs/mdx': repo.devDependencies['@astrojs/mdx'],
+				'@astrojs/react': repo.devDependencies['@astrojs/react'],
+				'@likec4/core': repo.devDependencies['@likec4/core'],
+				astro: repo.devDependencies.astro,
+				'astro-expressive-code': repo.devDependencies['astro-expressive-code'],
+				likec4: repo.devDependencies.likec4,
+				react: repo.devDependencies.react,
+				'react-dom': repo.devDependencies['react-dom'],
+				[repo.name]: `file:${tarball}`,
+			},
+			devDependencies: runCheck
+				? { '@astrojs/check': repo.devDependencies['@astrojs/check'], typescript: repo.devDependencies.typescript }
+				: {},
+		},
+		null,
+		2,
+	)}\n`,
+);
+
+step('Installing the plain-Astro site (registry + packed tarball)');
+run('bun', ['install'], { cwd: plain });
+done('installed the Astro-only dependencies');
+
+if (runCheck) {
+	step('Type-checking the plain-Astro site');
+	run('node', [astroCli(plain), 'check'], { cwd: plain });
+	done('astro check passed without Starlight’s App.Locals');
+}
+
+step('Building the plain-Astro site');
+run('node', [astroCli(plain), 'build'], { cwd: plain });
+
+const plainHtml = readFileSync(join(plain, 'dist', 'index.html'), 'utf8');
+const plainFrames = framesIn(plainHtml);
+
+// The island, then the inline fence: no Starlight page structure to count.
+if (plainFrames !== 2) failures.push(`plain Astro: expected 2 diagrams, found ${plainFrames}`);
+else done(`plain Astro rendered the island and the fence (${plainFrames} diagrams)`);
+
+if (/
 0) {
+			failures.push(...hydrationFailures.map((failure) => `plain Astro dev server: ${failure}`));
+		} else {
+			done(`plain Astro:  hydrated and drew ${nodes} nodes`);
+		}
+	} catch (error) {
+		const message = (error as Error).message;
+
+		failures.push(
+			/executable doesn't exist|playwright/i.test(message)
+				? `plain Astro dev server: ${message} (run \`bunx playwright install chromium\`)`
+				: `plain Astro dev server: ${message}`,
+		);
+	} finally {
+		await server?.stop();
+	}
+} else {
+	console.log('· skipped the plain-Astro hydration check (--no-hydrate)');
+}
+
+// ── The shape `astro add` writes ────────────────────────────────────────────
+// The CLI reads only npm metadata — the `astro-integration` keyword, the default
+// export — so it writes `import astroc4 from '@purview-dev/astroc4'` and
+// `integrations: [astroc4()]`. The default export carries the Astro protocol for
+// exactly this case, so rebuilding the same site that way has to render the same
+// diagrams. It runs after the hydration check so that check keeps testing the
+// documented `./integration` shape.
+step('Rebuilding the plain-Astro site in the shape `astro add` writes');
+
+writeFileSync(
+	join(plain, 'astro.config.mjs'),
+	[
+		"import { defineConfig } from 'astro/config';",
+		"import mdx from '@astrojs/mdx';",
+		"import react from '@astrojs/react';",
+		"import expressiveCode from 'astro-expressive-code';",
+		"import astroc4 from '@purview-dev/astroc4';",
+		'',
+		'export default defineConfig({',
+		'\tintegrations: [astroc4(), expressiveCode(), mdx(), react()],',
+		'});',
+		'',
+	].join('\n'),
+);
+
+run('node', [astroCli(plain), 'build'], { cwd: plain });
+
+const addHtml = readFileSync(join(plain, 'dist', 'index.html'), 'utf8');
+const addFrames = framesIn(addHtml);
+
+if (addFrames !== 2) {
+	failures.push(`\`astro add\` shape: expected 2 diagrams, found ${addFrames}`);
+} else {
+	done('`astro add`’s `integrations: [astroc4()]` renders diagrams');
 }
 
-if (!failures.some((failure) => failure.includes('inline fence'))) {
-	done('the inline fence compiled and laid out at build time');
+if (/
 0) {
 	console.error(`\n✗ ${failures.length} failure(s) in the consumer site:`);
@@ -328,5 +536,5 @@ if (failures.length > 0) {
 	process.exit(1);
 }
 
-console.log('\n✓ Packed package builds a real Starlight site.');
+console.log('\n✓ Packed package builds real Starlight and plain-Astro sites, and their diagrams hydrate.');
 
diff --git a/src/__tests__/fences.test.ts b/src/__tests__/fences.test.ts
index 988832e..7af71b1 100644
--- a/src/__tests__/fences.test.ts
+++ b/src/__tests__/fences.test.ts
@@ -1,6 +1,6 @@
 import { describe, expect, it, vi } from 'vitest';
 
-import { likec4Fences, parseFenceMeta } from '../fences.js';
+import { likec4Fences, parseFenceMeta, type FenceReporter } from '../fences.js';
 
 describe('parseFenceMeta', () => {
 	it('returns nothing for an absent or empty meta string', () => {
@@ -62,10 +62,21 @@ const fencePlugin = likec4Fences() as unknown as (factory: {
 	sourceFormat: string;
 }) => FencePlugin;
 
-const pluginFor = (source: string, sourceFormat: 'mdx' | 'md' = 'mdx') => {
+/** The same plugin, wired to a reporter (and MDX presence) instead of the fallback. */
+const fencePluginWith = (reporter: FenceReporter, mdx = true) =>
+	likec4Fences({ reporter, mdx }) as unknown as (factory: {
+		source: string;
+		sourceFormat: string;
+	}) => FencePlugin;
+
+const pluginFor = (
+	source: string,
+	sourceFormat: 'mdx' | 'md' = 'mdx',
+	build: (factory: { source: string; sourceFormat: string }) => FencePlugin = fencePlugin,
+) => {
 	const page_ = page(source, sourceFormat);
 
-	return { ...page_, plugin: fencePlugin(page_.factory) };
+	return { ...page_, plugin: build(page_.factory) };
 };
 
 const fence = (meta: string, value = '') => ({ type: 'code', lang: 'likec4', meta, value });
@@ -88,6 +99,31 @@ describe('the ```likec4 fence transform', () => {
 		warn.mockRestore();
 	});
 
+	it('reports through the reporter it is given, so Astro can label it', async () => {
+		// `src/integration.ts` passes the site's integration logger here; the label in
+		// front of the message is then Astro's (`[likec4-fences]`), not ours.
+		const reporter: FenceReporter = { warn: vi.fn() };
+		const { ctx, plugin } = pluginFor('```likec4\nmodel { }\n```', 'md', fencePluginWith(reporter));
+		const consoleWarn = vi.spyOn(console, 'warn').mockImplementation(() => {});
+
+		await plugin.code?.(fence(''), ctx);
+
+		expect(reporter.warn).toHaveBeenCalledWith(expect.stringContaining('page.mdx has a ```likec4 fence'));
+		expect(consoleWarn).not.toHaveBeenCalled();
+		consoleWarn.mockRestore();
+	});
+
+	it('tells a site without @astrojs/mdx to add it, not to rename the page', async () => {
+		// Renaming to `.mdx` cannot work when nothing processes `.mdx` pages.
+		const reporter: FenceReporter = { warn: vi.fn() };
+		const { ctx, plugin } = pluginFor('```likec4\nmodel { }\n```', 'md', fencePluginWith(reporter, false));
+
+		await plugin.code?.(fence(''), ctx);
+
+		expect(reporter.warn).toHaveBeenCalledWith(expect.stringContaining('astro add mdx'));
+		expect(reporter.warn).not.toHaveBeenCalledWith(expect.stringContaining('Rename the page'));
+	});
+
 	it('ignores fences in other languages', async () => {
 		const { ctx, plugin, replaced } = pluginFor('```likec4\nmodel { }\n```');
 
diff --git a/src/__tests__/integration.test.ts b/src/__tests__/integration.test.ts
new file mode 100644
index 0000000..45aefb2
--- /dev/null
+++ b/src/__tests__/integration.test.ts
@@ -0,0 +1,127 @@
+import { pathToFileURL } from 'node:url';
+
+import { describe, expect, it, vi } from 'vitest';
+
+import likec4Integration, {
+	likec4Integration as namedIntegration,
+	missingOptimizedDependencies,
+	PACKAGE_NAME,
+	type LikeC4IntegrationOptions,
+} from '../integration.js';
+
+/** The slice of `AstroIntegrationLogger` the integration uses, with spies. */
+interface StubLogger {
+	info: ReturnType;
+	warn: ReturnType;
+	fork: ReturnType;
+}
+
+function stubLogger(): StubLogger {
+	const logger: StubLogger = { info: vi.fn(), warn: vi.fn(), fork: vi.fn() };
+
+	logger.fork.mockImplementation(() => logger);
+
+	return logger;
+}
+
+/** Run the integration's `astro:config:setup` hook against a stubbed context. */
+async function setup(
+	options: LikeC4IntegrationOptions = {},
+	vite: Record = {},
+	integrations: { name: string }[] = [],
+) {
+	const updateConfig = vi.fn();
+	const logger = stubLogger();
+
+	await likec4Integration(options).hooks['astro:config:setup']!({
+		command: 'dev',
+		// `process.cwd()` is the repository root, so React resolves — the same
+		// condition a real site meets.
+		config: { root: pathToFileURL(process.cwd()), vite, integrations },
+		updateConfig,
+		logger,
+	} as never);
+
+	return { logger, updateConfig };
+}
+
+/** The `vite` option the integration handed to Astro. */
+const viteOf = (updateConfig: ReturnType): Record =>
+	(updateConfig.mock.calls[0]?.[0] as { vite: Record }).vite;
+
+describe('likec4Integration', () => {
+	it('is the default export, so a plain Astro site can import it by itself', () => {
+		expect(namedIntegration).toBe(likec4Integration);
+
+		const integration = likec4Integration();
+
+		expect(integration.name).toBe(`${PACKAGE_NAME}/integration`);
+		expect(typeof integration.hooks['astro:config:setup']).toBe('function');
+	});
+
+	it('installs the LikeC4 Vite plugin and the SSR exception the components need', async () => {
+		const { updateConfig } = await setup();
+
+		const vite = viteOf(updateConfig);
+
+		expect(vite.plugins).toHaveLength(1);
+		expect(vite.ssr).toEqual({ noExternal: [PACKAGE_NAME] });
+		expect(vite.optimizeDeps).toEqual({ include: ['react/compiler-runtime'] });
+	});
+
+	it('registers the fence transform with the site Markdown processor', async () => {
+		const { updateConfig } = await setup();
+
+		// The first call is the Vite config; the second replaces `markdown`.
+		const { markdown } = updateConfig.mock.calls[1]?.[0] as { markdown: { processor: unknown } };
+
+		expect(markdown.processor).toBeDefined();
+	});
+
+	it('leaves the Markdown processor alone when fences are off', async () => {
+		const { updateConfig } = await setup({ fences: false });
+
+		for (const [config] of updateConfig.mock.calls as [Record][]) {
+			expect(config).not.toHaveProperty('markdown');
+		}
+	});
+
+	it('adds @astrojs/react when the site has no React renderer', async () => {
+		// `client:only="react"` renders nothing without one, and Astro's error for
+		// that names React, not this package.
+		const { logger, updateConfig } = await setup();
+
+		const added = (updateConfig.mock.calls as [Record][])
+			.map(([config]) => config.integrations ?? [])
+			.flat()
+			.find((integration: { name?: string }) => integration.name === '@astrojs/react');
+
+		expect(added).toBeDefined();
+		expect(logger.info).toHaveBeenCalledWith(expect.stringContaining('@astrojs/react'));
+	});
+
+	it('does not add a renderer the site already registered', async () => {
+		const { updateConfig } = await setup({}, {}, [{ name: '@astrojs/react' }]);
+
+		const integrations = (updateConfig.mock.calls as [Record][])
+			.map(([config]) => config.integrations ?? [])
+			.flat();
+
+		expect(integrations).toEqual([]);
+	});
+
+	it('sets no Starlight-only option, because Astro has none', async () => {
+		const { updateConfig } = await setup();
+
+		for (const [config] of updateConfig.mock.calls as [Record][]) {
+			expect(config).not.toHaveProperty('expressiveCode');
+		}
+	});
+
+	it('keeps the site’s own optimized dependencies', async () => {
+		const { updateConfig } = await setup({}, { optimizeDeps: { include: ['gsap'] } });
+
+		expect(viteOf(updateConfig).optimizeDeps).toEqual({ include: ['react/compiler-runtime'] });
+		expect(missingOptimizedDependencies(['gsap'], process.cwd())).toEqual(['react/compiler-runtime']);
+	});
+});
diff --git a/src/__tests__/plugin.test.ts b/src/__tests__/plugin.test.ts
index dd54aa7..2a51e5f 100644
--- a/src/__tests__/plugin.test.ts
+++ b/src/__tests__/plugin.test.ts
@@ -1,16 +1,37 @@
 import { mkdtemp } from 'node:fs/promises';
 import { tmpdir } from 'node:os';
 import { join } from 'node:path';
-import { pathToFileURL } from 'node:url';
+import { fileURLToPath, pathToFileURL } from 'node:url';
 
+import type { AstroIntegration } from 'astro';
 import { describe, expect, it, vi } from 'vitest';
 
-import starlightLikeC4, { withLikeC4Language } from '../index.js';
+import starlightLikeC4, { missingOptimizedDependencies, withLikeC4Language } from '../index.js';
 import { translations } from '../translations.js';
 
 /** A registration as the plugin builds it. */
 const registration = { name: 'likec4', scopeName: 'source.likec4', aliases: ['c4'] };
 
+/** The slice of `AstroIntegrationLogger` the plugin uses, with spies. */
+interface StubLogger {
+	info: ReturnType;
+	warn: ReturnType;
+	fork: ReturnType;
+}
+
+/**
+ * `fork(label)` keeps Astro's label-swapping behaviour without losing the
+ * spies: every fork returns the same stub, so an assertion on `warn` sees
+ * messages logged through a fork too.
+ */
+function stubLogger(): StubLogger {
+	const logger: StubLogger = { info: vi.fn(), warn: vi.fn(), fork: vi.fn() };
+
+	logger.fork.mockImplementation(() => logger);
+
+	return logger;
+}
+
 /** Run the plugin's `config:setup` hook against a stubbed Starlight context. */
 async function setup(options: Parameters[0] = {}) {
 	const root = pathToFileURL(await mkdtemp(join(tmpdir(), 'astroc4-plugin-')));
@@ -18,7 +39,7 @@ async function setup(options: Parameters[0] = {}) {
 
 	const updateConfig = vi.fn();
 	const addIntegration = vi.fn();
-	const logger = { info: vi.fn(), warn: vi.fn() };
+	const logger = stubLogger();
 
 	const context = {
 		config: { expressiveCode: undefined },
@@ -34,6 +55,31 @@ async function setup(options: Parameters[0] = {}) {
 	return { addIntegration, logger, updateConfig };
 }
 
+/**
+ * Run the integration's `astro:config:setup` hook — the one that owns the Vite
+ * config — against a stubbed Astro context, with fences off so nothing else
+ * touches the Markdown processor.
+ */
+async function setupVite(root: URL, vite: Record = {}) {
+	const { addIntegration } = await setup({ fences: false });
+	const integration = addIntegration.mock.calls[0]?.[0] as AstroIntegration;
+	const updateConfig = vi.fn();
+
+	await integration.hooks['astro:config:setup']!({
+		command: 'dev',
+		config: { root, vite, integrations: [] },
+		updateConfig,
+		logger: stubLogger(),
+	} as never);
+
+	return updateConfig;
+}
+
+/** The `vite` option the integration handed to Astro. */
+function viteOf(updateConfig: ReturnType): Record {
+	return (updateConfig.mock.calls[0]?.[0] as { vite: Record }).vite;
+}
+
 describe('starlightLikeC4', () => {
 	it('adds the bridge to the LikeC4 Vite plugin', async () => {
 		const { addIntegration } = await setup();
@@ -118,6 +164,137 @@ describe('starlightLikeC4', () => {
 	});
 });
 
+describe('astro add', () => {
+	it('is also an Astro integration, so `astro add`’s `integrations: [astroc4()]` works', async () => {
+		// The CLI reads only npm metadata and the default export, and writes
+		// `astroc4()` into `integrations`. Starlight never calls `astro:*` hooks and
+		// Astro never calls `config:setup`, so one object can serve both.
+		const plugin = starlightLikeC4({ highlighting: { offline: true } });
+		const updateConfig = vi.fn();
+
+		await plugin.hooks['astro:config:setup']!({
+			command: 'dev',
+			config: { root: pathToFileURL(process.cwd()), vite: {}, integrations: [] },
+			updateConfig,
+			logger: stubLogger(),
+		} as never);
+
+		const { vite } = updateConfig.mock.calls[0]?.[0] as { vite: Record };
+
+		expect(vite.ssr).toEqual({ noExternal: ['@purview-dev/astroc4'] });
+		expect(vite.optimizeDeps).toEqual({ include: ['react/compiler-runtime'] });
+	});
+
+	it('warns when the CLI wires it into a Starlight site, where only half of it can run', async () => {
+		const plugin = starlightLikeC4({ highlighting: { offline: true } });
+		const logger = stubLogger();
+
+		await plugin.hooks['astro:config:setup']!({
+			command: 'dev',
+			config: {
+				root: pathToFileURL(process.cwd()),
+				vite: {},
+				integrations: [{ name: '@astrojs/starlight' }],
+			},
+			updateConfig: vi.fn(),
+			logger,
+		} as never);
+
+		expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('starlight({ plugins: [astroc4()] })'));
+	});
+
+	it('stays quiet on a plain Astro site, where that is the supported shape', async () => {
+		const plugin = starlightLikeC4({ highlighting: { offline: true } });
+		const logger = stubLogger();
+
+		await plugin.hooks['astro:config:setup']!({
+			command: 'dev',
+			config: { root: pathToFileURL(process.cwd()), vite: {}, integrations: [{ name: '@astrojs/mdx' }] },
+			updateConfig: vi.fn(),
+			logger,
+		} as never);
+
+		expect(logger.warn).not.toHaveBeenCalled();
+	});
+});
+
+describe('logging', () => {
+	it('warns about a fence in a .md page through the Astro logger, not the console', async () => {
+		// Astro's logger honours the CLI log level and labels the message with the
+		// integration's name; `fork` keeps "likec4-fences" as the label, which is
+		// the string the Troubleshooting page quotes.
+		const { addIntegration } = await setup();
+		const integration = addIntegration.mock.calls[0]?.[0] as AstroIntegration;
+		const logger = stubLogger();
+
+		await integration.hooks['astro:config:setup']!({
+			command: 'dev',
+			config: { root: pathToFileURL(process.cwd()), vite: {}, integrations: [] },
+			updateConfig: vi.fn(),
+			logger,
+		} as never);
+
+		expect(logger.fork).toHaveBeenCalledWith('likec4-fences');
+	});
+});
+
+describe('react/compiler-runtime', () => {
+	// `likec4/react` is compiled with the React Compiler and imports `c` from
+	// `react/compiler-runtime`, which React 19 ships as CommonJS. Unoptimized,
+	// dev serves it raw and the island throws
+	// "does not provide an export named 'c'" instead of hydrating.
+	it('pre-bundles it so the island can hydrate in dev', async () => {
+		const updateConfig = await setupVite(pathToFileURL(process.cwd()));
+
+		expect(viteOf(updateConfig).optimizeDeps).toEqual({ include: ['react/compiler-runtime'] });
+	});
+
+	it('adds nothing when the site already optimises it, because Astro concatenates', async () => {
+		const updateConfig = await setupVite(pathToFileURL(process.cwd()), {
+			optimizeDeps: { include: ['react/compiler-runtime'], exclude: ['@purview-dev/astroc4'] },
+		});
+
+		// Re-sending the site's list would duplicate it: `updateConfig` merges
+		// `vite` with Vite's `mergeConfig`, which concatenates arrays.
+		expect(viteOf(updateConfig).optimizeDeps).toBeUndefined();
+	});
+
+	it('leaves the rest of the Vite config alone', async () => {
+		const updateConfig = await setupVite(pathToFileURL(process.cwd()), {
+			optimizeDeps: { include: ['gsap'] },
+			resolve: { dedupe: ['react'] },
+		});
+
+		const vite = viteOf(updateConfig);
+
+		expect(vite.resolve).toBeUndefined();
+		expect(vite.optimizeDeps).toEqual({ include: ['react/compiler-runtime'] });
+	});
+
+	it('does not name a dependency the site cannot resolve', async () => {
+		const root = pathToFileURL(await mkdtemp(join(tmpdir(), 'astroc4-no-react-')));
+
+		expect(missingOptimizedDependencies(undefined, fileURLToPath(root))).toEqual([]);
+	});
+});
+
+describe('missingOptimizedDependencies', () => {
+	const root = process.cwd();
+
+	it('returns the entry a site without one needs', () => {
+		expect(missingOptimizedDependencies(undefined, root)).toEqual(['react/compiler-runtime']);
+		expect(missingOptimizedDependencies([], root)).toEqual(['react/compiler-runtime']);
+	});
+
+	it('skips an entry the site already lists, without touching the others', () => {
+		expect(missingOptimizedDependencies(['gsap', 'react/compiler-runtime'], root)).toEqual([]);
+	});
+
+	it('tolerates an `include` that is not a list', () => {
+		expect(missingOptimizedDependencies('react/compiler-runtime', root)).toEqual(['react/compiler-runtime']);
+	});
+});
+
 describe('withLikeC4Language', () => {
 	it('creates the shiki options when the site has none', () => {
 		expect(withLikeC4Language(undefined, registration)).toEqual({ shiki: { langs: [registration] } });
diff --git a/src/components/DiagramFrame.astro b/src/components/DiagramFrame.astro
index d8ce03a..cbc7816 100644
--- a/src/components/DiagramFrame.astro
+++ b/src/components/DiagramFrame.astro
@@ -20,7 +20,15 @@ const { height = 480 } = Astro.props;
 	/* LikeC4's renderer measures its container, so it needs a concrete height
 	   rather than collapsing to the intrinsic size of an absolutely-positioned
 	   canvas. The per-instance value arrives as an inline custom property. */
+	/* Same token contract as DiagramShell: Starlight's palette when present,
+	   scheme-neutral fallbacks when not (see the note there). */
 	.likec4-frame {
+		--likec4-border: var(--sl-color-gray-5, rgba(128, 128, 128, 0.35));
+		--likec4-surface: var(--sl-color-bg, transparent);
+		--likec4-danger: var(--sl-color-red, #c0392b);
+		--likec4-danger-soft: var(--sl-color-red-low, rgba(192, 57, 43, 0.12));
+		--likec4-danger-text: var(--sl-color-red-high, #8b1d1d);
+
 		min-height: var(--likec4-height);
 	}
 
@@ -35,13 +43,13 @@ const { height = 480 } = Astro.props;
 	}
 
 	.likec4-frame :global(.likec4-error) {
-		border: 1px solid var(--sl-color-red);
+		border: 1px solid var(--likec4-danger);
 		border-radius: 0.5rem;
-		background: var(--sl-color-red-low);
+		background: var(--likec4-danger-soft);
 		padding: 1rem;
 		font-size: 0.875rem;
 		line-height: 1.25rem;
-		color: var(--sl-color-red-high);
+		color: var(--likec4-danger-text);
 	}
 
 	.likec4-frame :global(.likec4-error p) {
@@ -55,7 +63,7 @@ const { height = 480 } = Astro.props;
 
 	.likec4-frame :global(.likec4-error__retry) {
 		margin-top: 0.75rem;
-		border: 1px solid var(--sl-color-red);
+		border: 1px solid var(--likec4-danger);
 		border-radius: 0.375rem;
 		background: transparent;
 		padding: 0.375rem 0.75rem;
@@ -67,6 +75,6 @@ const { height = 480 } = Astro.props;
 	}
 
 	.likec4-frame :global(.likec4-error__retry:hover) {
-		background: var(--sl-color-bg);
+		background: var(--likec4-surface);
 	}
 
diff --git a/src/components/DiagramShell.astro b/src/components/DiagramShell.astro
index c7d1589..1516428 100644
--- a/src/components/DiagramShell.astro
+++ b/src/components/DiagramShell.astro
@@ -54,17 +54,28 @@ const { title, description, hint, collapsible = true, defaultOpen = true } = Ast
 
 
 
diff --git a/src/components/LoadingPlaceholder.astro b/src/components/LoadingPlaceholder.astro
index acd9b8a..8dd0d0e 100644
--- a/src/components/LoadingPlaceholder.astro
+++ b/src/components/LoadingPlaceholder.astro
@@ -22,10 +22,16 @@ const { label } = Astro.props;