# Astroc4 > Interactive LikeC4 architecture diagrams for Astro and Starlight, with fences and syntax highlighting. A Starlight plugin that puts LikeC4 architecture diagrams inside an Astro site. A ```likec4 fence in an .mdx page becomes a laid-out, interactive diagram at build time, an empty fence or renders a view of the site's own .c4 model, and Expressive Code gains LikeC4 syntax highlighting derived from the language's Langium grammar rather than a hand-maintained TextMate file. The grammar is resolved while Astro loads its configuration, cached per repo@ref, and can be pinned, run offline from a bundled snapshot, or allowed to degrade to a warning, so a docs build is as reproducible as the package versions beside it. ## Documentation version - Channel: live (default) - Source: purview-dev/astroc4@main ## Resources - Repository: https://github.com/purview-dev/astroc4 - npm package: https://www.npmjs.com/package/@purview-dev/astroc4 - Project page: https://purview.dev/projects/astroc4/ - Documentation: https://purview.dev/docs/astroc4/ - Full machine-readable content: https://purview.dev/projects/astroc4/llms-full.txt - Concise machine-readable index: https://purview.dev/projects/astroc4/llms.txt # astroc4 Interactive [LikeC4](https://likec4.dev) architecture diagrams for [Astro](https://astro.build) and [Starlight](https://starlight.astro.build). ```sh bun add @purview-dev/astroc4 ``` ```js // astro.config.mjs import starlight from '@astrojs/starlight'; import react from '@astrojs/react'; import astroc4 from '@purview-dev/astroc4'; export default defineConfig({ integrations: [starlight({ plugins: [astroc4()] }), react()], }); ``` ```mdx import LikeC4View from '@purview-dev/astroc4/components/LikeC4View.astro'; ``` 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`. ## What it gives you | | | | --- | --- | | `` | Render a view from the site's `.c4` model, as a hydrated island. | | ` ```likec4 ` fences | Inline DSL compiled and laid out **at build time**, or an empty fence with `view="…"`. A typo fails the build. | | Syntax highlighting | A TextMate grammar derived from the Langium grammar in [likec4/likec4](https://github.com/likec4/likec4) at a ref you choose, registered with Expressive Code. | | Translatable UI | Every string the components render goes through Starlight's i18n. | | Fails like a build | A bad grammar ref, an unreachable repository or an invalid fence fails `dev`, `build`, `check` and `sync`. | ## Documentation The pages are grouped by what you are doing. Every folder has a `README.md` that lists its own pages. | | | | --- | --- | | [guide](guide/readme/) | Using the plugin: getting started, fences, components, highlighting, icons, i18n, options. | | [contributing](contributing/readme/) | Changing it: the module map, the local workflow, CI and releases. | | [Troubleshooting](troubleshooting/) | The failures people actually hit. | | [Roadmap](https://github.com/purview-dev/astroc4/blob/main/docs/wiki/Roadmap.md) | What Phase 1 covers and what Phase 2 adds. | Start with [Getting Started](guide/getting-started/) — a site with a diagram, in 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. --- # Troubleshooting Each entry is a failure that actually happens, and what it means. ## The build fails and the log mentions the grammar ``` could not fetch the LikeC4 grammar from https://raw.githubusercontent.com/… ``` The plugin resolves the grammar while Astro loads its config, so a machine without access to `raw.githubusercontent.com` fails the build. Choose one: ```js astroc4({ highlighting: { offline: true } }) // bundled snapshot, no network astroc4({ highlighting: { onError: 'warn' } }) // last cached copy, then bundled ``` ```sh ASTROC4_OFFLINE=1 bun run build ``` A ref that does not exist is different: it fails even with `onError: 'warn'`, because it will fail the same way on every build. Check the tag (`v1.60.0`, not `1.60.0`). ## Diagrams render but nothing is coloured 1. Is `expressiveCode: false` in the Starlight config? The plugin logs a warning and skips highlighting, because there are no code blocks to highlight. 2. Is the fence ` ```c4 ` or ` ```likec4 `? A ` ```likec4 ` fence in **`.mdx`** becomes a diagram, not code; use `c4` to show source, or ````. 3. Look at the HTML, not the exit code: `
` should contain
   several `--0:#…` colour variables. One colour means the grammar never reached
   Shiki.
