Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,23 @@ 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).

## [1.0.0-prerelease.8] - 2026-11-12

### Added

- **A `validation` mode for fences: `strict` (the default) or `relaxed`.** A
fence that cannot be turned into a diagram — its DSL does not compile, it names
a view its own DSL does not declare, an empty fence names no `view`, a
`project` does not exist, a `file=` does not resolve — previously always failed
the build, and `astro check` with it, because both run the same content
transform. `validation: 'relaxed'` logs the reason as a warning and leaves the
fence as a highlighted code block instead. Relaxing can be scoped, by content
collection (`collections: ['tutorials']`, a collection being the first folder
under the content directory) or by page path glob
(`relaxed: ['src/content/docs/examples/**']`), with `default` for the rest — so
a section or a collection relaxes without the rest of the site. Available on
both the Starlight plugin and the plain-Astro integration.

## [1.0.0-prerelease.7] - 2026-11-09

### Fixed
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,7 @@ reload in `astro dev` like any other.
| `projects` | `{}` | Named LikeC4 project folders, relative to the site root or absolute. See above. |
| `vitePlugin` | `{}` | Passed to `LikeC4VitePlugin`. Setting `workspace` or `languageServices` takes discovery over: `projects` is then ignored (with a warning). |
| `fences` | `true` | Turn ` ```likec4 ` fences into diagrams. |
| `validation` | `'strict'` | Whether a fence that cannot be drawn (bad DSL, missing view, bad `project`, unresolved `file=`) fails the build or only warns. `'relaxed'` warns and leaves it as a highlighted code block; `{ default, collections, relaxed: [globs] }` scopes it by collection or path. See [Fences](docs/wiki/guide/Fences.md#partial-and-invalid-examples). |
| `highlighting` | `true` | Highlight LikeC4 source. Starlight only — a plain Astro site passes `likec4Language` to Expressive Code. See below. |

### Syntax highlighting
Expand Down
46 changes: 46 additions & 0 deletions docs/wiki/guide/Fences.md
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,52 @@ Layouted snippets are memoised by a hash of their source, so a snippet is
compiled once per process, even though the dev server re-renders the page on
every edit.

## Partial and invalid examples

A fence that cannot be turned into a diagram fails `astro build` — and `astro
check` with it, because both run the same content transform. That covers more
than a compile error: a view its own DSL does not declare, an empty fence with
no `view`, a `project` that does not exist, a `file=` that does not resolve. A
tutorial or example page, though, often shows deliberately incomplete DSL.
`validation` picks what happens:

- `'strict'` — the default. The build and `astro check` fail, naming the page,
the fence and the reason.
- `'relaxed'` — the reason is logged as a warning instead, and the fence is left
in the page, where it highlights as a code block.

A single mode covers the whole site:

```ts
starlight({
plugins: [astroc4({ validation: 'relaxed' })],
});
```

Or relax only part of it — a content collection, a content area, a section, one
page:

```ts
starlight({
plugins: [
astroc4({
validation: {
default: 'strict',
// Whole collections, by name.
collections: ['tutorials'],
// Or page paths, relative to the site root (`*` within a segment,
// `**` across them).
relaxed: ['src/content/docs/examples/**'],
},
}),
],
});
```

A collection is the first folder under the content directory
(`src/content/<name>/…`), which is how Astro's `glob` loader maps a collection to
a folder. The plain-Astro integration takes the same option.

## Errors you will see

| Message | Cause |
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@purview-dev/astroc4",
"version": "1.0.0-prerelease.7",
"version": "1.0.0-prerelease.8",
"description": "Interactive LikeC4 architecture diagrams for Astro and Starlight: <LikeC4View>, ```likec4 fences, and LikeC4 syntax highlighting derived from the live Langium grammar.",
"license": "MIT",
"author": {
Expand Down
8 changes: 7 additions & 1 deletion skills/purview-astroc4-plugin/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,12 @@ Inline DSL and `file=` are compiled and laid out at build time, so a typo fails
the build with the file name and the parser's message. Without `view=`, a fence
shows the first `view` its source declares.

A tutorial or example page that intentionally shows partial DSL can relax this:
`astroc4({ validation: 'relaxed' })` logs a warning and leaves an undrawable
fence as a highlighted code block instead of failing. Scope it with
`{ default: 'strict', collections: ['tutorials'], relaxed: ['docs/examples/**'] }`
— collections are named, pages are globbed.

In `.mdx` a fence becomes a component. In `.md` it becomes a placeholder that an
injected page script mounts. It takes the same options (`title` defaults to
"Diagram", `collapsible`, `defaultOpen`), but has no hint or badge and its
Expand Down Expand Up @@ -155,7 +161,7 @@ coloured, why a diagram did not draw.
| --- | --- |
| No colours anywhere | `expressiveCode: false`? astroc4 in `integrations` instead of `plugins`? plain Astro without `likec4Language` passed to `expressiveCode()`? which fence language? does `<pre data-language="c4">` contain several `--0:#…` values? |
| Diagrams blank but the build passes | a static build passes even when islands fail to hydrate; use the browser smoke test (`bun scripts/hydration.ts <url>` on a **dev** server), not the exit code |
| A ` ```likec4 ` fence stayed a code block | `fences: false`, or the site's Markdown processor is not Sätteri (the log warns) |
| A ` ```likec4 ` fence stayed a code block | `fences: false`; the site's Markdown processor is not Sätteri; or the fence could not be drawn and `validation: 'relaxed'` left it as code. The last two log a warning |
| `.md` fence stuck on "Loading diagram…" | browser console: did the `import()` of `@purview-dev/astroc4/components/md-fences` fail? is there a `data-astroc4-fence` element and the injected page script? |
| Build fails `…likec4.config… could not be read` | the JSON5 parse or esbuild error follows the path; a JS/TS config's imports (other than `likec4/config`) must resolve from its folder |
| Build fails "Several LikeC4 projects exist" | more than one project (discovered or named in `projects`); pass `project="<name>"` |
Expand Down
133 changes: 132 additions & 1 deletion src/__tests__/fences.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { mkdir, mkdtemp, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { dirname, join } from 'node:path';
import { dirname, join, resolve } from 'node:path';
import { pathToFileURL } from 'node:url';

import { describe, expect, it, vi } from 'vitest';
Expand Down Expand Up @@ -135,6 +135,137 @@ describe('the ```likec4 fence transform', () => {
await expect(Promise.resolve(plugin.code?.(fence('', 'model { a = }'), ctx))).rejects.toThrow(/not valid LikeC4/);
});

it('leaves an invalid fence as a code block in relaxed mode, and reports the reason', async () => {
const onInvalid = vi.fn();
const build = likec4Fences({ validation: 'relaxed', onInvalid }) as unknown as typeof fencePlugin;
const { ctx, plugin, replaced } = pluginFor('```likec4\nmodel { a = }\n```', 'mdx', build);

await plugin.code?.(fence('', 'model { a = }'), ctx);

expect(replaced).toHaveLength(0);
expect(onInvalid).toHaveBeenCalledWith(expect.stringContaining('not valid LikeC4'));
expect(onInvalid).toHaveBeenCalledWith(expect.stringContaining('relaxed'));
});

it('still turns a valid fence into a diagram in relaxed mode', async () => {
const onInvalid = vi.fn();
const build = likec4Fences({ validation: 'relaxed', onInvalid }) as unknown as typeof fencePlugin;
const snippet = "specification { element box }\nmodel { a = box 'A' }\nviews { view flow { include * } }";
const { ctx, plugin, replaced } = pluginFor('```likec4 view="flow"\n' + snippet + '\n```', 'md', build);

await plugin.code?.(fence('view="flow"', snippet), ctx);

expect(replaced).toHaveLength(1);
expect(onInvalid).not.toHaveBeenCalled();
});

it('leaves a fence naming a view its own DSL does not declare as a code block in relaxed mode', async () => {
const onInvalid = vi.fn();
const build = likec4Fences({ validation: 'relaxed', onInvalid }) as unknown as typeof fencePlugin;
const snippet = "specification { element box }\nmodel { a = box 'A' }\nviews { view flow { include * } }";
const { ctx, plugin, replaced } = pluginFor('```likec4 view="missing"\n' + snippet + '\n```', 'mdx', build);

await plugin.code?.(fence('view="missing"', snippet), ctx);

expect(replaced).toHaveLength(0);
expect(onInvalid).toHaveBeenCalledWith(expect.stringContaining('has no view "missing"'));
});

it('relaxes only the pages a validation glob matches', async () => {
const root = resolve('/site');
const onInvalid = vi.fn();
const build = likec4Fences({
root,
validation: { default: 'strict', relaxed: ['tutorials/**'] },
onInvalid,
}) as unknown as typeof fencePlugin;
const invalid = '```likec4\nmodel { a = }\n```';

const tutorial = build({ source: invalid, sourceFormat: 'mdx' });
const replaced: unknown[] = [];

await tutorial.code?.(fence('', 'model { a = }'), {
fileURL: pathToFileURL(resolve(root, 'src/content/docs/tutorials/wip.mdx')),
replaceNode: (_node: unknown, next: unknown) => replaced.push(next),
});

expect(replaced).toHaveLength(0);
expect(onInvalid).toHaveBeenCalledTimes(1);

const reference = build({ source: invalid, sourceFormat: 'mdx' });

await expect(
Promise.resolve(
reference.code?.(fence('', 'model { a = }'), {
fileURL: pathToFileURL(resolve(root, 'src/content/docs/reference/api.mdx')),
replaceNode: () => {},
}),
),
).rejects.toThrow(/not valid LikeC4/);
});

it('relaxes every page of a listed content collection', async () => {
const root = resolve('/site');
const onInvalid = vi.fn();
const build = likec4Fences({
root,
contentDir: resolve(root, 'src/content'),
validation: { collections: ['tutorials'] },
onInvalid,
}) as unknown as typeof fencePlugin;
const invalid = '```likec4\nmodel { a = }\n```';

const tutorial = build({ source: invalid, sourceFormat: 'mdx' });

await tutorial.code?.(fence('', 'model { a = }'), {
fileURL: pathToFileURL(resolve(root, 'src/content/tutorials/wip.mdx')),
replaceNode: () => {},
});

expect(onInvalid).toHaveBeenCalledTimes(1);

const docs = build({ source: invalid, sourceFormat: 'mdx' });

await expect(
Promise.resolve(
docs.code?.(fence('', 'model { a = }'), {
fileURL: pathToFileURL(resolve(root, 'src/content/docs/api.mdx')),
replaceNode: () => {},
}),
),
).rejects.toThrow(/not valid LikeC4/);
});

it('swallows an empty fence a project check would reject, in relaxed mode', async () => {
const onInvalid = vi.fn();
const build = likec4Fences({ projects: ['docs', 'architecture'], validation: 'relaxed', onInvalid }) as unknown as typeof fencePlugin;
const { ctx, plugin, replaced } = pluginFor('```likec4 view="index"\n```', 'md', build);

await plugin.code?.(fence('view="index"'), ctx);

expect(replaced).toHaveLength(0);
expect(onInvalid).toHaveBeenCalledWith(expect.stringContaining('Several LikeC4 projects exist'));
});

it('swallows a file= that does not exist, in relaxed mode', async () => {
const site = await mkdtemp(join(tmpdir(), 'astroc4-fence-'));
const pageFile = join(site, 'src', 'content', 'docs', 'page.mdx');
const onInvalid = vi.fn();
const plugin = (likec4Fences({ root: site, validation: 'relaxed', onInvalid }) as any)({
source: '```likec4 file="./missing.c4"\n```',
sourceFormat: 'mdx',
});
const replaced: unknown[] = [];

await plugin.code(fence('file="./missing.c4"'), {
fileURL: pathToFileURL(pageFile),
replaceNode: (_node: unknown, next: unknown) => replaced.push(next),
});

expect(replaced).toHaveLength(0);
expect(onInvalid).toHaveBeenCalledWith(expect.stringContaining('does not exist'));
});

it('escapes everything it writes into the placeholder', () => {
const html = fencePlaceholder({
viewId: 'v"1',
Expand Down
75 changes: 75 additions & 0 deletions src/__tests__/integration-fences.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import { pathToFileURL } from 'node:url';

import { describe, expect, it, vi } from 'vitest';

const likec4Fences = vi.hoisted(() => vi.fn((_options?: unknown) => () => null));

// Only `likec4Fences` is replaced, so the integration loads LikeC4's Node API
// and the `file=` layout plugin exactly as it does in a build.
vi.mock('../fences.ts', async (importOriginal) => ({
...(await importOriginal<typeof import('../fences.ts')>()),
likec4Fences,
}));

import { likec4Integration, type LikeC4IntegrationOptions } from '../integration.ts';
import type { ValidationOptions } from '../validation.ts';

interface StubLogger {
info: ReturnType<typeof vi.fn>;
warn: ReturnType<typeof vi.fn>;
fork: ReturnType<typeof vi.fn>;
}

function stubLogger(): StubLogger {
const logger: StubLogger = { info: vi.fn(), warn: vi.fn(), fork: vi.fn() };

logger.fork.mockImplementation(() => logger);

return logger;
}

/** `vitePlugin.workspace` keeps project discovery out, so the test touches no files. */
async function setup(options: LikeC4IntegrationOptions = {}) {
const logger = stubLogger();

await likec4Integration({ vitePlugin: { workspace: 'diagrams' }, ...options }).hooks['astro:config:setup']!({
command: 'dev',
config: { root: pathToFileURL(process.cwd()), vite: {}, integrations: [] },
updateConfig: vi.fn(),
injectScript: vi.fn(),
logger,
} as never);

return { logger };
}

type FenceOptions = { validation?: ValidationOptions; onInvalid?: (message: string) => void };

/** The options the integration handed the fence transform on its last call. */
function lastFenceOptions(): FenceOptions {
const calls = likec4Fences.mock.calls as unknown as [FenceOptions][];

return calls.at(-1)![0];
}

describe('fence validation through the integration', () => {
it('leaves validation strict by default', async () => {
await setup();

expect(lastFenceOptions().validation).toBeUndefined();
});

it('passes the validation option through to the fence transform', async () => {
await setup({ validation: 'relaxed' });

expect(lastFenceOptions()).toMatchObject({ validation: 'relaxed' });
});

it('routes a skipped fence through the Astro logger, so the build warns instead of failing', async () => {
const { logger } = await setup({ validation: { relaxed: ['tutorials/**'] } });

lastFenceOptions().onInvalid?.('page.mdx: ```likec4 fence is not valid LikeC4');

expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('not valid LikeC4'));
});
});
Loading
Loading