From fe79242a46fcdee215da9468643ad98f8f295627 Mon Sep 17 00:00:00 2001 From: Kieron Lanning Date: Thu, 12 Nov 2026 14:44:58 +0000 Subject: [PATCH] feat: added support for relaxing likec4 fence validation - for examples etc --- CHANGELOG.md | 17 +++ README.md | 1 + docs/wiki/guide/Fences.md | 46 ++++++++ package.json | 2 +- skills/purview-astroc4-plugin/SKILL.md | 8 +- src/__tests__/fences.test.ts | 133 ++++++++++++++++++++++- src/__tests__/integration-fences.test.ts | 75 +++++++++++++ src/__tests__/validation.test.ts | 88 +++++++++++++++ src/fences.ts | 108 ++++++++++++++---- src/index.ts | 2 + src/integration.ts | 20 +++- src/validation.ts | 81 ++++++++++++++ 12 files changed, 553 insertions(+), 28 deletions(-) create mode 100644 src/__tests__/integration-fences.test.ts create mode 100644 src/__tests__/validation.test.ts create mode 100644 src/validation.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 4e461d9..65f0cd7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/README.md b/README.md index 8f6d2b3..948af00 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/docs/wiki/guide/Fences.md b/docs/wiki/guide/Fences.md index 8f9a348..803840f 100644 --- a/docs/wiki/guide/Fences.md +++ b/docs/wiki/guide/Fences.md @@ -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//…`), 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 | diff --git a/package.json b/package.json index b2b5f1b..57df201 100644 --- a/package.json +++ b/package.json @@ -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: , ```likec4 fences, and LikeC4 syntax highlighting derived from the live Langium grammar.", "license": "MIT", "author": { diff --git a/skills/purview-astroc4-plugin/SKILL.md b/skills/purview-astroc4-plugin/SKILL.md index 1f3fd92..d00f5d6 100644 --- a/skills/purview-astroc4-plugin/SKILL.md +++ b/skills/purview-astroc4-plugin/SKILL.md @@ -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 @@ -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 `
` 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 ` 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=""` |
diff --git a/src/__tests__/fences.test.ts b/src/__tests__/fences.test.ts
index b97e7e9..8ffc531 100644
--- a/src/__tests__/fences.test.ts
+++ b/src/__tests__/fences.test.ts
@@ -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';
@@ -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',
diff --git a/src/__tests__/integration-fences.test.ts b/src/__tests__/integration-fences.test.ts
new file mode 100644
index 0000000..74f624b
--- /dev/null
+++ b/src/__tests__/integration-fences.test.ts
@@ -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()),
+	likec4Fences,
+}));
+
+import { likec4Integration, type LikeC4IntegrationOptions } from '../integration.ts';
+import type { ValidationOptions } from '../validation.ts';
+
+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;
+}
+
+/** `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'));
+	});
+});
diff --git a/src/__tests__/validation.test.ts b/src/__tests__/validation.test.ts
new file mode 100644
index 0000000..3e56dbc
--- /dev/null
+++ b/src/__tests__/validation.test.ts
@@ -0,0 +1,88 @@
+import { describe, expect, it } from 'vitest';
+
+import { globToRegExp, validationModeFor, type ValidationScope } from '../validation.ts';
+
+describe('globToRegExp', () => {
+	it('matches at any depth, so a bare section name finds a nested page', () => {
+		expect(globToRegExp('tutorials/**').test('src/content/docs/tutorials/wip.mdx')).toBe(true);
+		expect(globToRegExp('wip.mdx').test('src/content/docs/tutorials/wip.mdx')).toBe(true);
+	});
+
+	it('does not let * cross a path segment', () => {
+		expect(globToRegExp('tutorials/*.mdx').test('tutorials/wip.mdx')).toBe(true);
+		expect(globToRegExp('tutorials/*.mdx').test('tutorials/a/wip.mdx')).toBe(false);
+	});
+
+	it('lets ** cross path segments', () => {
+		expect(globToRegExp('docs/**/wip.mdx').test('docs/a/b/wip.mdx')).toBe(true);
+	});
+
+	it('treats a dot and a plus literally', () => {
+		expect(globToRegExp('a+b.mdx').test('a+b.mdx')).toBe(true);
+		expect(globToRegExp('a+b.mdx').test('axb.mdx')).toBe(false);
+	});
+
+	it('anchors the end, so a folder glob does not match a page inside it', () => {
+		expect(globToRegExp('docs').test('docs')).toBe(true);
+		expect(globToRegExp('docs').test('docs/wip.mdx')).toBe(false);
+	});
+});
+
+describe('validationModeFor', () => {
+	const page = 'src/content/docs/tutorials/wip.mdx';
+
+	it('is strict by default', () => {
+		expect(validationModeFor(undefined, page)).toBe('strict');
+		expect(validationModeFor({}, page)).toBe('strict');
+	});
+
+	it('takes a single mode for the whole site', () => {
+		expect(validationModeFor('relaxed', page)).toBe('relaxed');
+		expect(validationModeFor('strict', page)).toBe('strict');
+	});
+
+	it('relaxes the pages a glob matches, and leaves the rest at default', () => {
+		const options: ValidationScope = { default: 'strict', relaxed: ['tutorials/**'] };
+
+		expect(validationModeFor(options, page)).toBe('relaxed');
+		expect(validationModeFor(options, 'src/content/docs/reference/api.mdx')).toBe('strict');
+	});
+
+	it('accepts a single pattern as well as a list', () => {
+		const options: ValidationScope = { relaxed: 'tutorials/**' };
+
+		expect(validationModeFor(options, page)).toBe('relaxed');
+	});
+
+	it('relaxes a whole site set to `default: relaxed`', () => {
+		const options: ValidationScope = { default: 'relaxed' };
+
+		expect(validationModeFor(options, page)).toBe('relaxed');
+	});
+
+	it('falls back to default when the page path is unknown', () => {
+		const options: ValidationScope = { default: 'relaxed', relaxed: ['tutorials/**'] };
+
+		expect(validationModeFor(options, undefined)).toBe('relaxed');
+	});
+
+	it('relaxes every page of a listed content collection', () => {
+		const options: ValidationScope = { collections: ['tutorials'] };
+
+		expect(validationModeFor(options, 'src/content/tutorials/wip.mdx', 'tutorials')).toBe('relaxed');
+		expect(validationModeFor(options, 'src/content/docs/api.mdx', 'docs')).toBe('strict');
+	});
+
+	it('takes a single collection name, and matches it without a page path', () => {
+		const options: ValidationScope = { collections: 'tutorials' };
+
+		expect(validationModeFor(options, undefined, 'tutorials')).toBe('relaxed');
+		expect(validationModeFor(options, undefined, 'docs')).toBe('strict');
+	});
+
+	it('relaxes a matching collection even when default stays strict', () => {
+		const options: ValidationScope = { default: 'strict', collections: ['tutorials'] };
+
+		expect(validationModeFor(options, 'src/content/tutorials/wip.mdx', 'tutorials')).toBe('relaxed');
+	});
+});
diff --git a/src/fences.ts b/src/fences.ts
index 8295edd..ffe82f3 100644
--- a/src/fences.ts
+++ b/src/fences.ts
@@ -15,6 +15,7 @@ import { fileURLToPath } from 'node:url';
 import type { MdastPluginEntry, PluginFactoryContext } from 'satteri';
 
 import { translations } from './translations.ts';