4. Is the site on a Starlight older than 0.42.8? The floor is load-bearing — the
   plugin needs the `astroConfig` hook argument and an `updateConfig` key that
   earlier versions do not accept.

## `expressiveCode: false` warning, but I want highlighting

You cannot have both: with Expressive Code disabled Starlight renders code with
Astro's own ``, which reads `markdown.shikiConfig`, not the Expressive Code
options this plugin sets.

## A fence stayed a code block

The page is `.md`. Astro only supports components in `.mdx`, so the transform
leaves the fence alone and logs:

```
[likec4-fences] /path/page.md has a ```likec4 fence, but fences only render as diagrams in .mdx files.
```

Rename the page, or use `` from an `.astro` layout.

## `LikeC4View` shows "Unknown LikeC4 projectId"

The plugin compiles **every** `.c4` project under the site root, and the first one
found is the default. A vendored example model (a `node_modules` copy, a docs
sample) can silently become the default. Keep only the projects you mean to
render, or pass `projectId` explicitly, or point `vitePlugin.workspace` at one
directory.

## `Invalid … Could not resolve reference to ElementKind named 'person'`

LikeC4 requires every element kind to be declared before use:

```
specification {
  element person
}
model {
  reader = person 'Reader'
}
```

This is a model error, not a plugin error — the plugin just lets it fail the
build, which is the point.

## The build crashed on Windows with "Vite module runner has been closed"

That is the race `preloadLikeC4()` and the `require()`-instead-of-`import()`
comment in `src/fences.mjs` exist to prevent. If it comes back, the preload is no
longer running before the first page render (look at `command === 'build'` in
`src/index.mjs`).

## Highlighting changed without a code change

The default ref is `main`, which moves. Pin a tag or a SHA, or set `ASTROC4_REF`
in CI. The build log always names what it used:

```
[astroc4] LikeC4 grammar likec4/likec4@main (da5af77245f9) — highlighting ```c4 and ```likec4 fences
```

`from cache` / `from bundled` in that line tells you which path was taken.

## My theme does not colour LikeC4 source

The scopes are TextMate-standard but LikeC4-specific (`keyword.control.likec4`,
`constant.language.likec4`, …). A theme without rules for them falls back to
nearest-parent colours, which can look flat. Add rules for the scopes listed in
[Syntax Highlighting](../guide/syntax-highlighting/#scopes).

## `Cannot find module …/@purview-dev/astroc4/dist/index.js`

That version was published without its build output. `package.json` `exports`
points at `dist/`, which is built (and gitignored), so a publish from a checkout
that never ran `build:lib` produces a tarball with only `src/`. It happened once,
to `1.0.0-prerelease.3`.

Check what is installed before debugging anything else:

```sh
bun -e "console.log(require.resolve('@purview-dev/astroc4'))"
```

That prints the file the package resolves to, and fails when the published
tarball shipped `src/` only.

If it is missing, move to a version that has it (`1.0.0-prerelease.4` or later,
`1.0.0-prerelease.2` for the older source-only layout). Publishes are now gated:
the release job builds first, and `prepublishOnly` runs `scripts/check-dist.ts`,
which refuses to publish when any `exports` target is missing.

## `bun run test:site` fails but everything else passes

It packs the package and builds a consumer site in a temp directory, so it fails
for reasons the playground cannot see: `files` missing something, an `exports`
subpath pointing at a file that is not published, a peer dependency not declared,
a declaration that does not resolve against the consumer's `node_modules`, or a
`dist/` file the build forgets to copy. Re-run with `--keep` to inspect the site
it built.

---

# Architecture

For anyone changing the code. Two pipelines meet at the page: the model (LikeC4's
Vite plugin, plus the fence transform) and the highlighting (the grammar).

## Module map

```
src/                                  TypeScript sources — bundled into dist/ by build:lib
  index.ts                  the Starlight plugin: hooks, options, Expressive Code injection
  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
  types.d.ts                ambient types for `likec4:*` virtual modules and `t()`
  grammar/
    langium.ts              Langium text → vocabulary (blocks, keywords, domains, terminals)
    textmate.ts             vocabulary → TextMate JSON
    source.ts               ref validation, fetch, cache, resolve, failure policy
    like-c4.langium         committed snapshot of upstream's grammar
    likec4.tmLanguage.json  GENERATED from it — never edit
  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
    DiagramFrame.astro      the sized container
    LoadingPlaceholder.astro  pre-hydration placeholder (carries the height)
    LikeC4ProjectView.tsx   island: loads a project from `likec4:react/`
    LikeC4InlineView.tsx    island: renders a build-time model dump
    ErrorBoundary.tsx       translated error UI with retry
    labels.ts               server → island string plumbing, `format()`
    render-icon.tsx         icon rendering, `withImageIcons`
    icon-source.ts          icon id → source, refuses `file://`
    utils.ts                small shared helpers
