From 6a185c05dc3ee5ac9e3419e4ec44d6e7498f20b6 Mon Sep 17 00:00:00 2001 From: Kieron Lanning Date: Mon, 9 Nov 2026 13:47:37 +0000 Subject: [PATCH 01/15] chore: external playground project for named projects --- playground-external/images/brand/logo.svg | 1 + playground-external/likec4.config.json | 8 ++++++++ playground-external/model.c4 | 15 +++++++++++++++ 3 files changed, 24 insertions(+) create mode 100644 playground-external/images/brand/logo.svg create mode 100644 playground-external/likec4.config.json create mode 100644 playground-external/model.c4 diff --git a/playground-external/images/brand/logo.svg b/playground-external/images/brand/logo.svg new file mode 100644 index 0000000..aa59b2b --- /dev/null +++ b/playground-external/images/brand/logo.svg @@ -0,0 +1 @@ + diff --git a/playground-external/likec4.config.json b/playground-external/likec4.config.json new file mode 100644 index 0000000..3fde917 --- /dev/null +++ b/playground-external/likec4.config.json @@ -0,0 +1,8 @@ +{ + "$schema": "../node_modules/likec4/config/schema.json", + "name": "external", + "title": "astroc4 external project", + "imageAliases": { + "@": "./images" + } +} diff --git a/playground-external/model.c4 b/playground-external/model.c4 new file mode 100644 index 0000000..0fd3567 --- /dev/null +++ b/playground-external/model.c4 @@ -0,0 +1,15 @@ +specification { + element system +} + +model { + brand = system 'Brand' { + icon @/brand/logo.svg + } +} + +views { + view index { + include * + } +} From 2646396ea8b0741215abce9ef751937d3e476009 Mon Sep 17 00:00:00 2001 From: Kieron Lanning Date: Mon, 9 Nov 2026 13:48:44 +0000 Subject: [PATCH 02/15] feat: resolve configured and discovered LikeC4 projects --- src/__tests__/projects.test.ts | 114 +++++++++++++++++++++++++ src/projects.ts | 152 +++++++++++++++++++++++++++++++++ 2 files changed, 266 insertions(+) create mode 100644 src/__tests__/projects.test.ts create mode 100644 src/projects.ts diff --git a/src/__tests__/projects.test.ts b/src/__tests__/projects.test.ts new file mode 100644 index 0000000..150214c --- /dev/null +++ b/src/__tests__/projects.test.ts @@ -0,0 +1,114 @@ +import { mkdir, mkdtemp, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join, resolve } from 'node:path'; + +import { describe, expect, it } from 'vitest'; + +import { Astroc4ConfigError, discoverProjects, resolveProjects } from '../projects'; + +async function project(folder: string, name: string, extra: Record = {}): Promise { + await mkdir(folder, { recursive: true }); + await writeFile(join(folder, 'likec4.config.json'), JSON.stringify({ name, ...extra })); + await writeFile(join(folder, 'model.c4'), 'model {}'); + return folder; +} + +async function tempRoot(): Promise { + return mkdtemp(join(tmpdir(), 'astroc4-projects-')); +} + +describe('resolveProjects', () => { + it('resolves a configured folder relative to root and an absolute one', async () => { + const root = await tempRoot(); + await project(join(root, 'site', 'diagrams'), 'site-diagrams'); + const outside = await project(join(root, 'elsewhere'), 'elsewhere-config-name'); + + const projects = await resolveProjects({ + root: join(root, 'site'), + configured: { local: './diagrams', remote: outside }, + discovered: [], + }); + + expect(projects).toMatchObject([ + { name: 'local', folder: resolve(root, 'site', 'diagrams'), likec4Id: 'site-diagrams', source: 'configured' }, + { name: 'remote', folder: outside, likec4Id: 'elsewhere-config-name', source: 'configured' }, + ]); + }); + + it('fails for a configured folder that does not exist', async () => { + const root = await tempRoot(); + + await expect( + resolveProjects({ root, configured: { missing: './nowhere' }, discovered: [] }), + ).rejects.toThrow(Astroc4ConfigError); + await expect( + resolveProjects({ root, configured: { missing: './nowhere' }, discovered: [] }), + ).rejects.toThrow(/missing.*nowhere|nowhere.*missing/s); + }); + + it('fails for a configured folder without likec4.config.json', async () => { + const root = await tempRoot(); + await mkdir(join(root, 'empty')); + + await expect( + resolveProjects({ root, configured: { empty: './empty' }, discovered: [] }), + ).rejects.toThrow(/likec4\.config\.json[\s\S]*empty/); + }); + + it('compiles a configured project that is also discovered once, under its configured name', async () => { + const root = await tempRoot(); + await project(join(root, 'diagrams'), 'site-diagrams'); + const discovered = await discoverProjects(root); + + const projects = await resolveProjects({ root, configured: { arch: './diagrams' }, discovered }); + + expect(projects).toHaveLength(1); + expect(projects[0]).toMatchObject({ name: 'arch', source: 'configured' }); + }); + + it('fails when two projects share a name', async () => { + const root = await tempRoot(); + await project(join(root, 'a'), 'shared'); + const other = await project(join(root, 'outside', 'b'), 'other'); + const discovered = await discoverProjects(join(root, 'a')); + + await expect( + resolveProjects({ root: join(root, 'a'), configured: { shared: other }, discovered }), + ).rejects.toThrow(/shared[\s\S]*a[\s\S]*b|shared[\s\S]*b[\s\S]*a/); + }); + + it('resolves imageAliases to absolute folders against the project folder', async () => { + const root = await tempRoot(); + const folder = await project(join(root, 'diagrams'), 'site-diagrams', { + imageAliases: { '@': './images', '@brand': '../brand' }, + }); + + const [resolved] = await resolveProjects({ root, configured: { arch: folder }, discovered: [] }); + + expect(resolved!.imageAliases).toEqual({ + '@': resolve(folder, 'images'), + '@brand': resolve(root, 'brand'), + }); + }); +}); + +describe('discoverProjects', () => { + it('finds every likec4.config.json under root and names it by its config name', async () => { + const root = await tempRoot(); + await project(join(root, 'src', 'likec4'), 'playground'); + + const projects = await discoverProjects(root); + + expect(projects).toMatchObject([ + { name: 'playground', likec4Id: 'playground', source: 'discovered', folder: resolve(root, 'src', 'likec4') }, + ]); + }); + + it('skips node_modules and dot folders', async () => { + const root = await tempRoot(); + await project(join(root, 'node_modules', 'pkg'), 'from-node-modules'); + await project(join(root, '.astro', 'astroc4', 'workspace', 'x'), 'from-cache'); + + expect(await discoverProjects(root)).toEqual([]); + }); +}); diff --git a/src/projects.ts b/src/projects.ts new file mode 100644 index 0000000..f244107 --- /dev/null +++ b/src/projects.ts @@ -0,0 +1,152 @@ +// projects.ts +// path: src/projects.ts +// Which LikeC4 projects a site renders, and what each is called. +// +// Two sources, compiled together: +// - discovered: every `likec4.config.json` under the site root, named by the +// `name` in its own config (today's behaviour); +// - configured: `projects: { : }` in the plugin options, where +// the folder may live anywhere — another repository, a sibling folder — and +// the key is the name components use. +// +// Every failure is an `Astroc4ConfigError` naming the folder, so a typo in +// astro.config fails the build with the path rather than a blank diagram. + +import { readdir, readFile, stat } from 'node:fs/promises'; +import { join, resolve } from 'node:path'; + +const CONFIG_FILE = 'likec4.config.json'; + +export interface ResolvedProject { + /** The name components pass as `project`. */ + name: string; + /** Absolute folder holding the project's `likec4.config.json`. */ + folder: string; + /** The `name` in the project's own config — LikeC4's project id. */ + likec4Id: string; + source: 'configured' | 'discovered'; + /** `imageAliases` from the config, each resolved to an absolute folder. */ + imageAliases: Record; +} + +export class Astroc4ConfigError extends Error { + constructor(message: string) { + super(`[astroc4] ${message}`); + this.name = 'Astroc4ConfigError'; + } +} + +/** Every LikeC4 project under `root`, skipping `node_modules` and dot folders. */ +export async function discoverProjects(root: string): Promise { + const found: ResolvedProject[] = []; + + async function walk(folder: string): Promise { + const entries = await readdir(folder, { withFileTypes: true }); + + if (entries.some((entry) => entry.isFile() && entry.name === CONFIG_FILE)) { + const config = await readConfig(folder); + found.push(toProject(config.name, folder, config, 'discovered')); + } + + for (const entry of entries) { + if (!entry.isDirectory() || entry.name === 'node_modules' || entry.name.startsWith('.')) continue; + await walk(join(folder, entry.name)); + } + } + + await walk(resolve(root)); + return found; +} + +/** + * Configured projects first, in declaration order, then the discovered ones + * no configured entry already covers. + */ +export async function resolveProjects(options: { + root: string; + configured?: Record; + discovered: ResolvedProject[]; +}): Promise { + const configured: ResolvedProject[] = []; + + for (const [name, path] of Object.entries(options.configured ?? {})) { + const folder = resolve(options.root, path); + + if (!(await isDirectory(folder))) { + throw new Astroc4ConfigError(`project "${name}": folder ${folder} does not exist.`); + } + + if (!(await isFile(join(folder, CONFIG_FILE)))) { + throw new Astroc4ConfigError(`project "${name}": no ${CONFIG_FILE} in ${folder}.`); + } + + configured.push(toProject(name, folder, await readConfig(folder), 'configured')); + } + + const configuredFolders = new Set(configured.map((project) => project.folder)); + const projects = [ + ...configured, + ...options.discovered.filter((project) => !configuredFolders.has(project.folder)), + ]; + + const byName = new Map(); + + for (const project of projects) { + const existing = byName.get(project.name); + + if (existing) { + throw new Astroc4ConfigError( + `two LikeC4 projects are named "${project.name}": ${existing.folder} and ${project.folder}.`, + ); + } + + byName.set(project.name, project); + } + + return projects; +} + +interface LikeC4Config { + name: string; + imageAliases?: Record; +} + +async function readConfig(folder: string): Promise { + const path = join(folder, CONFIG_FILE); + let config: unknown; + + try { + config = JSON.parse(await readFile(path, 'utf8')); + } catch (error) { + throw new Astroc4ConfigError(`${path} is not valid JSON: ${(error as Error).message}`); + } + + const name = (config as { name?: unknown }).name; + + if (typeof name !== 'string' || name.trim().length === 0) { + throw new Astroc4ConfigError(`${path} has no "name".`); + } + + return config as LikeC4Config; +} + +function toProject( + name: string, + folder: string, + config: LikeC4Config, + source: ResolvedProject['source'], +): ResolvedProject { + const imageAliases = Object.fromEntries( + Object.entries(config.imageAliases ?? {}).map(([alias, path]) => [alias, resolve(folder, path)]), + ); + + return { name, folder: resolve(folder), likec4Id: config.name, source, imageAliases }; +} + +async function isDirectory(path: string): Promise { + return (await stat(path).catch(() => null))?.isDirectory() ?? false; +} + +async function isFile(path: string): Promise { + return (await stat(path).catch(() => null))?.isFile() ?? false; +} From 11fff5161c9e2700c071870d38a7cd4dc4a1c91e Mon Sep 17 00:00:00 2001 From: Kieron Lanning Date: Mon, 9 Nov 2026 13:51:28 +0000 Subject: [PATCH 03/15] feat: named LikeC4 projects through a staging workspace --- src/__tests__/integration-projects.test.ts | 85 ++++++++++++++++++++++ src/__tests__/integration.test.ts | 4 +- src/__tests__/projects.test.ts | 10 +++ src/__tests__/workspace.test.ts | 83 +++++++++++++++++++++ src/index.ts | 6 +- src/integration.ts | 73 +++++++++++++++++-- src/projects.ts | 17 +++++ src/workspace.ts | 83 +++++++++++++++++++++ 8 files changed, 354 insertions(+), 7 deletions(-) create mode 100644 src/__tests__/integration-projects.test.ts create mode 100644 src/__tests__/workspace.test.ts create mode 100644 src/workspace.ts diff --git a/src/__tests__/integration-projects.test.ts b/src/__tests__/integration-projects.test.ts new file mode 100644 index 0000000..2477346 --- /dev/null +++ b/src/__tests__/integration-projects.test.ts @@ -0,0 +1,85 @@ +import { mkdir, mkdtemp, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { pathToFileURL } from 'node:url'; + +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const likec4VitePlugin = vi.hoisted(() => vi.fn((_options?: unknown) => ({ name: 'likec4-stub' }))); + +vi.mock('likec4/vite-plugin', () => ({ LikeC4VitePlugin: likec4VitePlugin })); + +import { likec4Integration, type LikeC4IntegrationOptions } from '../integration.js'; +import { PROJECTS_MODULE_ID } from '../workspace.js'; + +async function folderWithConfig(folder: string, name: string): Promise { + await mkdir(folder, { recursive: true }); + await writeFile(join(folder, 'likec4.config.json'), JSON.stringify({ name })); + await writeFile(join(folder, 'model.c4'), 'model {}'); + return folder; +} + +async function setup(root: string, options: LikeC4IntegrationOptions) { + const updateConfig = vi.fn(); + const logger = { info: vi.fn(), warn: vi.fn(), fork: vi.fn() }; + logger.fork.mockImplementation(() => logger); + + await likec4Integration({ ...options, fences: false }).hooks['astro:config:setup']!({ + command: 'dev', + config: { root: pathToFileURL(join(root, '/')), vite: {}, integrations: [] }, + updateConfig, + logger, + } as never); + + const vite = (updateConfig.mock.calls[0]?.[0] as { vite: { plugins: any[] } }).vite; + + return { logger, vite }; +} + +/** Load `virtual:astroc4/projects` through the plugin that serves it. */ +async function projectsModuleSource(plugins: any[]): Promise { + for (const plugin of plugins) { + const resolved = await plugin.resolveId?.(PROJECTS_MODULE_ID); + if (resolved) return plugin.load(resolved); + } + + throw new Error('no plugin serves virtual:astroc4/projects'); +} + +describe('likec4Integration with projects', () => { + beforeEach(() => likec4VitePlugin.mockClear()); + + it('passes the staging workspace to LikeC4VitePlugin when projects is set', async () => { + const temp = await mkdtemp(join(tmpdir(), 'astroc4-site-')); + const site = join(temp, 'site'); + await folderWithConfig(join(site, 'src', 'likec4'), 'local'); + const external = await folderWithConfig(join(temp, 'external'), 'external-model'); + + const { vite } = await setup(site, { projects: { architecture: external } }); + + expect(likec4VitePlugin).toHaveBeenCalledWith({ workspace: join(site, '.astro', 'astroc4', 'workspace') }); + const source = await projectsModuleSource(vite.plugins); + expect(source).toContain('["architecture","local"]'); + expect(source).toContain('"architecture":"external-model"'); + }); + + it('keeps an explicit vitePlugin.workspace and says projects is ignored', async () => { + const site = await mkdtemp(join(tmpdir(), 'astroc4-site-')); + const external = await folderWithConfig(join(site, '..', `ext-${Date.now()}`), 'ext'); + + const { logger } = await setup(site, { vitePlugin: { workspace: 'diagrams' }, projects: { ext: external } }); + + expect(likec4VitePlugin).toHaveBeenCalledWith({ workspace: 'diagrams' }); + expect(logger.warn).toHaveBeenCalledWith(expect.stringMatching(/projects.*ignored|ignored.*projects/)); + }); + + it('leaves discovery to LikeC4 when projects is not set, and still names the discovered projects', async () => { + const site = await mkdtemp(join(tmpdir(), 'astroc4-site-')); + await folderWithConfig(join(site, 'src', 'likec4'), 'playground'); + + const { vite } = await setup(site, {}); + + expect(likec4VitePlugin).toHaveBeenCalledWith({}); + expect(await projectsModuleSource(vite.plugins)).toContain('["playground"]'); + }); +}); diff --git a/src/__tests__/integration.test.ts b/src/__tests__/integration.test.ts index 45aefb2..3e5264d 100644 --- a/src/__tests__/integration.test.ts +++ b/src/__tests__/integration.test.ts @@ -64,7 +64,9 @@ describe('likec4Integration', () => { const vite = viteOf(updateConfig); - expect(vite.plugins).toHaveLength(1); + // LikeC4's plugin, then the one serving `virtual:astroc4/projects`. + expect(vite.plugins).toHaveLength(2); + expect(vite.plugins[1].name).toBe(`${PACKAGE_NAME}/projects`); expect(vite.ssr).toEqual({ noExternal: [PACKAGE_NAME] }); expect(vite.optimizeDeps).toEqual({ include: ['react/compiler-runtime'] }); }); diff --git a/src/__tests__/projects.test.ts b/src/__tests__/projects.test.ts index 150214c..05ce1ba 100644 --- a/src/__tests__/projects.test.ts +++ b/src/__tests__/projects.test.ts @@ -77,6 +77,16 @@ describe('resolveProjects', () => { ).rejects.toThrow(/shared[\s\S]*a[\s\S]*b|shared[\s\S]*b[\s\S]*a/); }); + it('fails when two projects carry the same LikeC4 id in their configs', async () => { + const root = await tempRoot(); + const first = await project(join(root, 'one'), 'default'); + const second = await project(join(root, 'two'), 'default'); + + await expect( + resolveProjects({ root, configured: { one: first, two: second }, discovered: [] }), + ).rejects.toThrow(/"default"[\s\S]*one[\s\S]*two/); + }); + it('resolves imageAliases to absolute folders against the project folder', async () => { const root = await tempRoot(); const folder = await project(join(root, 'diagrams'), 'site-diagrams', { diff --git a/src/__tests__/workspace.test.ts b/src/__tests__/workspace.test.ts new file mode 100644 index 0000000..f6c8f70 --- /dev/null +++ b/src/__tests__/workspace.test.ts @@ -0,0 +1,83 @@ +import { mkdir, mkdtemp, readdir, realpath, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { describe, expect, it } from 'vitest'; + +import type { ResolvedProject } from '../projects'; +import { prepareWorkspace, projectsModule, PROJECTS_MODULE_ID } from '../workspace'; + +async function folderWithConfig(root: string, name: string): Promise { + const folder = join(root, name); + await mkdir(folder, { recursive: true }); + await writeFile(join(folder, 'likec4.config.json'), JSON.stringify({ name })); + return folder; +} + +function project(name: string, folder: string, likec4Id = name): ResolvedProject { + return { name, folder, likec4Id, source: 'configured', imageAliases: {} }; +} + +describe('prepareWorkspace', () => { + it('creates one entry per project named by its resolved name', async () => { + const root = await mkdtemp(join(tmpdir(), 'astroc4-workspace-')); + const a = await folderWithConfig(root, 'a'); + const b = await folderWithConfig(root, 'b'); + + const staging = await prepareWorkspace([project('first', a), project('second', b)], join(root, 'cache')); + + expect(staging).toBe(join(root, 'cache', 'workspace')); + expect((await readdir(staging)).sort()).toEqual(['first', 'second']); + expect(await realpath(join(staging, 'first'))).toBe(await realpath(a)); + }); + + it('removes an entry for a project no longer configured', async () => { + const root = await mkdtemp(join(tmpdir(), 'astroc4-workspace-')); + const a = await folderWithConfig(root, 'a'); + const b = await folderWithConfig(root, 'b'); + const cache = join(root, 'cache'); + + await prepareWorkspace([project('first', a), project('second', b)], cache); + const staging = await prepareWorkspace([project('first', a)], cache); + + expect(await readdir(staging)).toEqual(['first']); + // Removing the link never touches the project it pointed at. + expect(await readdir(b)).toEqual(['likec4.config.json']); + }); + + it('is idempotent', async () => { + const root = await mkdtemp(join(tmpdir(), 'astroc4-workspace-')); + const a = await folderWithConfig(root, 'a'); + const cache = join(root, 'cache'); + + await prepareWorkspace([project('first', a)], cache); + const staging = await prepareWorkspace([project('first', a)], cache); + + expect(await readdir(staging)).toEqual(['first']); + }); + + it('repoints an entry whose project moved', async () => { + const root = await mkdtemp(join(tmpdir(), 'astroc4-workspace-')); + const a = await folderWithConfig(root, 'a'); + const b = await folderWithConfig(root, 'b'); + const cache = join(root, 'cache'); + + await prepareWorkspace([project('first', a)], cache); + const staging = await prepareWorkspace([project('first', b)], cache); + + expect(await realpath(join(staging, 'first'))).toBe(await realpath(b)); + }); +}); + +describe('projectsModule', () => { + it('exports the project names and their LikeC4 ids', () => { + const source = projectsModule([project('architecture', '/x', 'minder-architecture'), project('other', '/y')]); + + expect(source).toContain('export const projectNames = ["architecture","other"];'); + expect(source).toContain('export const likec4IdFor = {"architecture":"minder-architecture","other":"other"};'); + }); + + it('has a stable virtual id', () => { + expect(PROJECTS_MODULE_ID).toBe('virtual:astroc4/projects'); + }); +}); diff --git a/src/index.ts b/src/index.ts index af45e73..38d3329 100644 --- a/src/index.ts +++ b/src/index.ts @@ -106,7 +106,11 @@ export type LikeC4StarlightPlugin = StarlightPlugin & Pick[0]; + /** + * Named LikeC4 projects, compiled alongside those discovered under the site + * root: `{ architecture: '../../docs/architecture/likec4' }`. Each folder + * holds a `likec4.config.json` and may be relative to the site root or + * absolute; the key is the name `` uses. + */ + projects?: Record; /** Render ```likec4 fences in .mdx pages as diagrams. Defaults to `true`. */ fences?: boolean; } @@ -106,20 +116,20 @@ function resolvesFrom(specifier: string, root: string): boolean { */ export function likec4Integration({ vitePlugin = {}, + projects, fences = true, }: LikeC4IntegrationOptions = {}): AstroIntegration { return { name: `${PACKAGE_NAME}/integration`, hooks: { 'astro:config:setup': async ({ command, config, updateConfig, logger }) => { - const optimizeDeps = missingOptimizedDependencies( - config.vite?.optimizeDeps?.include, - fileURLToPath(config.root), - ); + const root = fileURLToPath(config.root); + const optimizeDeps = missingOptimizedDependencies(config.vite?.optimizeDeps?.include, root); + const { pluginOptions, resolved } = await workspaceFor(root, vitePlugin, projects, logger); updateConfig({ vite: { - plugins: [LikeC4VitePlugin(vitePlugin)], + plugins: [LikeC4VitePlugin(pluginOptions), projectsPlugin(resolved)], // The components ship as .astro/.tsx source, so Vite has to // transform them rather than hand them to Node as-is. ssr: { noExternal: [PACKAGE_NAME] }, @@ -145,6 +155,59 @@ export function likec4Integration({ }; } +/** + * The options `LikeC4VitePlugin` gets, and the projects the site can name. + * + * With `projects`, configured and discovered projects are linked into one + * staging workspace, because the plugin compiles a single folder. Without it, + * LikeC4 discovers under the site root exactly as before, and the discovered + * projects are only listed so components can name them. An explicit + * `vitePlugin.workspace` is the site taking over discovery itself, so it wins. + */ +async function workspaceFor( + root: string, + vitePlugin: NonNullable, + configured: Record | undefined, + logger: AstroIntegrationLogger, +): Promise<{ pluginOptions: NonNullable; resolved: ResolvedProject[] }> { + const hasConfigured = configured !== undefined && Object.keys(configured).length > 0; + + // A site handing over its own language services, or its own workspace, has + // taken discovery over. + if (vitePlugin.workspace !== undefined || 'languageServices' in vitePlugin) { + if (hasConfigured) { + logger.warn( + '`projects` is ignored because `vitePlugin.workspace` or `vitePlugin.languageServices` is set; remove one of them.', + ); + } + + return { pluginOptions: vitePlugin, resolved: [] }; + } + + const discovered = await discoverProjects(root); + + if (!hasConfigured) { + return { pluginOptions: vitePlugin, resolved: discovered }; + } + + const resolved = await resolveProjects({ root, configured, discovered }); + const workspace = await prepareWorkspace(resolved, join(root, '.astro', 'astroc4')); + + // Narrowed above: this branch never carries `languageServices`. + return { pluginOptions: { ...(vitePlugin as { workspace?: string }), workspace }, resolved }; +} + +/** Serves `virtual:astroc4/projects`: the names components may pass as `project`. */ +function projectsPlugin(projects: ResolvedProject[]): VitePlugin { + const resolvedId = `\0${PROJECTS_MODULE_ID}`; + + return { + name: `${PACKAGE_NAME}/projects`, + resolveId: (id) => (id === PROJECTS_MODULE_ID ? resolvedId : undefined), + load: (id) => (id === resolvedId ? projectsModule(projects) : undefined), + }; +} + /** * Add `@astrojs/react` when the site has not. * diff --git a/src/projects.ts b/src/projects.ts index f244107..d5f5804 100644 --- a/src/projects.ts +++ b/src/projects.ts @@ -103,6 +103,23 @@ export async function resolveProjects(options: { byName.set(project.name, project); } + // LikeC4 registers a project by the `name` in its own config, so two + // projects from different places that share it would collide inside the + // compiler however differently they are named here. + const byId = new Map(); + + for (const project of projects) { + const existing = byId.get(project.likec4Id); + + if (existing) { + throw new Astroc4ConfigError( + `two LikeC4 projects declare "name": "${project.likec4Id}" in their configs: ${existing.folder} and ${project.folder}. Rename one.`, + ); + } + + byId.set(project.likec4Id, project); + } + return projects; } diff --git a/src/workspace.ts b/src/workspace.ts new file mode 100644 index 0000000..c48299a --- /dev/null +++ b/src/workspace.ts @@ -0,0 +1,83 @@ +// workspace.ts +// path: src/workspace.ts +// One folder LikeC4 can compile, holding every project the site renders. +// +// `LikeC4VitePlugin` takes a single `workspace`. A site's projects live in +// several places (under its root, in a sibling folder, in another checkout), so +// they are gathered as directory links under one staging folder and the plugin +// is pointed at that. Links, not copies: the sources stay where their authors +// edit them, and LikeC4 sees every change. +// +// The staging folder lives in the site's `.astro/` cache, never under +// `node_modules/`: LikeC4 excludes `**/node_modules/**` and would register +// nothing there. + +import { lstat, mkdir, readdir, readlink, realpath, rm, symlink } from 'node:fs/promises'; +import { join, resolve } from 'node:path'; + +import type { ResolvedProject } from './projects.js'; + +/** The virtual module components read project names from. */ +export const PROJECTS_MODULE_ID = 'virtual:astroc4/projects'; + +/** + * Link every project into `/workspace` under its resolved name, and + * remove links for projects that are no longer configured. + * + * @returns The staging folder to hand `LikeC4VitePlugin` as its `workspace`. + */ +export async function prepareWorkspace(projects: ResolvedProject[], cacheDir: string): Promise { + const staging = join(cacheDir, 'workspace'); + await mkdir(staging, { recursive: true }); + + const wanted = new Map(projects.map((project) => [project.name, resolve(project.folder)])); + + for (const entry of await readdir(staging)) { + const target = wanted.get(entry); + + if (target === undefined || !(await pointsAt(join(staging, entry), target))) { + // `rm` on a link removes the link, never what it points at. + await rm(join(staging, entry), { recursive: false, force: true }); + } + } + + for (const [name, folder] of wanted) { + const link = join(staging, name); + + if (await exists(link)) continue; + + // A junction needs no elevated rights on Windows; elsewhere the type + // argument is ignored and a plain directory symlink is made. + await symlink(folder, link, 'junction'); + } + + return staging; +} + +/** The source of `virtual:astroc4/projects` for a set of resolved projects. */ +export function projectsModule(projects: ResolvedProject[]): string { + const names = projects.map((project) => project.name); + const ids = Object.fromEntries(projects.map((project) => [project.name, project.likec4Id])); + + return [ + `export const projectNames = ${JSON.stringify(names)};`, + `export const likec4IdFor = ${JSON.stringify(ids)};`, + ].join('\n'); +} + +async function pointsAt(link: string, target: string): Promise { + try { + const stats = await lstat(link); + + if (!stats.isSymbolicLink()) return false; + + await readlink(link); + return (await realpath(link)) === (await realpath(target)); + } catch { + return false; + } +} + +async function exists(path: string): Promise { + return (await lstat(path).catch(() => null)) !== null; +} From 04d4338b166fb42814811541871a10e6d0eccf78 Mon Sep 17 00:00:00 2001 From: Kieron Lanning Date: Mon, 9 Nov 2026 13:52:44 +0000 Subject: [PATCH 04/15] feat: map local LikeC4 icons to served URLs --- src/__tests__/icon-assets.test.ts | 161 +++++++++++++++++++++++++++++ src/icon-assets.ts | 166 ++++++++++++++++++++++++++++++ 2 files changed, 327 insertions(+) create mode 100644 src/__tests__/icon-assets.test.ts create mode 100644 src/icon-assets.ts diff --git a/src/__tests__/icon-assets.test.ts b/src/__tests__/icon-assets.test.ts new file mode 100644 index 0000000..6270aed --- /dev/null +++ b/src/__tests__/icon-assets.test.ts @@ -0,0 +1,161 @@ +import { mkdir, mkdtemp, symlink, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; +import { pathToFileURL } from 'node:url'; + +import { describe, expect, it } from 'vitest'; + +import { buildIconManifest, ICON_ROUTE, iconAssetFor } from '../icon-assets'; +import { Astroc4ConfigError, type ResolvedProject } from '../projects'; + +const SVG = ''; + +async function file(path: string): Promise { + await mkdir(dirname(path), { recursive: true }); + await writeFile(path, SVG); + return path; +} + +/** A project folder `architecture` with an `@` alias to `images/` and `@brand` to `../brand`. */ +async function fixture(folderName = 'architecture') { + const root = await mkdtemp(join(tmpdir(), 'astroc4-icons-')); + const site = join(root, 'site'); + const folder = join(root, 'repo', folderName); + await mkdir(join(site, 'public'), { recursive: true }); + await mkdir(folder, { recursive: true }); + + const project: ResolvedProject = { + name: 'architecture', + folder, + likec4Id: 'minder-architecture', + source: 'configured', + imageAliases: { '@': join(folder, 'images'), '@brand': join(root, 'repo', 'brand') }, + }; + + return { root, site, folder, project }; +} + +const url = (path: string) => pathToFileURL(path).href; + +describe('iconAssetFor', () => { + it('maps an @ alias icon to /_astroc4//', async () => { + const { site, folder, project } = await fixture(); + const icon = await file(join(folder, 'images', 'azure', 'entra', 'x.svg')); + + expect(iconAssetFor(project, url(icon), '/', site)).toMatchObject({ + url: '/_astroc4/architecture/azure/entra/x.svg', + projectName: 'architecture', + }); + }); + + it('maps a named alias with its name as the first segment', async () => { + const { root, site, project } = await fixture(); + const icon = await file(join(root, 'repo', 'brand', 'logo.svg')); + + expect(iconAssetFor(project, url(icon), '/', site)?.url).toBe('/_astroc4/architecture/brand/logo.svg'); + }); + + it('maps a relative-path icon to its path under the project folder, with .. made safe', async () => { + const { root, site, folder, project } = await fixture(); + const inside = await file(join(folder, 'icons', 'a.svg')); + const outside = await file(join(root, 'repo', 'shared', 'b.svg')); + + expect(iconAssetFor(project, url(inside), '/', site)?.url).toBe('/_astroc4/architecture/icons/a.svg'); + expect(iconAssetFor(project, url(outside), '/', site)?.url).toBe('/_astroc4/architecture/__up/shared/b.svg'); + }); + + it('prefixes Astro base', async () => { + const { site, folder, project } = await fixture(); + const icon = await file(join(folder, 'images', 'x.svg')); + + expect(iconAssetFor(project, url(icon), '/docs/', site)?.url).toBe('/docs/_astroc4/architecture/x.svg'); + expect(iconAssetFor(project, url(icon), '/docs', site)?.url).toBe('/docs/_astroc4/architecture/x.svg'); + }); + + it('treats an upper-case, lower-case or %3A-encoded drive and a folder with a space the same', async () => { + const { site, project } = await fixture('arch itecture'); + const icon = await file(join(project.folder, 'images', 'with space', 'x.svg')); + const href = url(icon); + const variants = [href, href.replace(/^file:\/\/\/([A-Za-z]):/, (_, d: string) => `file:///${d.toLowerCase()}%3A`)]; + + for (const variant of variants) { + expect(iconAssetFor(project, variant, '/', site)?.url).toBe('/_astroc4/architecture/with%20space/x.svg'); + } + }); + + it('maps a URL through the staging junction like the original file', async () => { + const { root, site, folder, project } = await fixture(); + const icon = await file(join(folder, 'images', 'x.svg')); + const link = join(root, 'site', '.astro', 'astroc4', 'workspace', 'architecture'); + await mkdir(dirname(link), { recursive: true }); + await symlink(folder, link, 'junction'); + + expect(iconAssetFor(project, url(join(link, 'images', 'x.svg')), '/', site)?.url).toBe( + iconAssetFor(project, url(icon), '/', site)?.url, + ); + }); + + it('leaves a public/ icon to classifyIcon', async () => { + const { site, project } = await fixture(); + const icon = await file(join(site, 'public', 'icons', 'p.svg')); + + expect(iconAssetFor(project, url(icon), '/', site)).toBeNull(); + }); + + it('fails for a missing file, naming the icon', async () => { + const { site, folder, project } = await fixture(); + const missing = url(join(folder, 'images', 'gone.svg')); + + expect(() => iconAssetFor(project, missing, '/', site)).toThrow(Astroc4ConfigError); + expect(() => iconAssetFor(project, missing, '/', site)).toThrow(/gone\.svg/); + }); +}); + +describe('buildIconManifest', () => { + it('keys every asset by its compiled URL', async () => { + const { site, folder, project } = await fixture(); + const icon = await file(join(folder, 'images', 'x.svg')); + + const manifest = buildIconManifest([{ project, fileUrl: url(icon), source: 'model.c4' }], '/', site); + + expect(manifest.get(url(icon))?.url).toBe('/_astroc4/architecture/x.svg'); + expect(ICON_ROUTE).toBe('/_astroc4/'); + }); + + it('names the .c4 file when an icon is missing', async () => { + const { site, folder, project } = await fixture(); + + expect(() => + buildIconManifest( + [{ project, fileUrl: url(join(folder, 'images', 'gone.svg')), source: 'views/c1.c4' }], + '/', + site, + ), + ).toThrow(/views\/c1\.c4[\s\S]*gone\.svg|gone\.svg[\s\S]*views\/c1\.c4/); + }); + + it('fails when two files map to the same served path, naming both', async () => { + const { root, site, folder, project } = await fixture(); + // `@` + brand/logo.svg and `@brand` + logo.svg both serve /_astroc4/architecture/brand/logo.svg. + const viaRoot = await file(join(folder, 'images', 'brand', 'logo.svg')); + const viaBrand = await file(join(root, 'repo', 'brand', 'logo.svg')); + + expect(() => + buildIconManifest( + [ + { project, fileUrl: url(viaRoot), source: 'a.c4' }, + { project, fileUrl: url(viaBrand), source: 'b.c4' }, + ], + '/', + site, + ), + ).toThrow(/brand\/logo\.svg[\s\S]*images[\s\S]*repo[\\/]brand|repo[\\/]brand[\s\S]*images/); + }); + + it('skips public/ icons', async () => { + const { site, project } = await fixture(); + const icon = await file(join(site, 'public', 'p.svg')); + + expect(buildIconManifest([{ project, fileUrl: url(icon), source: 'a.c4' }], '/', site).size).toBe(0); + }); +}); diff --git a/src/icon-assets.ts b/src/icon-assets.ts new file mode 100644 index 0000000..e93ff7d --- /dev/null +++ b/src/icon-assets.ts @@ -0,0 +1,166 @@ +// icon-assets.ts +// path: src/icon-assets.ts +// Where a LikeC4 icon that lives on disk is served from. +// +// LikeC4 resolves `icon @/azure/x.svg` (through the project's `imageAliases`) +// and `icon ./icons/x.svg` to absolute `file://` URLs in the compiled model. +// Shipped as they are, those 404 for every visitor and leak the build +// machine's folders. Each is mapped here to a URL under `/_astroc4/` that the +// dev server serves and the build copies, so an author keeps writing +// `icon @/…` and the site just works. +// +// @ -> /images icon @/azure/entra/x.svg -> /_astroc4//azure/entra/x.svg +// @brand -> ../brand icon @brand/logo.svg -> /_astroc4//brand/logo.svg +// (no alias) icon ../shared/a.svg -> /_astroc4//__up/shared/a.svg +// +// Files already under the site's `public/` are Astro's to serve, and +// `classifyIcon` keeps handling them. + +import { realpathSync } from 'node:fs'; +import { isAbsolute, join, relative, sep } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { Astroc4ConfigError, type ResolvedProject } from './projects.js'; + +/** The URL prefix every gathered icon is served under (after Astro's `base`). */ +export const ICON_ROUTE = '/_astroc4/'; + +/** The segment a `..` in a relative icon path becomes, so URLs never climb. */ +const UP = '__up'; + +export interface IconAsset { + /** The file on disk, with links resolved. */ + file: string; + /** The served path, including Astro's `base`. */ + url: string; + projectName: string; +} + +/** + * The served asset for one compiled icon URL, or `null` for a file the site's + * `public/` folder already serves. + */ +export function iconAssetFor( + project: ResolvedProject, + fileUrl: string, + base: string, + siteRoot: string, +): IconAsset | null { + const file = realFile(fileUrl); + + if (isInside(file, tryRealpath(join(siteRoot, 'public')))) { + return null; + } + + const segments = aliasSegments(project, file) ?? relativeSegments(tryRealpath(project.folder), file); + const path = [project.name, ...segments].map(encodeURIComponent).join('/'); + + return { file, url: `${base.replace(/\/+$/, '')}${ICON_ROUTE}${path}`, projectName: project.name }; +} + +/** + * Every compiled icon URL that needs serving, keyed by that URL. + * + * @throws Astroc4ConfigError for a missing icon (naming the `.c4` file) or for + * two different files that would be served at the same URL. + */ +export function buildIconManifest( + entries: Array<{ project: ResolvedProject; fileUrl: string; source: string }>, + base: string, + siteRoot: string, +): Map { + const manifest = new Map(); + const byUrl = new Map(); + + for (const { project, fileUrl, source } of entries) { + let asset: IconAsset | null; + + try { + asset = iconAssetFor(project, fileUrl, base, siteRoot); + } catch (error) { + throw new Astroc4ConfigError(`${source}: ${(error as Error).message.replace(/^\[astroc4\] /, '')}`); + } + + if (asset === null) continue; + + const existing = byUrl.get(asset.url); + + if (existing && !samePath(existing.asset.file, asset.file)) { + throw new Astroc4ConfigError( + `two icons would be served at ${asset.url}: ${existing.asset.file} (${existing.source}) and ${asset.file} (${source}). Rename one, or give its alias another name.`, + ); + } + + byUrl.set(asset.url, { asset, source }); + manifest.set(fileUrl, asset); + } + + return manifest; +} + +/** The longest alias folder containing `file`: `[alias name?, …path under it]`. */ +function aliasSegments(project: ResolvedProject, file: string): string[] | null { + let best: { folder: string; alias: string } | null = null; + + for (const [alias, folder] of Object.entries(project.imageAliases)) { + const real = tryRealpath(folder); + + if (isInside(file, real) && (best === null || real.length > best.folder.length)) { + best = { folder: real, alias }; + } + } + + if (best === null) return null; + + const name = best.alias.replace(/^@/, ''); + + return [...(name ? [name] : []), ...split(relative(best.folder, file))]; +} + +function relativeSegments(folder: string, file: string): string[] { + return split(relative(folder, file)).map((segment) => (segment === '..' ? UP : segment)); +} + +function realFile(fileUrl: string): string { + let path: string; + + try { + // `file:///p%3A/…` is how LikeC4 writes a Windows drive; decoding first + // lets `fileURLToPath` read it like `file:///P:/…`. + path = fileURLToPath(decodeDrive(fileUrl)); + } catch { + throw new Astroc4ConfigError(`icon ${fileUrl} is not a file URL.`); + } + + try { + return realpathSync(path); + } catch { + throw new Astroc4ConfigError(`icon ${path} does not exist.`); + } +} + +function decodeDrive(fileUrl: string): string { + return fileUrl.replace(/^file:\/\/\/([A-Za-z])%3A/i, 'file:///$1:'); +} + +function tryRealpath(path: string): string { + try { + return realpathSync(path); + } catch { + return path; + } +} + +function isInside(file: string, folder: string): boolean { + const path = relative(folder, file); + + return path.length > 0 && !path.startsWith('..') && !isAbsolute(path); +} + +function samePath(a: string, b: string): boolean { + return sep === '\\' ? a.toLowerCase() === b.toLowerCase() : a === b; +} + +function split(path: string): string[] { + return path.split(/[\\/]+/).filter((segment) => segment.length > 0); +} From d7f58cb82b87cf6e77edc69bc62f696512c7552b Mon Sep 17 00:00:00 2001 From: Kieron Lanning Date: Mon, 9 Nov 2026 13:55:38 +0000 Subject: [PATCH 05/15] feat: serve and emit LikeC4 icons transparently --- src/__tests__/icon-plugin.test.ts | 120 ++++++++++++++++++ src/__tests__/integration-projects.test.ts | 21 ++++ src/__tests__/integration.test.ts | 6 +- src/components/icon-source.ts | 5 + src/icon-plugin.ts | 134 +++++++++++++++++++++ src/integration.ts | 12 +- 6 files changed, 295 insertions(+), 3 deletions(-) create mode 100644 src/__tests__/icon-plugin.test.ts create mode 100644 src/icon-plugin.ts diff --git a/src/__tests__/icon-plugin.test.ts b/src/__tests__/icon-plugin.test.ts new file mode 100644 index 0000000..b3ac71b --- /dev/null +++ b/src/__tests__/icon-plugin.test.ts @@ -0,0 +1,120 @@ +import { mkdir, mkdtemp, readFile, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; +import { pathToFileURL } from 'node:url'; + +import { describe, expect, it, vi } from 'vitest'; + +import { astroc4IconPlugin } from '../icon-plugin'; +import type { ResolvedProject } from '../projects'; + +const SVG = ''; + +async function fixture() { + const root = await mkdtemp(join(tmpdir(), 'astroc4-plugin-')); + const site = join(root, 'site'); + const folder = join(root, 'repo', 'architecture'); + const icon = join(folder, 'images', 'azure', 'x.svg'); + const other = join(folder, 'images', 'tech', 'y.svg'); + + for (const path of [icon, other]) { + await mkdir(dirname(path), { recursive: true }); + await writeFile(path, SVG); + } + + await mkdir(join(site, 'public'), { recursive: true }); + + const project: ResolvedProject = { + name: 'architecture', + folder, + likec4Id: 'minder-architecture', + source: 'configured', + imageAliases: { '@': join(folder, 'images') }, + }; + + const plugin = astroc4IconPlugin({ projects: [project], base: '/', siteRoot: site }); + + return { root, site, icon, other, plugin }; +} + +/** How LikeC4 writes a string into its generated module: every `/` as `/`. */ +const escaped = (value: string) => value.replaceAll('/', '\\u002F'); + +const transform = (plugin: any, code: string, id: string) => plugin.transform(code, id)?.code ?? null; + +describe('astroc4IconPlugin', () => { + it('rewrites every file:// icon in a likec4 virtual module to its served URL', async () => { + const { icon, other, plugin } = await fixture(); + const code = `export const model = {"a":{"icon":"${escaped(pathToFileURL(icon).href)}"},"b":{"icon":"${pathToFileURL(other).href}"}};`; + + const output = transform(plugin, code, 'likec4:plugin/minder-architecture/model.js'); + + expect(output).toContain(escaped('/_astroc4/architecture/azure/x.svg')); + expect(output).toContain('/_astroc4/architecture/tech/y.svg'); + expect(output).not.toContain('file:'); + }); + + it('leaves non-likec4 modules untouched', async () => { + const { icon, plugin } = await fixture(); + + expect(transform(plugin, `const a = "${pathToFileURL(icon).href}";`, '/src/page.ts')).toBeNull(); + }); + + it('serves a manifest file from the dev middleware with its content type', async () => { + const { icon, plugin } = await fixture(); + transform(plugin, `"${pathToFileURL(icon).href}"`, 'likec4:plugin/minder-architecture/model.js'); + const middleware = devMiddleware(plugin); + const res = response(); + + await middleware({ url: '/_astroc4/architecture/azure/x.svg' }, res, vi.fn()); + + expect(res.setHeader).toHaveBeenCalledWith('Content-Type', 'image/svg+xml'); + expect(res.end).toHaveBeenCalledWith(Buffer.from(SVG)); + }); + + it('answers 404 for a path outside the manifest, including ../ traversal', async () => { + const { icon, plugin } = await fixture(); + transform(plugin, `"${pathToFileURL(icon).href}"`, 'likec4:plugin/minder-architecture/model.js'); + const middleware = devMiddleware(plugin); + + for (const url of [ + '/_astroc4/architecture/azure/missing.svg', + '/_astroc4/architecture/../../repo/architecture/images/azure/x.svg', + '/_astroc4/architecture/%2e%2e/%2e%2e/secret', + ]) { + const res = response(); + await middleware({ url }, res, vi.fn()); + expect(res.statusCode).toBe(404); + expect(res.end).toHaveBeenCalledWith(); + } + }); + + it('passes other requests on', async () => { + const { plugin } = await fixture(); + const next = vi.fn(); + + await devMiddleware(plugin)({ url: '/index.html' }, response(), next); + + expect(next).toHaveBeenCalled(); + }); + + it('copies every manifest file into the build output at its URL', async () => { + const { root, icon, plugin } = await fixture(); + transform(plugin, `"${pathToFileURL(icon).href}"`, 'likec4:plugin/minder-architecture/model.js'); + const out = join(root, 'dist'); + + await plugin.emit(out); + + expect(await readFile(join(out, '_astroc4', 'architecture', 'azure', 'x.svg'), 'utf8')).toBe(SVG); + }); +}); + +function devMiddleware(plugin: any) { + let handler: any; + plugin.configureServer({ middlewares: { use: (fn: any) => (handler = fn) } }); + return handler as (req: { url: string }, res: any, next: () => void) => Promise; +} + +function response() { + return { statusCode: 200, setHeader: vi.fn(), end: vi.fn() }; +} diff --git a/src/__tests__/integration-projects.test.ts b/src/__tests__/integration-projects.test.ts index 2477346..7341472 100644 --- a/src/__tests__/integration-projects.test.ts +++ b/src/__tests__/integration-projects.test.ts @@ -63,6 +63,27 @@ describe('likec4Integration with projects', () => { expect(source).toContain('"architecture":"external-model"'); }); + it('installs the icon plugin and copies gathered icons when the build is done', async () => { + const temp = await mkdtemp(join(tmpdir(), 'astroc4-site-')); + const site = join(temp, 'site'); + await mkdir(site); + const external = await folderWithConfig(join(temp, 'external'), 'external-model'); + const integration = likec4Integration({ projects: { architecture: external }, fences: false }); + const updateConfig = vi.fn(); + const logger = { info: vi.fn(), warn: vi.fn(), fork: vi.fn() }; + + await integration.hooks['astro:config:setup']!({ + command: 'build', + config: { root: pathToFileURL(join(site, '/')), base: '/', vite: {}, integrations: [] }, + updateConfig, + logger, + } as never); + + const plugins = (updateConfig.mock.calls[0]?.[0] as { vite: { plugins: any[] } }).vite.plugins; + expect(plugins.map((plugin) => plugin.name)).toContain('@purview-dev/astroc4/icons'); + expect(typeof integration.hooks['astro:build:done']).toBe('function'); + }); + it('keeps an explicit vitePlugin.workspace and says projects is ignored', async () => { const site = await mkdtemp(join(tmpdir(), 'astroc4-site-')); const external = await folderWithConfig(join(site, '..', `ext-${Date.now()}`), 'ext'); diff --git a/src/__tests__/integration.test.ts b/src/__tests__/integration.test.ts index 3e5264d..8c851c5 100644 --- a/src/__tests__/integration.test.ts +++ b/src/__tests__/integration.test.ts @@ -64,9 +64,11 @@ describe('likec4Integration', () => { const vite = viteOf(updateConfig); - // LikeC4's plugin, then the one serving `virtual:astroc4/projects`. - expect(vite.plugins).toHaveLength(2); + // LikeC4's plugin, the one serving `virtual:astroc4/projects`, and the + // one rewriting and serving local icons. + expect(vite.plugins).toHaveLength(3); expect(vite.plugins[1].name).toBe(`${PACKAGE_NAME}/projects`); + expect(vite.plugins[2].name).toBe(`${PACKAGE_NAME}/icons`); expect(vite.ssr).toEqual({ noExternal: [PACKAGE_NAME] }); expect(vite.optimizeDeps).toEqual({ include: ['react/compiler-runtime'] }); }); diff --git a/src/components/icon-source.ts b/src/components/icon-source.ts index 1763d1a..f211eb6 100644 --- a/src/components/icon-source.ts +++ b/src/components/icon-source.ts @@ -10,6 +10,11 @@ // Files under `public/` are the exception worth supporting: Astro serves them // verbatim at the site root, so the absolute path can be rewritten back to the // URL it will actually be served from. +// +// Every other local icon — through an `imageAliases` alias or a relative path — +// is rewritten at build time by `src/icon-plugin.ts` to its `/_astroc4/…` URL, +// so it arrives here as `url` already. A `file://` value still reaching this +// function is one the build could not attribute to a project. /** Everything after a `/public/` segment, which Astro serves at the site root. */ const PUBLIC_SEGMENT = /\/public\/(.+)$/; diff --git a/src/icon-plugin.ts b/src/icon-plugin.ts new file mode 100644 index 0000000..55e50f2 --- /dev/null +++ b/src/icon-plugin.ts @@ -0,0 +1,134 @@ +// icon-plugin.ts +// path: src/icon-plugin.ts +// Makes image aliases just work: every icon LikeC4 compiled to a `file://` URL +// is rewritten to its `/_astroc4/…` URL before the model reaches the browser, +// served from its original file by the dev server, and copied into the build +// output. The author writes `icon @/azure/x.svg` and changes nothing else. +// +// LikeC4 serializes its generated modules with every `/` escaped as `/` +// (so they can be inlined into HTML), which is why URLs are matched in both +// forms and written back in the form they were found in. + +import { readFile, copyFile, mkdir } from 'node:fs/promises'; +import { dirname, extname, join } from 'node:path'; +import { pathToFileURL } from 'node:url'; + +import type { Plugin as VitePlugin } from 'vite'; + +import { buildIconManifest, ICON_ROUTE, type IconAsset } from './icon-assets.js'; +import type { ResolvedProject } from './projects.js'; + +/** The ids LikeC4's Vite plugin resolves its per-project modules to. */ +const LIKEC4_MODULE = /likec4:plugin\/(?[^/]+)\/[^/]+$/; + +/** A `file:///…` URL, plain or with LikeC4's escaped slashes. */ +const FILE_URL = /file:(?:\\u002F|\/){3}[^"'`\s]+/g; + +const CONTENT_TYPES: Record = { + '.svg': 'image/svg+xml', + '.png': 'image/png', + '.jpg': 'image/jpeg', + '.jpeg': 'image/jpeg', + '.gif': 'image/gif', + '.webp': 'image/webp', + '.ico': 'image/x-icon', +}; + +export interface IconPluginState { + projects: ResolvedProject[]; + /** Astro's `base`. */ + base: string; + /** The site root; files under its `public/` are left to Astro. */ + siteRoot: string; +} + +export type Astroc4IconPlugin = VitePlugin & { + /** Every icon gathered so far, keyed by its compiled `file://` URL. */ + manifest(): Map; + /** Copy every gathered icon into a build output folder at its URL. */ + emit(outDir: string): Promise; +}; + +export function astroc4IconPlugin(state: IconPluginState): Astroc4IconPlugin { + const manifest = new Map(); + const byUrl = new Map(); + const base = state.base.replace(/\/+$/, ''); + + function gather(project: ResolvedProject, fileUrl: string): IconAsset | undefined { + const known = manifest.get(fileUrl); + if (known) return known; + + const entries = [ + // Re-validating against what is already served is how a second file at + // the same URL is caught, whichever module compiled first. + ...[...byUrl.values()].map((asset) => ({ + project: state.projects.find((p) => p.name === asset.projectName)!, + fileUrl: pathToFileURL(asset.file).href, + source: `LikeC4 project "${asset.projectName}"`, + })), + { project, fileUrl, source: `LikeC4 project "${project.name}"` }, + ]; + + const asset = buildIconManifest(entries, state.base, state.siteRoot).get(fileUrl); + if (!asset) return undefined; + + manifest.set(fileUrl, asset); + byUrl.set(asset.url, asset); + + return asset; + } + + return { + name: '@purview-dev/astroc4/icons', + enforce: 'post', + transform(code, id) { + const match = LIKEC4_MODULE.exec(id); + if (!match) return null; + + const project = state.projects.find((p) => p.likec4Id === match.groups!.projectId); + if (!project || !code.includes('file:')) return null; + + const rewritten = code.replace(FILE_URL, (found) => { + const escapedForm = found.includes('\\u002F'); + const asset = gather(project, found.replaceAll('\\u002F', '/')); + + if (!asset) return found; + + return escapedForm ? asset.url.replaceAll('/', '\\u002F') : asset.url; + }); + + return rewritten === code ? null : { code: rewritten, map: null }; + }, + configureServer(server) { + const prefix = `${base}${ICON_ROUTE}`; + + server.middlewares.use(async (req, res, next) => { + const url = req.url?.split('?')[0]; + + if (!url?.startsWith(prefix)) return next(); + + const asset = byUrl.get(url); + + // Only what the model asked for is ever served: no path from the + // request reaches the file system. + if (!asset) { + res.statusCode = 404; + res.end(); + return; + } + + res.setHeader('Content-Type', CONTENT_TYPES[extname(asset.file).toLowerCase()] ?? 'application/octet-stream'); + res.end(await readFile(asset.file)); + }); + }, + manifest: () => manifest, + async emit(outDir) { + for (const asset of byUrl.values()) { + const target = join(outDir, ...decodeURIComponent(asset.url.slice(base.length)).split('/')); + + await mkdir(dirname(target), { recursive: true }); + await copyFile(asset.file, target); + } + }, + }; +} diff --git a/src/integration.ts b/src/integration.ts index 20a4a84..acd3e4f 100644 --- a/src/integration.ts +++ b/src/integration.ts @@ -31,6 +31,7 @@ import { LikeC4VitePlugin } from 'likec4/vite-plugin'; import type { Plugin as VitePlugin } from 'vite'; import { likec4Fences, preloadLikeC4 } from './fences.js'; +import { astroc4IconPlugin, type Astroc4IconPlugin } from './icon-plugin.js'; import { discoverProjects, resolveProjects, type ResolvedProject } from './projects.js'; import { prepareWorkspace, PROJECTS_MODULE_ID, projectsModule } from './workspace.js'; @@ -119,6 +120,9 @@ export function likec4Integration({ projects, fences = true, }: LikeC4IntegrationOptions = {}): AstroIntegration { + // Created in `astro:config:setup`, read again when the build is done. + let icons: Astroc4IconPlugin | undefined; + return { name: `${PACKAGE_NAME}/integration`, hooks: { @@ -127,9 +131,11 @@ export function likec4Integration({ const optimizeDeps = missingOptimizedDependencies(config.vite?.optimizeDeps?.include, root); const { pluginOptions, resolved } = await workspaceFor(root, vitePlugin, projects, logger); + icons = astroc4IconPlugin({ projects: resolved, base: config.base ?? '/', siteRoot: root }); + updateConfig({ vite: { - plugins: [LikeC4VitePlugin(pluginOptions), projectsPlugin(resolved)], + plugins: [LikeC4VitePlugin(pluginOptions), projectsPlugin(resolved), icons], // The components ship as .astro/.tsx source, so Vite has to // transform them rather than hand them to Node as-is. ssr: { noExternal: [PACKAGE_NAME] }, @@ -151,6 +157,10 @@ export function likec4Integration({ await preloadLikeC4(); } }, + // Every icon the compiled models referenced, at the URL they now name. + 'astro:build:done': async ({ dir }) => { + await icons?.emit(fileURLToPath(dir)); + }, }, }; } From 60665624816c3da3de163b2963307e64a04ac6f0 Mon Sep 17 00:00:00 2001 From: Kieron Lanning Date: Mon, 9 Nov 2026 14:00:29 +0000 Subject: [PATCH 06/15] feat: project and file props with an explicit default --- src/__tests__/fences.test.ts | 51 +++++++++++++++ src/__tests__/file-layout.test.ts | 38 +++++++++++ src/__tests__/integration.test.ts | 7 +- src/components/LikeC4View.astro | 87 +++++++++++++++++++++---- src/components/__tests__/labels.test.ts | 11 ++++ src/components/__tests__/utils.test.ts | 28 +++++++- src/components/labels.ts | 3 + src/components/utils.ts | 43 +++++++++++- src/fences.ts | 49 +++++++++++++- src/file-layout.ts | 41 ++++++++++++ src/integration.ts | 5 +- src/translations.ts | 1 + src/types.d.ts | 6 ++ 13 files changed, 347 insertions(+), 23 deletions(-) create mode 100644 src/__tests__/file-layout.test.ts create mode 100644 src/file-layout.ts diff --git a/src/__tests__/fences.test.ts b/src/__tests__/fences.test.ts index 7af71b1..30bb077 100644 --- a/src/__tests__/fences.test.ts +++ b/src/__tests__/fences.test.ts @@ -1,3 +1,8 @@ +import { mkdir, mkdtemp, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; +import { pathToFileURL } from 'node:url'; + import { describe, expect, it, vi } from 'vitest'; import { likec4Fences, parseFenceMeta, type FenceReporter } from '../fences.js'; @@ -160,6 +165,52 @@ describe('the ```likec4 fence transform', () => { expect(JSON.stringify(inserted[0]?.nodes)).toContain('@purview-dev/astroc4/components/LikeC4View.astro'); }); + it('passes project= through to the project view component', async () => { + const { ctx, plugin, replaced } = pluginFor('```likec4 view="index" project="architecture"\n```'); + + await plugin.code?.(fence('view="index" project="architecture"'), ctx); + + expect(replaced[0]?.next).toMatchObject({ + name: '__LikeC4View', + attributes: expect.arrayContaining([{ type: 'mdxJsxAttribute', name: 'project', value: 'architecture' }]), + }); + }); + + it('renders a file= fence from the file relative to the page', async () => { + const site = await mkdtemp(join(tmpdir(), 'astroc4-fence-')); + const pageFile = join(site, 'src', 'content', 'docs', 'page.mdx'); + await mkdir(join(dirname(pageFile), 'flows'), { recursive: true }); + await writeFile( + join(dirname(pageFile), 'flows', 'request.c4'), + "specification { element box }\nmodel { a = box 'A' }\nviews { view requestPath { include * } }\n", + ); + const replaced: { next: any }[] = []; + const plugin = (likec4Fences({ root: site }) as any)({ + source: '```likec4 file="./flows/request.c4" view="requestPath"\n```', + sourceFormat: 'mdx', + }); + + await plugin.code(fence('file="./flows/request.c4" view="requestPath"'), { + fileURL: pathToFileURL(pageFile), + replaceNode: (_: unknown, next: any) => replaced.push({ next }), + }); + + expect(replaced[0]?.next.name).toBe('__LikeC4Fence'); + expect(replaced[0]?.next.attributes).toEqual( + expect.arrayContaining([{ type: 'mdxJsxAttribute', name: 'viewId', value: 'requestPath' }]), + ); + }); + + it('fails a file= fence that escapes the site root', async () => { + const site = await mkdtemp(join(tmpdir(), 'astroc4-fence-')); + const pageFile = join(site, 'src', 'content', 'docs', 'page.mdx'); + const plugin = (likec4Fences({ root: site }) as any)({ source: '```likec4 file="../../../../x.c4"\n```', sourceFormat: 'mdx' }); + + await expect( + plugin.code(fence('file="../../../../x.c4"'), { fileURL: pathToFileURL(pageFile), replaceNode: () => {} }), + ).rejects.toThrow(/outside the site/); + }); + it('inserts nothing when no fence was rewritten', async () => { const { ctx, plugin, inserted } = pluginFor('```likec4\n```'); diff --git a/src/__tests__/file-layout.test.ts b/src/__tests__/file-layout.test.ts new file mode 100644 index 0000000..b09d271 --- /dev/null +++ b/src/__tests__/file-layout.test.ts @@ -0,0 +1,38 @@ +import { mkdtemp, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { describe, expect, it, vi } from 'vitest'; + +import { FILE_LAYOUT_QUERY, fileLayoutPlugin } from '../file-layout'; + +describe('fileLayoutPlugin', () => { + it('compiles a .c4 file imported with ?astroc4-layout into its layouted model', async () => { + const folder = await mkdtemp(join(tmpdir(), 'astroc4-file-')); + const file = join(folder, 'flow.c4'); + await writeFile(file, "specification { element box }\nmodel { a = box 'A' }\nviews { view flow { include * } }\n"); + const plugin: any = fileLayoutPlugin(); + const context = { addWatchFile: vi.fn() }; + + const code: string = await plugin.load.call(context, `${file}${FILE_LAYOUT_QUERY}`); + + expect(code.startsWith('export default ')).toBe(true); + // LikeC4 adds its own `index` view next to the declared one. + expect(JSON.parse(code.slice('export default '.length).replace(/;$/, '')).viewIds).toContain('flow'); + expect(context.addWatchFile).toHaveBeenCalledWith(file); + }); + + it('ignores other modules', async () => { + expect(await (fileLayoutPlugin() as any).load.call({}, '/src/flow.c4')).toBeNull(); + }); + + it('fails with the file path when the file is not valid LikeC4', async () => { + const folder = await mkdtemp(join(tmpdir(), 'astroc4-file-')); + const file = join(folder, 'broken.c4'); + await writeFile(file, 'model { a = }'); + + await expect((fileLayoutPlugin() as any).load.call({ addWatchFile: vi.fn() }, `${file}${FILE_LAYOUT_QUERY}`)).rejects.toThrow( + /broken\.c4/, + ); + }); +}); diff --git a/src/__tests__/integration.test.ts b/src/__tests__/integration.test.ts index 8c851c5..7df2cb5 100644 --- a/src/__tests__/integration.test.ts +++ b/src/__tests__/integration.test.ts @@ -64,11 +64,12 @@ describe('likec4Integration', () => { const vite = viteOf(updateConfig); - // LikeC4's plugin, the one serving `virtual:astroc4/projects`, and the - // one rewriting and serving local icons. - expect(vite.plugins).toHaveLength(3); + // LikeC4's plugin, the one serving `virtual:astroc4/projects`, the one + // rewriting and serving local icons, and the one compiling `file=` views. + expect(vite.plugins).toHaveLength(4); expect(vite.plugins[1].name).toBe(`${PACKAGE_NAME}/projects`); expect(vite.plugins[2].name).toBe(`${PACKAGE_NAME}/icons`); + expect(vite.plugins[3].name).toBe(`${PACKAGE_NAME}/file-layout`); expect(vite.ssr).toEqual({ noExternal: [PACKAGE_NAME] }); expect(vite.optimizeDeps).toEqual({ include: ['react/compiler-runtime'] }); }); diff --git a/src/components/LikeC4View.astro b/src/components/LikeC4View.astro index 4b1add5..44e6298 100644 --- a/src/components/LikeC4View.astro +++ b/src/components/LikeC4View.astro @@ -1,22 +1,35 @@ --- // LikeC4View.astro // path: src/components/LikeC4View.astro -// Docs-facing wrapper around the LikeC4 React island. Use this from .mdx pages: +// Docs-facing wrapper around the LikeC4 React islands. Use this from .mdx pages: // import LikeC4View from '@purview-dev/astroc4/components/LikeC4View.astro'; -// -// +// (the only project) +// (a named or discovered project) +// (one .c4 file under src/) +// +// The project is resolved here, on the server, so an unknown name — or no name +// when the site has several projects — fails the build instead of rendering an +// error box in the browser. + +import { likec4IdFor, projectNames } from 'virtual:astroc4/projects'; import DiagramFrame from './DiagramFrame.astro'; import DiagramShell from './DiagramShell.astro'; +import LikeC4InlineView from './LikeC4InlineView'; import LikeC4ProjectView from './LikeC4ProjectView'; import LoadingPlaceholder from './LoadingPlaceholder.astro'; import { getIslandLabels, getTranslator } from './labels'; +import { fileKey, resolveProjectId } from './utils'; interface Props { - /** View id inside the selected LikeC4 project. */ + /** View id inside the selected LikeC4 project or file. */ viewId: string; - /** Optional project id (maps to `likec4:react/`). */ + /** A project named in the plugin's `projects` option, or discovered under the site root. */ + project?: string; + /** @deprecated Use `project`. */ projectId?: string; + /** A single `.c4` file, relative to the site's `src/` folder. */ + file?: string; /** Shell heading. */ title?: string; description?: string; @@ -35,13 +48,55 @@ const labels = getIslandLabels(t, 'likec4.view.loading'); const { viewId, + project, projectId, + file, title = t('likec4.view.title'), description = t('likec4.view.description'), height = 480, collapsible = true, defaultOpen = true, } = Astro.props; + +let inlineModel: unknown; +let likec4Id: string | undefined; + +if (file !== undefined) { + // Every .c4 file under src/, compiled on import by the file-layout plugin. + const layouts = import.meta.glob<{ dump: unknown; viewIds: string[] }>('/src/**/*.c4', { + query: '?astroc4-layout', + import: 'default', + }); + const key = fileKey(file); + const load = key ? layouts[key] : undefined; + + if (!load) { + throw new Error( + `[astroc4] must name a .c4 file under src/` + + (key ? `; ${key} does not exist.` : '.'), + ); + } + + const layout = await load(); + + if (!layout.viewIds.includes(viewId)) { + throw new Error(`[astroc4] ${key} has no view "${viewId}". Declared: ${layout.viewIds.join(', ')}.`); + } + + inlineModel = layout.dump; +} else if (projectNames.length > 0) { + const resolution = resolveProjectId(project ?? projectId, projectNames, labels); + + if (resolution.error) { + throw new Error(`[astroc4] : ${resolution.error}`); + } + + likec4Id = likec4IdFor[resolution.resolvedProjectId!]; +} else { + // The site took discovery over (`vitePlugin.workspace`), so only the island + // knows the projects. + likec4Id = project ?? projectId; +} --- - - { - /* `slot="fallback"` is required: default-slot children of a `client:only` - island are parked in an inert