From b27f867d8a6842b092a5e1773bd0fade85a20d55 Mon Sep 17 00:00:00 2001 From: Kieron Lanning Date: Fri, 6 Nov 2026 10:05:29 +0000 Subject: [PATCH 1/5] feat: support plain Astro alongside the Starlight plugin MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `@purview-dev/astroc4/integration` is now a published entry point: the same LikeC4 Vite plugin, fence transform, Windows preload and dependency fix the Starlight plugin installs, because both call the new `src/integration.ts`. The default export keeps working for `starlight({ plugins: [astroc4()] })` and also carries the Astro `astro:config:setup` hook, so the `integrations: [astroc4()]` that `astro add` writes produces a working site — and warns when it lands on a Starlight site, where the translations and the highlighting cannot run. Three Starlight assumptions had to go: `Astro.locals.t` is read as `unknown` because `App.Locals` is an empty interface without Starlight's augmentation, colours moved to a `--likec4-*` layer that falls back to scheme-neutral values so a plain Astro page is still legible, and the fence warning goes through `logger.fork('likec4-fences')` instead of `console`. The integration also adds `@astrojs/react` when the site has not, since the islands are `client:only="react"` and a site without a renderer builds and then draws nothing. `@astrojs/mdx` and `astro-expressive-code` stay the site's decision. `examples/astro` is a runnable plain-Astro site (`bun run dev:astro`, port 4322): one page, one model, an inline fence and `ec.config.mjs` highlighting. Bumps to 1.0.0-prerelease.5. --- bun.lock | 6 + examples/astro/astro.config.mjs | 36 +++ examples/astro/ec.config.mjs | 14 ++ examples/astro/src/likec4/likec4.config.json | 4 + examples/astro/src/likec4/model.c4 | 19 ++ examples/astro/src/pages/index.mdx | 48 ++++ examples/astro/tsconfig.json | 17 ++ package.json | 19 +- scripts/build.ts | 12 +- src/__tests__/fences.test.ts | 42 +++- src/__tests__/integration.test.ts | 127 ++++++++++ src/__tests__/plugin.test.ts | 183 +++++++++++++- src/components/DiagramFrame.astro | 18 +- src/components/DiagramShell.astro | 25 +- src/components/LikeC4Fence.astro | 3 +- src/components/LikeC4View.astro | 17 +- src/components/LoadingPlaceholder.astro | 14 +- src/fences.ts | 40 +++- src/index.ts | 172 ++++++------- src/integration.ts | 239 +++++++++++++++++++ 20 files changed, 925 insertions(+), 130 deletions(-) create mode 100644 examples/astro/astro.config.mjs create mode 100644 examples/astro/ec.config.mjs create mode 100644 examples/astro/src/likec4/likec4.config.json create mode 100644 examples/astro/src/likec4/model.c4 create mode 100644 examples/astro/src/pages/index.mdx create mode 100644 examples/astro/tsconfig.json create mode 100644 src/__tests__/integration.test.ts create mode 100644 src/integration.ts 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/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/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;