```

## Data flow

**A diagram renders in three steps.**

1. *Build, model.* `LikeC4VitePlugin` compiles every `.c4` project into the
   `likec4:model` / `likec4:icons` / `likec4:projects` virtual modules.
   `` picks a project there.
2. *Build, fences.* For a ` ```likec4 ` fence with DSL, `fences.ts` runs
   `LikeC4.fromSource(source)` and `layoutedModel()`, then hands the resulting
   `$data` JSON to `` as a prop. This is why layout needs no browser.
3. *Browser.* `client:only="react"` islands hydrate with `@likec4/core`, render
   through `likec4/react`, and rehydrate either a project model or the inline
   dump. Nothing is laid out client-side.

**Highlighting is resolved before any of that.**

`config:setup` (a Starlight plugin hook) runs *before* Starlight constructs its
Expressive Code integration, which is what makes the injection possible:
`updateConfig({ expressiveCode: { …existing, shiki: { langs: [registration] } } })`.

## Load-bearing details

| Detail | Why |
| --- | --- |
| `likec4` (the Node API) is never imported at module scope | It pulls Langium, chevrotain, an LSP and the WASM layout engine; doing it while `astro.config.mjs` loads triggered a config-reload restart that killed the content-layer transport on a cold start. `fences.ts` loads it lazily. |
| On Windows, `require('likec4')` rather than `import('likec4')` | Vite's SSR module runner can already be tearing down when a page's fence needs the API, which crashed the build; `require()` goes through Node's resolver instead. |
| `preloadLikeC4()` on `command === 'build'` | Finishes that load before the race exists. Build-only, because eager-loading in dev is what caused the restart. |
| Fence imports use the package name (`@purview-dev/astroc4/components/…`) | They are written into the *site's* page, so they resolve from the site, not from this package. |
| The fence plugin is Sätteri mdast, appended to the site's processor | Astro 7 made Sätteri the default; `remarkPlugins` is a legacy option that is silently ignored without `@astrojs/markdown-satteri`. |
| `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. |

## Tests

| Layer | Where |
| --- | --- |
| 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) |

---

# Local Development

```sh
bun install
bun run dev        # playground at http://localhost:4321
```

`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.