+import { validationModeFor, type ValidationOptions } from './validation.ts';
 
 const require = createRequire(import.meta.url);
 
@@ -59,6 +60,9 @@ type ComponentName = keyof typeof IMPORTS;
 
 const STRINGS = translations.en;
 
+/** Appended to every reason `relaxed` validation turns into a warning instead of an error. */
+const RELAXED_NOTE = 'Left as a highlighted code block because this page is validated in `relaxed` mode.';
+
 interface Layout {
 	dump: any;
 	viewIds: string[];
@@ -174,6 +178,31 @@ function describe(fileURL: URL | undefined): string {
 	}
 }
 
+/** The page's path from the site root, so validation can be scoped by page. */
+function pagePath(fileURL: URL | undefined, root: string | undefined): string | undefined {
+	if (!fileURL || !root) return undefined;
+
+	try {
+		return relative(resolve(root), fileURLToPath(fileURL));
+	} catch {
+		return undefined;
+	}
+}
+
+/**
+ * The content collection a page belongs to: its first folder under the content
+ * directory, which is how Astro's `glob` loader maps a collection to a folder.
+ */
+function collectionOf(fileURL: URL | undefined, contentDir: string | undefined): string | undefined {
+	const within = pagePath(fileURL, contentDir);
+
+	if (!within || within.startsWith('..')) return undefined;
+
+	const [first, ...rest] = within.split(/[\\/]/);
+
+	return rest.length > 0 ? first : undefined;
+}
+
 export interface FenceReporter {
 	warn: (message: string) => void;
 }
