@purview-dev/astroc4 0.0.0-stage → 1.0.0-prerelease.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE.md +9 -0
- package/README.md +179 -2
- package/package.json +96 -4
- package/src/components/DiagramFrame.astro +72 -0
- package/src/components/DiagramShell.astro +121 -0
- package/src/components/ErrorBoundary.tsx +52 -0
- package/src/components/LikeC4Fence.astro +62 -0
- package/src/components/LikeC4InlineView.tsx +67 -0
- package/src/components/LikeC4ProjectView.tsx +153 -0
- package/src/components/LikeC4View.astro +80 -0
- package/src/components/LoadingPlaceholder.astro +67 -0
- package/src/components/icon-source.ts +68 -0
- package/src/components/labels.ts +62 -0
- package/src/components/render-icon.tsx +41 -0
- package/src/components/utils.ts +69 -0
- package/src/expressive-code.d.mts +28 -0
- package/src/expressive-code.mjs +46 -0
- package/src/fences.mjs +288 -0
- package/src/grammar/langium.mjs +559 -0
- package/src/grammar/like-c4.langium +1357 -0
- package/src/grammar/likec4.tmLanguage.json +327 -0
- package/src/grammar/source.mjs +410 -0
- package/src/grammar/textmate.mjs +439 -0
- package/src/index.d.mts +64 -0
- package/src/index.mjs +228 -0
- package/src/translations.mjs +33 -0
- package/src/types.d.ts +12 -0
package/src/fences.mjs
ADDED
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
// fences.mjs
|
|
2
|
+
// path: src/fences.mjs
|
|
3
|
+
// Turns a ```likec4 code fence into a rendered diagram instead of a code block.
|
|
4
|
+
//
|
|
5
|
+
// Two forms:
|
|
6
|
+
//
|
|
7
|
+
// ```likec4 view="requestPath" title="Request path"
|
|
8
|
+
// ```
|
|
9
|
+
// -> renders a view from the site's LikeC4 project (any likec4.config.json).
|
|
10
|
+
//
|
|
11
|
+
// ```likec4 title="Example"
|
|
12
|
+
// specification { element system }
|
|
13
|
+
// model { a = system 'A' b = system 'B' a -> b }
|
|
14
|
+
// views { view index { include * } }
|
|
15
|
+
// ```
|
|
16
|
+
// -> compiles and lays out this snippet at build time, then renders it.
|
|
17
|
+
//
|
|
18
|
+
// Layout needs LikeC4's WASM graph engine, so it has to happen during the
|
|
19
|
+
// build; the browser only rehydrates the resulting JSON.
|
|
20
|
+
//
|
|
21
|
+
// This is a Sätteri mdast plugin, not a remark one. Astro 7 made Sätteri the
|
|
22
|
+
// default Markdown processor, and `remarkPlugins` is a legacy option that is
|
|
23
|
+
// silently ignored unless @astrojs/markdown-remark is installed. The plugin's
|
|
24
|
+
// integration (src/index.mjs) registers it; sites do not add it themselves.
|
|
25
|
+
|
|
26
|
+
import { createHash } from 'node:crypto';
|
|
27
|
+
import { createRequire } from 'node:module';
|
|
28
|
+
import { fileURLToPath } from 'node:url';
|
|
29
|
+
|
|
30
|
+
const require = createRequire(import.meta.url);
|
|
31
|
+
|
|
32
|
+
// `likec4`'s Node API is deliberately NOT imported at module scope. It pulls in
|
|
33
|
+
// Langium, chevrotain, an LSP and the WASM layout engine, and this module is
|
|
34
|
+
// loaded as part of the site's astro.config.mjs — on a cold start that was
|
|
35
|
+
// enough to make Astro report "Configuration file updated. Restarting...", and the restart
|
|
36
|
+
// killed the content-layer transport, leaving every page a 500. Load it lazily
|
|
37
|
+
// so the cost is paid only when a page actually contains an inline fence.
|
|
38
|
+
let likec4ApiPromise;
|
|
39
|
+
|
|
40
|
+
function loadLikeC4() {
|
|
41
|
+
// A plain `require('likec4')` rather than `import('likec4')`: Vite's SSR
|
|
42
|
+
// module runner intercepts dynamic `import()` from any module it loaded
|
|
43
|
+
// (this one included) to route it through the runner's own module graph —
|
|
44
|
+
// and on Windows that runner can already be mid-teardown by the time a
|
|
45
|
+
// page's ```likec4 fence triggers this, crashing the whole `astro build`
|
|
46
|
+
// (see `preloadLikeC4` below). `require()` goes through Node's own
|
|
47
|
+
// resolver instead, which Node 22+ supports for ESM-only packages like
|
|
48
|
+
// this one, sidestepping the runner entirely.
|
|
49
|
+
likec4ApiPromise ??= Promise.resolve(require('likec4').LikeC4);
|
|
50
|
+
|
|
51
|
+
return likec4ApiPromise;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Kick off (and await) the same lazy load `loadLikeC4()` uses, so a later
|
|
56
|
+
* call inside Satteri's processing reuses the already-resolved promise
|
|
57
|
+
* instead of issuing a fresh `import('likec4')`.
|
|
58
|
+
*
|
|
59
|
+
* `astro build` on Windows crashes mid-build ("Vite module runner has been
|
|
60
|
+
* closed", followed by a native libuv assertion that kills the process) if
|
|
61
|
+
* that first `import('likec4')` happens while Vite is tearing down the SSR
|
|
62
|
+
* module runner used for the content layer — which is exactly when the first
|
|
63
|
+
* page containing a ```likec4 fence is rendered. Finishing the import before
|
|
64
|
+
* that point removes the race. src/index.mjs calls this from
|
|
65
|
+
* `astro:config:setup` for builds only — the module-scope comment above
|
|
66
|
+
* `loadLikeC4` explains why eager-loading it in dev is unsafe.
|
|
67
|
+
*/
|
|
68
|
+
export function preloadLikeC4() {
|
|
69
|
+
return loadLikeC4();
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const FENCE_LANG = 'likec4';
|
|
73
|
+
|
|
74
|
+
/** Component the fence is rewritten into, per form. */
|
|
75
|
+
const INLINE_COMPONENT = '__LikeC4Fence';
|
|
76
|
+
const PROJECT_COMPONENT = '__LikeC4View';
|
|
77
|
+
|
|
78
|
+
// Package specifiers, not relative paths: the import is written into the page
|
|
79
|
+
// being compiled, so it has to resolve from the site, not from this file.
|
|
80
|
+
/** The published package name; see the comment on `PACKAGE_NAME` in index.mjs. */
|
|
81
|
+
const PACKAGE_NAME = '@purview-dev/astroc4';
|
|
82
|
+
|
|
83
|
+
const IMPORTS = {
|
|
84
|
+
[INLINE_COMPONENT]: `${PACKAGE_NAME}/components/LikeC4Fence.astro`,
|
|
85
|
+
[PROJECT_COMPONENT]: `${PACKAGE_NAME}/components/LikeC4View.astro`,
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Compiling a snippet costs ~250ms (a Langium workspace per call) and the same
|
|
90
|
+
* page is re-rendered on every dev edit, so key on the source: a build pays
|
|
91
|
+
* that once per distinct snippet, not once per render.
|
|
92
|
+
*/
|
|
93
|
+
const layoutCache = new Map();
|
|
94
|
+
|
|
95
|
+
/** Parse `key="value"` / `key=value` pairs from the fence's meta string. */
|
|
96
|
+
export function parseFenceMeta(meta) {
|
|
97
|
+
const options = {};
|
|
98
|
+
|
|
99
|
+
if (!meta) return options;
|
|
100
|
+
|
|
101
|
+
const pattern = /(\w+)=(?:"([^"]*)"|'([^']*)'|(\S+))/g;
|
|
102
|
+
let match;
|
|
103
|
+
|
|
104
|
+
while ((match = pattern.exec(meta)) !== null) {
|
|
105
|
+
options[match[1]] = match[2] ?? match[3] ?? match[4] ?? '';
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return options;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
async function layoutSnippet(source) {
|
|
112
|
+
const key = createHash('sha256').update(source).digest('hex');
|
|
113
|
+
const cached = layoutCache.get(key);
|
|
114
|
+
|
|
115
|
+
if (cached) return cached;
|
|
116
|
+
|
|
117
|
+
const LikeC4 = await loadLikeC4();
|
|
118
|
+
const likec4 = await LikeC4.fromSource(source);
|
|
119
|
+
|
|
120
|
+
if (likec4.hasErrors()) {
|
|
121
|
+
// Only the first line of each error — LikeC4's parser errors enumerate
|
|
122
|
+
// every token it expected, which runs to hundreds of lines.
|
|
123
|
+
const detail = likec4
|
|
124
|
+
.getErrors()
|
|
125
|
+
.map((error) => String(error.message).split('\n')[0].trim())
|
|
126
|
+
.slice(0, 3)
|
|
127
|
+
.join('; ');
|
|
128
|
+
|
|
129
|
+
throw new Error(detail || 'the snippet could not be parsed');
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const model = await likec4.layoutedModel();
|
|
133
|
+
// `$data` is the plain serialisable model; `$model` is its deprecated alias.
|
|
134
|
+
const dump = JSON.parse(JSON.stringify(model.$data));
|
|
135
|
+
|
|
136
|
+
const result = { dump, viewIds: Object.keys(dump.views ?? {}) };
|
|
137
|
+
layoutCache.set(key, result);
|
|
138
|
+
|
|
139
|
+
return result;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** ESTree for `import <local> from '<source>';`, as the MDX compiler expects. */
|
|
143
|
+
function importNode(local, source) {
|
|
144
|
+
return {
|
|
145
|
+
type: 'mdxjsEsm',
|
|
146
|
+
value: `import ${local} from ${JSON.stringify(source)};`,
|
|
147
|
+
data: {
|
|
148
|
+
estree: {
|
|
149
|
+
type: 'Program',
|
|
150
|
+
sourceType: 'module',
|
|
151
|
+
body: [
|
|
152
|
+
{
|
|
153
|
+
type: 'ImportDeclaration',
|
|
154
|
+
specifiers: [
|
|
155
|
+
{ type: 'ImportDefaultSpecifier', local: { type: 'Identifier', name: local } },
|
|
156
|
+
],
|
|
157
|
+
source: { type: 'Literal', value: source, raw: JSON.stringify(source) },
|
|
158
|
+
attributes: [],
|
|
159
|
+
},
|
|
160
|
+
],
|
|
161
|
+
},
|
|
162
|
+
},
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function attributes(pairs) {
|
|
167
|
+
return Object.entries(pairs)
|
|
168
|
+
.filter(([, value]) => value !== undefined && value !== null && value !== '')
|
|
169
|
+
.map(([name, value]) => ({ type: 'mdxJsxAttribute', name, value: String(value) }));
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function describe(fileURL) {
|
|
173
|
+
if (!fileURL) return 'a content file';
|
|
174
|
+
|
|
175
|
+
try {
|
|
176
|
+
return fileURLToPath(fileURL);
|
|
177
|
+
} catch {
|
|
178
|
+
return String(fileURL);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// region plugin
|
|
183
|
+
/**
|
|
184
|
+
* @returns {import('satteri').MdastPluginEntry}
|
|
185
|
+
*/
|
|
186
|
+
export function likec4Fences() {
|
|
187
|
+
// A factory runs once per document, so `used` is safely per-file.
|
|
188
|
+
return (factory) => {
|
|
189
|
+
// Astro only supports components in .mdx. Leaving the fence as a code
|
|
190
|
+
// block is the graceful outcome, but say so — a silent code block where
|
|
191
|
+
// a diagram was intended is easy to miss in review.
|
|
192
|
+
if (factory.sourceFormat !== 'mdx') {
|
|
193
|
+
if (!factory.source.includes('```' + FENCE_LANG)) return null;
|
|
194
|
+
|
|
195
|
+
return {
|
|
196
|
+
name: 'likec4-fences:warn',
|
|
197
|
+
code(node, ctx) {
|
|
198
|
+
if (node.lang !== FENCE_LANG) return;
|
|
199
|
+
|
|
200
|
+
console.warn(
|
|
201
|
+
`[likec4-fences] ${describe(ctx.fileURL)} has a \`\`\`${FENCE_LANG} fence, but fences only ` +
|
|
202
|
+
'render as diagrams in .mdx files. Rename the page to .mdx, or use <LikeC4View /> directly.',
|
|
203
|
+
);
|
|
204
|
+
},
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
if (!factory.source.includes('```' + FENCE_LANG)) return null;
|
|
209
|
+
|
|
210
|
+
const used = new Set();
|
|
211
|
+
|
|
212
|
+
return {
|
|
213
|
+
name: 'likec4-fences',
|
|
214
|
+
|
|
215
|
+
async code(node, ctx) {
|
|
216
|
+
if (node.lang !== FENCE_LANG) return;
|
|
217
|
+
|
|
218
|
+
const where = `${describe(ctx.fileURL)}: \`\`\`${FENCE_LANG} fence`;
|
|
219
|
+
const options = parseFenceMeta(node.meta);
|
|
220
|
+
const source = (node.value ?? '').trim();
|
|
221
|
+
|
|
222
|
+
const common = {
|
|
223
|
+
title: options.title,
|
|
224
|
+
description: options.description,
|
|
225
|
+
height: options.height,
|
|
226
|
+
collapsible: options.collapsible,
|
|
227
|
+
defaultOpen: options.defaultOpen,
|
|
228
|
+
};
|
|
229
|
+
|
|
230
|
+
if (source.length === 0) {
|
|
231
|
+
if (!options.view) {
|
|
232
|
+
throw new Error(`${where} is empty, so it needs \`view="<id>"\` to name a view in the project model.`);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
used.add(PROJECT_COMPONENT);
|
|
236
|
+
|
|
237
|
+
ctx.replaceNode(node, {
|
|
238
|
+
type: 'mdxJsxFlowElement',
|
|
239
|
+
name: PROJECT_COMPONENT,
|
|
240
|
+
attributes: attributes({ ...common, viewId: options.view }),
|
|
241
|
+
children: [],
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
let layout;
|
|
248
|
+
|
|
249
|
+
try {
|
|
250
|
+
layout = await layoutSnippet(source);
|
|
251
|
+
} catch (error) {
|
|
252
|
+
throw new Error(`${where} is not valid LikeC4 — ${error.message}`);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
const viewId = options.view ?? layout.viewIds[0];
|
|
256
|
+
|
|
257
|
+
if (!viewId || !layout.viewIds.includes(viewId)) {
|
|
258
|
+
throw new Error(
|
|
259
|
+
`${where} has no view "${viewId ?? '(none)'}". Declared: ${layout.viewIds.join(', ') || '(none)'}.`,
|
|
260
|
+
);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
used.add(INLINE_COMPONENT);
|
|
264
|
+
|
|
265
|
+
ctx.replaceNode(node, {
|
|
266
|
+
type: 'mdxJsxFlowElement',
|
|
267
|
+
name: INLINE_COMPONENT,
|
|
268
|
+
attributes: attributes({ ...common, viewId, model: JSON.stringify(layout.dump) }),
|
|
269
|
+
children: [],
|
|
270
|
+
});
|
|
271
|
+
},
|
|
272
|
+
|
|
273
|
+
// Imports go in once, after every fence on the page is known.
|
|
274
|
+
after(root, ctx) {
|
|
275
|
+
if (used.size === 0) return;
|
|
276
|
+
|
|
277
|
+
ctx.insertChildAt(
|
|
278
|
+
root,
|
|
279
|
+
0,
|
|
280
|
+
[...used].map((local) => importNode(local, IMPORTS[local])),
|
|
281
|
+
);
|
|
282
|
+
},
|
|
283
|
+
};
|
|
284
|
+
};
|
|
285
|
+
}
|
|
286
|
+
// endregion
|
|
287
|
+
|
|
288
|
+
export default likec4Fences;
|