## 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 dev` | `build:lib`, then the playground dev server on port 4321. |
| `bun run build` | `build:lib`, then a static build of the playground. |
| `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 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:smoke` | `scripts/smoke.ts` against a running server (Playwright). |
| `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. |

The playground scripts `cd playground` first rather than passing
`--root playground`: with `--root`, `astro preview` cannot find Starlight's 404
route.

## Verifying a change

```sh
bun run test && bun run check && bun run build
bun run preview &
bun scripts/smoke.ts http://localhost:4321
```

The static build passes even when islands fail to hydrate, so the smoke test is
not optional: it is the only thing that proves the diagrams draw, the icons
resolve, the fences became diagrams and the highlighting reached Expressive Code
(it asserts at least four colours per code block).

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.

## Working on the grammar

1. Change `src/grammar/langium.ts` or `src/grammar/textmate.ts`.
2. `bun run test` — `snapshot.test.ts` will fail, because the committed JSON no
   longer matches its derivation.
3. `bun run grammar:sync` and commit both files.

`bun run grammar:sync --ref v1.60.0` regenerates from a release instead of
`main`; the snapshot records the ref and the content hash it was built from.

Two things to keep in mind while editing the derivation:

- the fixture pair `src/grammar/__fixtures__/mini*.langium` encodes the `agent`
  difference between `v1.59.4` and `main`, so it is what keeps version selection
  honest without the network;
- `src/grammar/__tests__/snapshot.test.ts` compares the JSON, so key order and
  sorting in the generator matter. Everything is sorted deliberately.

## Expected noise

- `WARN likec4.server.workspace No likec4 documents found in workspace` during a
  build — one per inline ` ```likec4 ` fence, from the throwaway workspace
  `LikeC4.fromSource` compiles each snippet in.
- `[WARN] [@astrojs/sitemap] The Sitemap integration requires the 'site'
  astro.config option` — the playground has no `site`.
- `bun run test:site` prints Astro's Pagefind/sitemap warnings from the
  throwaway site. They are the consumer site's, not a problem.

---

# Contributing — changing astroc4

For anyone editing the package: how it is put together, how to run it, and how a
change reaches npm.

| Page | What it answers |
| --- | --- |
| [Architecture](../architecture/) | The module map, the two pipelines that meet at a page, and the decisions behind them. |
| [Local Development](../local-development/) | Scripts, the playground, and how to verify a change. |
| [Repository CI/CD](../repository-ci-cd/) | The workflows, the release flow, dist-tags, secrets and trusted publishing. |

Start with [Local Development](../local-development/) if you just want to run it.

Back to the [documentation index](../../).

---

# Repository CI/CD

Two workflows, both following the purview-dev pattern: a shared organisation
workflow for the gate, repository-local jobs for the work that only makes sense
here.

## `pr.yml`