@@ -190,10 +219,21 @@ export interface FenceOptions {
 	 * `project` is checked at build time; without it, only the browser can.
 	 */
 	projects?: string[];
+	/**
+	 * How a fence that cannot be turned into a diagram is treated. `strict` (the
+	 * default) fails the build and `astro check`; `relaxed` logs a warning and
+	 * leaves the fence as a highlighted code block. Relaxing can be scoped by
+	 * page path glob or content collection. See `./validation.ts`.
+	 */
+	validation?: ValidationOptions;
+	/** The site's content directory, so a page's collection can be resolved. */
+	contentDir?: string;
+	/** Called with the reason for each fence `relaxed` validation leaves as a code block. */
+	onInvalid?: (message: string) => void;
 }
 
 // region plugin
-export function likec4Fences({ root, projects }: FenceOptions = {}): MdastPluginEntry {
+export function likec4Fences({ root, projects, validation, contentDir, onInvalid }: FenceOptions = {}): MdastPluginEntry {
 	// A factory runs once per document, so `used` is per page.
 	return (factory: PluginFactoryContext) => {
 		if (!factory.source.includes('```' + FENCE_LANG)) return null;
@@ -209,11 +249,25 @@ export function likec4Fences({ root, projects }: FenceOptions = {}): MdastPlugin
 			async code(node, ctx) {
 				if (node.lang !== FENCE_LANG) return;
 
+				const mode = validationModeFor(validation, pagePath(ctx.fileURL, root), collectionOf(ctx.fileURL, contentDir));
+				const fail = (reason: string): void => {
+					if (mode === 'strict') throw new Error(reason);
+
+					onInvalid?.(`${reason} ${RELAXED_NOTE}`);
+				};
 				const where = `${describe(ctx.fileURL)}: \`\`\`${FENCE_LANG} fence`;
 				const options = parseFenceMeta(node.meta);
-				const source = options.file
-					? await readFenceFile(options.file, ctx.fileURL, root, where)
-					: (node.value ?? '').trim();
+				let source;
+
+				try {
+					source = options.file
+						? await readFenceFile(options.file, ctx.fileURL, root, where)
+						: (node.value ?? '').trim();
+				} catch (error) {
+					fail(error instanceof Error ? error.message : String(error));
+
+					return;
+				}
 
 				const common = {
 					title: options.title,
@@ -225,10 +279,18 @@ export function likec4Fences({ root, projects }: FenceOptions = {}): MdastPlugin
 
 				if (source.length === 0) {
 					if (!options.view) {
-						throw new Error(`${where} is empty, so it needs \`view=""\` to name a view in the project model.`);
+						fail(`${where} is empty, so it needs \`view=""\` to name a view in the project model.`);
+
+						return;
 					}
 
-					checkProject(options.project, projects, where);
+					const projectFailure = projectError(options.project, projects, where);
+
+					if (projectFailure !== undefined) {
+						fail(projectFailure);
+
+						return;
+					}
 
 					if (markdown) {
 						ctx.replaceNode(node, {
@@ -256,17 +318,17 @@ export function likec4Fences({ root, projects }: FenceOptions = {}): MdastPlugin
 				try {
 					layout = await layoutSource(source);
 				} catch (error) {
-					throw new Error(
-						`${where} is not valid LikeC4 — ${error instanceof Error ? error.message : String(error)}`,
-					);
+					fail(`${where} is not valid LikeC4 — ${error instanceof Error ? error.message : String(error)}`);
+
+					return;
 				}
 
 				const viewId = options.view ?? defaultView(source, layout.viewIds);
 
 				if (!viewId || !layout.viewIds.includes(viewId)) {
-					throw new Error(
-						`${where} has no view "${viewId ?? '(none)'}". Declared: ${layout.viewIds.join(', ') || '(none)'}.`,
-					);
+					fail(`${where} has no view "${viewId ?? '(none)'}". Declared: ${layout.viewIds.join(', ') || '(none)'}.`);
+
+					return;
 				}
 
 				if (markdown) {
@@ -303,27 +365,25 @@ export function likec4Fences({ root, projects }: FenceOptions = {}): MdastPlugin
 // endregion
 
 /** The same rule `` applies: required with several projects, and must exist. */
-function checkProject(project: string | undefined, projects: string[] | undefined, where: string): void {
-	if (projects === undefined) return;
+function projectError(project: string | undefined, projects: string[] | undefined, where: string): string | undefined {
+	if (projects === undefined) return undefined;
 
 	const requested = project?.trim();
 	const available = projects.join(', ');
 
 	if (!requested) {
-		if (projects.length > 1) {
-			throw new Error(`${where}: ${STRINGS['likec4.error.projectRequired'].replace('{available}', available)}`);
-		}
-
-		return;
+		return projects.length > 1
+			? `${where}: ${STRINGS['likec4.error.projectRequired'].replace('{available}', available)}`
+			: undefined;
 	}
 
 	if (!projects.includes(requested)) {
-		throw new Error(
-			`${where}: ${STRINGS['likec4.error.unknownProject']
-				.replace('{projectId}', requested)
-				.replace('{available}', available || '(none)')}`,
-		);
+		return `${where}: ${STRINGS['likec4.error.unknownProject']
+			.replace('{projectId}', requested)
+			.replace('{available}', available || '(none)')}`;
 	}
+
+	return undefined;
 }
 
 /**
diff --git a/src/index.ts b/src/index.ts
index a2e7b98..62133e6 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -16,6 +16,7 @@ import { LANGUAGE_ALIASES, LANGUAGE_NAME } from './grammar/textmate.ts';
 import { translations } from './translations.ts';
 
 export { missingOptimizedDependencies };
+export type { ValidationMode, ValidationOptions, ValidationScope } from './validation.ts';
 
 export interface LikeC4HighlightingOptions {
 	/** Branch, tag or commit SHA of `likec4/likec4`. Defaults to `main`; also `ASTROC4_REF`. */
@@ -56,6 +57,7 @@ export default function starlightLikeC4(options: StarlightLikeC4Options = {}): L
 		vitePlugin: options.vitePlugin,
 		projects: options.projects,
 		fences: options.fences,
+		validation: options.validation,
 	});
 
 	return {
diff --git a/src/integration.ts b/src/integration.ts
index 3eb26e6..c628c76 100644
--- a/src/integration.ts
+++ b/src/integration.ts
@@ -11,10 +11,13 @@ import { fileLayoutPlugin } from './file-layout.ts';
 import { astroc4IconPlugin, type Astroc4IconPlugin } from './icon-plugin.ts';
 import { discoverProjects, resolveProjects, type ResolvedProject } from './projects.ts';
 import { prepareWorkspace, PROJECTS_MODULE_ID, projectsModule } from './workspace.ts';
+import type { ValidationOptions } from './validation.ts';
 
 /** Scoped because npm refuses the unscoped `astroc4` as too similar to `astro`. */
 export const PACKAGE_NAME = '@purview-dev/astroc4';
 
+export type { ValidationMode, ValidationOptions, ValidationScope } from './validation.ts';
+
 export interface LikeC4IntegrationOptions {
 	/**
 	 * Passed through to `LikeC4VitePlugin`. Setting `workspace` or
@@ -29,6 +32,14 @@ export interface LikeC4IntegrationOptions {
 	projects?: Record;
 	/** Render ```likec4 fences in .md and .mdx pages as diagrams. Defaults to `true`. */
 	fences?: boolean;
+	/**
+	 * How a fence that cannot be turned into a diagram is treated. `strict` (the
+	 * default) fails the build and `astro check`; `relaxed` logs a warning and
+	 * leaves the fence as a highlighted code block. Relaxing can be scoped by
+	 * page path glob or content collection, so a tutorial section or collection
+	 * need not take the whole site's strictness with it.
+	 */
+	validation?: ValidationOptions;
 }
 
 /**
@@ -88,6 +99,7 @@ export function likec4Integration({
 	vitePlugin = {},
 	projects,
 	fences = true,
+	validation,
 }: LikeC4IntegrationOptions = {}): AstroIntegration {
 	let icons: Astroc4IconPlugin | undefined;
 
@@ -111,7 +123,7 @@ export function likec4Integration({
 				});
 
 				if (fences) {
-					await registerFences(config, updateConfig, logger, resolved);
+					await registerFences(config, updateConfig, logger, resolved, validation);
 					injectScript('page', MD_FENCES_SCRIPT);
 				}
 
@@ -211,14 +223,20 @@ async function registerFences(
 	updateConfig: (config: Record) => void,
 	logger: AstroIntegrationLogger,
 	projects: ResolvedProject[],
+	validation: ValidationOptions | undefined,
 ): Promise {
 	const { satteri, isSatteriProcessor } = await import('@astrojs/markdown-satteri');
 	const existing = config.markdown?.processor;
 	const fences = () =>
 		likec4Fences({
 			root: fileURLToPath(config.root),
+			// `validation` can scope by page path and by content collection, and only
+			// the content directory says where a collection's folder starts.
+			contentDir: config.srcDir ? fileURLToPath(new URL('content/', config.srcDir)) : undefined,
 			// Empty when the site took discovery over: then only the browser knows.
 			projects: projects.length > 0 ? projects.map((project) => project.name) : undefined,
+			validation,
+			onInvalid: (message) => logger.warn(message),
 		});
 
 	// No processor configured means Astro's default, which is Sätteri.
diff --git a/src/validation.ts b/src/validation.ts
new file mode 100644
index 0000000..e9c0837
--- /dev/null
+++ b/src/validation.ts
@@ -0,0 +1,81 @@
+// How a fence that cannot be turned into a diagram is treated: `strict` fails the
+// build — and so `astro check`, which runs the same content transform — while
+// `relaxed` logs the reason as a warning and leaves the fence as a highlighted
+// code block. Relaxing can be scoped by content collection or page path, so a
+// tutorial section need not take the whole site's strictness with it.
+
+export type ValidationMode = 'strict' | 'relaxed';
+
+export interface ValidationScope {
+	/** The mode for a page no `relaxed` pattern matches. Defaults to `'strict'`. */
+	default?: ValidationMode;
+	/**
+	 * Page path globs, relative to the site root, that use `'relaxed'`. `*`
+	 * matches within a path segment, `**` across segments, and a pattern matches
+	 * at any depth — `tutorials/**` finds `src/content/docs/tutorials/…`.
+	 */
+	relaxed?: string | string[];
+	/**
+	 * Content collections that use `'relaxed'`, by name. A page's collection is
+	 * its first folder under the content directory (`src/content//…`),
+	 * which is how Astro's `glob` loader maps a collection to a folder.
+	 */
+	collections?: string | string[];
+}
+
+/** A single mode for the whole site, or per-page rules. */
+export type ValidationOptions = ValidationMode | ValidationScope;
+
+/** A glob as a regexp anchored at both ends, with an implicit "any folder" prefix. */
+export function globToRegExp(pattern: string): RegExp {
+	let body = '';
+
+	for (let index = 0; index < pattern.length; index += 1) {
+		const char = pattern[index]!;
+
+		if (char === '*') {
+			if (pattern[index + 1] === '*') {
+				body += '.*';
+				index += 1;
+			} else {
+				body += '[^/]*';
+			}
+		} else if (char === '?') {
+			body += '[^/]';
+		} else if ('.+^${}()|[]\\'.includes(char)) {
+			body += `\\${char}`;
+		} else {
+			body += char;
+		}
+	}
+
+	return new RegExp(`^(?:.*/)?${body}$`);
+}
+
+const toSlashes = (value: string): string => value.replaceAll('\\', '/');
+
+/**
+ * The mode a page uses. `relativePath` is the page's path from the site root and
+ * `collection` its content collection; either may be `undefined` when it cannot
+ * be worked out — then whatever is left decides, falling back to `default`.
+ */
+export function validationModeFor(
+	options: ValidationOptions | undefined,
+	relativePath: string | undefined,
+	collection?: string,
+): ValidationMode {
+	if (options === undefined) return 'strict';
+	if (typeof options === 'string') return options;
+
+	const fallback = options.default ?? 'strict';
+	const collections = options.collections === undefined ? [] : [options.collections].flat();
+
+	if (collection !== undefined && collections.includes(collection)) return 'relaxed';
+
+	if (relativePath === undefined) return fallback;
+
+	const patterns = options.relaxed === undefined ? [] : [options.relaxed].flat();
+	const path = toSlashes(relativePath);
+
+	return patterns.some((pattern) => globToRegExp(pattern).test(path)) ? 'relaxed' : fallback;
+}