| Job | Purpose |
| --- | --- |
| `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. |

### `.config/purview-build.json`

The gate is configured, not scripted, so the local and CI answers to "does this
build" cannot diverge:

| Setting | Value | Why |
| --- | --- | --- |
| `ProjectType` | `Web` | Bun commands instead of the .NET modules, the same arrangement `purview-dev.github.io` uses. |
| `WebInstallCommand` | `bun install --frozen-lockfile` | A PR must not silently update the lockfile. |
| `WebBuildCommand` | `bun run grammar:check && bun run check && bun run build` | Ordered to fail fastest: a grammar snapshot that drifted from its derivation, then the type-check, then the playground build. |
| `WebTestCommand` | `bun run test` | The hermetic suite; the live grammar check is its own job. |
| `RunLint` | `false` | No linter or formatter is configured in this repository yet. |
| `RunPack`, `ValidatePack` | `false` | Nothing is packed; astroc4 publishes to npm from `release.yml`. |
| `WebBuildOutput` | `playground/dist` | What `bun run build` produces. |
| `Release.Mode` | `None` | The release workflow passes `GitHubRelease`; the build pipeline publishes nothing. |

`grammar:check` runs before the type-check on purpose: the grammar is generated,
and a derivation change without a regenerated snapshot is the failure mode a
review is most likely to miss.

The live grammar check is its own job so a red run says "upstream moved the
grammar" (or GitHub is down) rather than "this branch broke something".

## `release.yml`

Triggers on a push to `main` that changes `package.json` — the version lives
there, and that is true for the .NET repositories too (`Version__Source =
PackageJson`).

| Job | Purpose |
| --- | --- |
| `Publish to npm` | Runs the unit suite and `grammar:check`, **builds the package** (`build:lib`) and refuses to continue unless `dist/` carries the entry points, their declarations and the grammar data, reads the version, skips when the version is already on the registry, then `npm publish --access public --tag ` authenticated by **trusted publishing**. |
| `Tag and GitHub release` | `purview-dev/build/.github/workflows/purview-release.yml@main` with `release-mode: GitHubRelease`, `run-pack: false`, `validate-pack: false`. |

### Why npm and not NuGet

Astroc4 is consumed by Astro's bundler; a `.nupkg` cannot be. The organisation's
release action is still used — `GitHubRelease` mode tags the repository and
creates the release with generated notes — and the npm publish runs *before* it,
so a failed publish never leaves a release pointing at an uninstallable version.

If a NuGet artefact is ever wanted (for example a package that only carries the
generated grammar for .NET tooling), add a `NuGet` publish step; it must not
replace the npm one.

### Dist-tags

`npm publish --tag prerelease` puts a prerelease under `prerelease`, so that tag
can be pinned (`bun add @purview-dev/astroc4@prerelease`). A version without a
prerelease suffix publishes under `latest`.

`latest` **cannot be deleted** — the registry answers `400 Bad Request` to
`DELETE …/dist-tags/latest`, because a package with published versions must have
one. So the release workflow keeps it honest instead:

| State | `latest` | `prerelease` |
| --- | --- | --- |
| No stable release yet | the newest prerelease (CI moves it after each publish) | the newest prerelease |
| A stable release exists | the newest stable (its publish sets it) | the last prerelease |

Moving the tag needs the **"Allow npm dist-tag"** permission on the trusted
publisher (npm ≥ 11.21). It is granted for this package, so the publish job moves
`latest` itself. Without it the step warns rather than failing, the registry
answers `403 … This trusted publisher cannot manage dist-tags`, and the move has
to be done by hand — which needs a browser 2FA approval:

```sh
npm dist-tag add @purview-dev/astroc4@ latest
```

The step only moves `latest` while `latest` itself is a prerelease, so once
`1.0.0` ships a `1.0.1-prerelease.1` can never pull the default install back onto
a prerelease.

It also runs on `workflow_dispatch`, and that is how `latest` first reached
`1.0.0-prerelease.4`: a dispatch re-runs the check without republishing anything
(the publish step skips a version already on the registry), and the tag move
succeeds over OIDC. Both tags now resolve `1.0.0-prerelease.4`.

### The published package is a build artifact

`exports` points at `dist/`, which is gitignored. The publish job therefore runs
`bun run build:lib` and refuses to continue unless `dist/` carries the entry
points, their declarations and the grammar data — `1.0.0-prerelease.3` shipped
without them and could not be imported, because the job published a fresh
checkout straight after the tests. `prepublishOnly` runs
`scripts/check-dist.ts`, which walks the `exports` map and fails when any target
is missing, so a local `npm publish` cannot repeat it either.

### Idempotency

Both halves are safe to re-run: the npm step checks `npm view @purview-dev/astroc4@`
first, and the shared release workflow skips when the `v` tag exists.

## Publishing authentication: npm trusted publishing

The publish job holds **no npm token**. It authenticates with an OIDC token that
GitHub mints for that workflow run, which npm exchanges for a short-lived
credential scoped to the `purview-dev/astroc4` repository and to `release.yml`.
There is nothing to rotate, leak or revoke.

### What the workflow does

| Step | Why |
| --- | --- |
| `permissions: id-token: write` on the publish job | Without it there is no OIDC token to exchange. |
| Asserts `ACTIONS_ID_TOKEN_REQUEST_URL` is set | Trusted publishing only works on GitHub-hosted runners; a self-hosted runner fails here with a message that says so, instead of a confusing 401 later. |
| `npm install -g 'npm@^11.5.1'`, then asserts the floor | Trusted publishing needs npm CLI **11.5.1 or later**. |
| No `registry-url` in `setup-node` | That option writes `//registry.npmjs.org/:_authToken=${NODE_AUTH_TOKEN}` into `.npmrc`; with no token defined the unresolved placeholder is what npm would try to use instead of the OIDC exchange. |
| No `--provenance`, no `NODE_AUTH_TOKEN` on the publish step | Provenance attestations are generated automatically under trusted publishing. Forcing the flag is unnecessary and can only fail. |

### One-time setup

The package is **scoped** — `@purview-dev/astroc4` — because npm refuses the
unscoped name with *"Package name too similar to existing package astro"*. A
scope also needs its npm **organisation** to exist (`purview-dev`, which is the
GitHub org name, not the same thing on npm; create it at
 if `npm org ls purview-dev` reports it
missing). If the organisation name is unavailable, the package can live under
the personal scope instead: `@kieronlanning/astroc4`, which changes
`package.json` and the specifiers in `src/index.ts` and `src/fences.ts` only.

The publish job runs in the GitHub **environment `npm`**. It declares no
required reviewer, so a merged version bump publishes unattended; the environment
exists to keep a deployment record per release and to restrict the job to
protected branches (`main`). The OIDC token carries `environment: npm` as a
claim, which is what lets the trusted publisher be narrowed to it later.

Leaving the trusted publisher's `Environment name` **empty works** — verified by
running the workflow in this environment: the OIDC exchange against a publisher
that names no environment succeeded. Filling it in *narrows* the publisher to
runs in that environment, which is worthwhile defence in depth; npm cannot edit a
connection, so that means deleting and re-creating it.

| Field | Value |
| --- | --- |
| Organization or user | `purview-dev` |
| Repository | `astroc4` |
| Workflow filename | `release.yml` |
| Environment name | *empty, or `npm` to narrow it to the publish job* |
| Allow npm publish | ticked |
| Allow npm dist-tag | ticked |

The CLI cannot grant the dist-tag permission (npm 11.x only exposes
`--allow-publish` and `--allow-stage-publish`), so the web UI is the way in for
that one.

Trusted publishing is configured **per package, in the package's settings**, so
the package has to exist on npm first. `@purview-dev/astroc4` has never been
published:

1. **Bootstrap the first version once, locally**, from an account that can
   publish to the scope (`npm login`):

   ```sh
   npm org ls purview-dev          # is the scope yours to publish to?
   bun run test && bun run grammar:check
   npm publish --access public --tag prerelease
   ```

   This consumes `1.0.0-prerelease.1`. Do it once; every later release goes
   through CI. `--access public` is required: a scoped package defaults to
   restricted, and CI passes it too.

2. **Add the trusted publisher.** From the CLI (npm ≥ 11.20), in the same
   session:

   ```sh
   npm trust github @purview-dev/astroc4 \
     --repo purview-dev/astroc4 \
     --file release.yml \
     --allow-publish

   npm trust list @purview-dev/astroc4   # expect the release.yml relationship
   ```

   `--allow-publish` is **not** the default: without it the relationship may only
   stage a release, and `npm publish` gets no token at all — which npm reports as
   `ENEEDAUTH` ("you need to authorize this machine"), not as a permission error.
   In the web UI the same switch is the *Allow npm publish* checkbox.

   The web UI does the same thing at
    → *Trusted
   Publisher* → GitHub Actions:

   | Field | Value |
   | --- | --- |
   | Organization or user | `purview-dev` |
   | Repository | `astroc4` |
   | Workflow filename | `release.yml` |
   | Environment name | *(empty — this repository's publish job runs in `npm`, which an empty claim matches)* |

3. Bump the version and merge; `release.yml` publishes the next one with no
   secret involved.

Both the `repository` field in `package.json` and the trusted publisher name
`purview-dev/astroc4`, and npm validates the workflow filename — so renaming
`release.yml` stops publishing until the trusted publisher is updated. That also
rules out publishing from the shared `purview-release.yml`: a called workflow
changes which filename is validated.

### Caveats

- **GitHub-hosted runners only.** Self-hosted is not supported yet.
- **Provenance needs a public repository.** Publishing works from a private
  repository, but npm does not attach provenance attestations for one. Flipping
  `purview-dev/astroc4` to public is what starts producing them.
- A package can have up to 10 trusted publishers, and an existing one cannot be
  edited — delete it and create a new one.

## Secrets and variables

| Name | Kind | Used for |
| --- | --- | --- |
| `npm` | environment | The publish job runs in it: a deployment record per release, restricted to protected branches. No required reviewer, so merging a version bump publishes without a prompt. |
| `secrets: inherit` | — | Forwards the organisation NuGet secrets the shared workflows expect, even though this repository publishes none. |
| `PURVIEW_NUGET_SOURCE_MAPPING` | variable | Organisation feed mapping; forwarded by the shared workflows. |

No npm secret exists, by design. If you ever add one, it is a bug: trusted
publishing is the only supported path here.

## Branch protection

`main` is protected: a pull request is required, the four PR checks must pass
(`Build and test / Build`, `Live grammar`, `Browser smoke test`, `Packed consumer
site`), the branch must be up to date with `main`, force pushes and deletion are
refused. Administrators can bypass, so a docs-only fix can still be pushed
directly — deliberately, because a `package.json` change is what publishes.

## Releasing

1. Bump `version` in `package.json` (`1.0.0-prerelease.2`, `1.0.0`, …).
2. Merge to `main`.

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.

## Local equivalents

| Workflow job | Locally |
| --- | --- |
| `Build and test` | `just verify` — the same chain as `WebBuildCommand` then `WebTestCommand` |
| `Live grammar` | `bun run test:network` |
| `Browser smoke test` | `bun run preview` then `bun scripts/smoke.ts http://localhost:4321` |
| `Packed consumer site` | `bun run test:site` |

`Justfile` wraps each of them.

---

# Components

Two Astro components are exported. One is for authors; the other exists for the
fence transform.

## ``

```mdx
import LikeC4View from '@purview-dev/astroc4/components/LikeC4View.astro';



```

| Prop | Type | Default | Meaning |
| --- | --- | --- | --- |
| `viewId` | `string` | required | Which view to render. |
| `projectId` | `string` | the first project found | Which compiled LikeC4 project. |
| `title` | `string` | translated `likec4.view.title` | Shell heading. |
| `description` | `string` | translated `likec4.view.description` | Shell sub-heading. |
| `height` | `number` | `480` | Frame height in pixels. |
| `collapsible` | `boolean` | `true` | Render the shell as a `
`. | | `defaultOpen` | `boolean` | `true` | Whether it starts open. | It renders a shell (`
`) around a sized frame, and puts the LikeC4 React island inside as `client:only="react"` — the diagram has no useful server-rendered form, and the loading placeholder carries the frame's height so the page does not jump when it hydrates. ## `` The target of the fence transform. Authors do not import it; `src/fences.ts` rewrites a ` ```likec4 ` fence into it and passes the model it compiled at build time as a JSON string. | Prop | Type | Meaning | | --- | --- | --- | | `model` | `string` | The layouted model, JSON-encoded by the fence plugin. | | `viewId` | `string` | Which view of that model to draw. | | `title` / `description` | `string` | Shell heading and sub-heading. | | `height` | `string` | Pixel height; arrives as a string through the MDX attribute. | | `collapsible` / `defaultOpen` | `string` | `"false"` turns the behaviour off. | It throws if `model` is not valid JSON — a fence cannot produce anything else, so 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: | Class | Element | | --- | --- | | `likec4-shell` | The outer `
` | | `likec4-shell__header` / `__title` / `__description` / `__hint` | The `` and its parts | | `likec4-shell__body` | The content area | | `likec4-frame` | The sized container (also carries `--likec4-height`) | | `likec4-badge` | The "click a node" badge `` adds | | `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. ## Client islands The islands are `@likec4/core` plus `likec4/react`, wrapped in an error boundary that renders translated text and a retry button. Two details matter if you change them: - client code imports `@likec4/core/model`, never `likec4/model` — the latter is the Node entry point and drags `createRequire` into the browser bundle, which fails at hydration; - `client:only` fallbacks need `slot="fallback"`, or Starlight parks them in an inert `