@executablemd/testing 0.3.1 → 0.4.0
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/esm/{testing/npm/src/src → src}/assertions.js +15 -24
- package/esm/{testing/npm/src/src → src}/handlers.js +140 -10
- package/esm/{testing/npm/src/src → src}/vocabulary.js +3 -0
- package/package.json +3 -3
- package/types/src/assertions.d.ts +11 -1
- package/types/src/handlers.d.ts +1 -0
- package/esm/core/mod.js +0 -55
- package/esm/core/src/api.js +0 -31
- package/esm/core/src/collect.js +0 -24
- package/esm/core/src/component-api.js +0 -47
- package/esm/core/src/content-context.js +0 -23
- package/esm/core/src/deno-compiler.js +0 -51
- package/esm/core/src/errors.js +0 -13
- package/esm/core/src/eval-context.js +0 -13
- package/esm/core/src/eval-handler.js +0 -79
- package/esm/core/src/eval-interpolate.js +0 -63
- package/esm/core/src/eval-transform.js +0 -378
- package/esm/core/src/execute.js +0 -378
- package/esm/core/src/expand.js +0 -988
- package/esm/core/src/frontmatter.js +0 -98
- package/esm/core/src/heal.js +0 -29
- package/esm/core/src/interpolate.js +0 -39
- package/esm/core/src/modifiers/daemon.js +0 -60
- package/esm/core/src/modifiers/persist.js +0 -35
- package/esm/core/src/modifiers/timeout.js +0 -55
- package/esm/core/src/modifiers.js +0 -113
- package/esm/core/src/output/normalize.js +0 -33
- package/esm/core/src/output/terminal.js +0 -20
- package/esm/core/src/render.js +0 -30
- package/esm/core/src/replay-stream.js +0 -58
- package/esm/core/src/sample-api.js +0 -45
- package/esm/core/src/scanner.js +0 -632
- package/esm/core/src/temp-file-compiler.js +0 -68
- package/esm/core/src/types.js +0 -7
- package/esm/core/src/validate.js +0 -82
- package/esm/durable-streams/combinators.js +0 -227
- package/esm/durable-streams/context.js +0 -13
- package/esm/durable-streams/divergence.js +0 -56
- package/esm/durable-streams/each.js +0 -193
- package/esm/durable-streams/effect.js +0 -273
- package/esm/durable-streams/ephemeral.js +0 -80
- package/esm/durable-streams/errors.js +0 -92
- package/esm/durable-streams/http-stream.js +0 -207
- package/esm/durable-streams/mod.js +0 -34
- package/esm/durable-streams/operations.js +0 -88
- package/esm/durable-streams/replay-guard.js +0 -87
- package/esm/durable-streams/replay-index.js +0 -132
- package/esm/durable-streams/run.js +0 -137
- package/esm/durable-streams/serialize.js +0 -65
- package/esm/durable-streams/stream.js +0 -56
- package/esm/durable-streams/types.js +0 -10
- package/esm/runtime/apis.js +0 -237
- package/esm/runtime/find-free-port.js +0 -33
- package/esm/runtime/mod.js +0 -20
- /package/esm/{testing/npm/src/_dnt.polyfills.js → _dnt.polyfills.js} +0 -0
- /package/esm/{testing/npm/src/_dnt.shims.js → _dnt.shims.js} +0 -0
- /package/esm/{testing/npm/src/deps → deps}/jsr.io/@std/assert/1.0.19/almost_equals.js +0 -0
- /package/esm/{testing/npm/src/deps → deps}/jsr.io/@std/assert/1.0.19/array_includes.js +0 -0
- /package/esm/{testing/npm/src/deps → deps}/jsr.io/@std/assert/1.0.19/assert.js +0 -0
- /package/esm/{testing/npm/src/deps → deps}/jsr.io/@std/assert/1.0.19/assertion_error.js +0 -0
- /package/esm/{testing/npm/src/deps → deps}/jsr.io/@std/assert/1.0.19/equal.js +0 -0
- /package/esm/{testing/npm/src/deps → deps}/jsr.io/@std/assert/1.0.19/equals.js +0 -0
- /package/esm/{testing/npm/src/deps → deps}/jsr.io/@std/assert/1.0.19/exists.js +0 -0
- /package/esm/{testing/npm/src/deps → deps}/jsr.io/@std/assert/1.0.19/fail.js +0 -0
- /package/esm/{testing/npm/src/deps → deps}/jsr.io/@std/assert/1.0.19/false.js +0 -0
- /package/esm/{testing/npm/src/deps → deps}/jsr.io/@std/assert/1.0.19/greater.js +0 -0
- /package/esm/{testing/npm/src/deps → deps}/jsr.io/@std/assert/1.0.19/greater_or_equal.js +0 -0
- /package/esm/{testing/npm/src/deps → deps}/jsr.io/@std/assert/1.0.19/instance_of.js +0 -0
- /package/esm/{testing/npm/src/deps → deps}/jsr.io/@std/assert/1.0.19/is_error.js +0 -0
- /package/esm/{testing/npm/src/deps → deps}/jsr.io/@std/assert/1.0.19/less.js +0 -0
- /package/esm/{testing/npm/src/deps → deps}/jsr.io/@std/assert/1.0.19/less_or_equal.js +0 -0
- /package/esm/{testing/npm/src/deps → deps}/jsr.io/@std/assert/1.0.19/match.js +0 -0
- /package/esm/{testing/npm/src/deps → deps}/jsr.io/@std/assert/1.0.19/mod.js +0 -0
- /package/esm/{testing/npm/src/deps → deps}/jsr.io/@std/assert/1.0.19/not_equals.js +0 -0
- /package/esm/{testing/npm/src/deps → deps}/jsr.io/@std/assert/1.0.19/not_instance_of.js +0 -0
- /package/esm/{testing/npm/src/deps → deps}/jsr.io/@std/assert/1.0.19/not_match.js +0 -0
- /package/esm/{testing/npm/src/deps → deps}/jsr.io/@std/assert/1.0.19/not_strict_equals.js +0 -0
- /package/esm/{testing/npm/src/deps → deps}/jsr.io/@std/assert/1.0.19/object_match.js +0 -0
- /package/esm/{testing/npm/src/deps → deps}/jsr.io/@std/assert/1.0.19/rejects.js +0 -0
- /package/esm/{testing/npm/src/deps → deps}/jsr.io/@std/assert/1.0.19/strict_equals.js +0 -0
- /package/esm/{testing/npm/src/deps → deps}/jsr.io/@std/assert/1.0.19/string_includes.js +0 -0
- /package/esm/{testing/npm/src/deps → deps}/jsr.io/@std/assert/1.0.19/throws.js +0 -0
- /package/esm/{testing/npm/src/deps → deps}/jsr.io/@std/assert/1.0.19/unimplemented.js +0 -0
- /package/esm/{testing/npm/src/deps → deps}/jsr.io/@std/assert/1.0.19/unreachable.js +0 -0
- /package/esm/{testing/npm/src/deps → deps}/jsr.io/@std/internal/1.0.14/build_message.js +0 -0
- /package/esm/{testing/npm/src/deps → deps}/jsr.io/@std/internal/1.0.14/diff.js +0 -0
- /package/esm/{testing/npm/src/deps → deps}/jsr.io/@std/internal/1.0.14/diff_str.js +0 -0
- /package/esm/{testing/npm/src/deps → deps}/jsr.io/@std/internal/1.0.14/format.js +0 -0
- /package/esm/{testing/npm/src/deps → deps}/jsr.io/@std/internal/1.0.14/styles.js +0 -0
- /package/esm/{testing/npm/src/deps → deps}/jsr.io/@std/internal/1.0.14/types.js +0 -0
- /package/esm/{testing/npm/src/mod.js → mod.js} +0 -0
- /package/esm/{testing/npm/src/src → src}/journal.js +0 -0
- /package/esm/{testing/npm/src/src → src}/test-api.js +0 -0
- /package/esm/{testing/npm/src/src → src}/use-testing.js +0 -0
package/esm/core/src/scanner.js
DELETED
|
@@ -1,632 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Boundary scanner for Executable MDX (spec §2.2, §3.5).
|
|
3
|
-
*
|
|
4
|
-
* Parses raw markdown text into a flat sequence of Segments.
|
|
5
|
-
* Identifies two kinds of execution boundaries:
|
|
6
|
-
* 1. Component invocations — `<PascalCase ...>` tags
|
|
7
|
-
* 2. Executable code blocks — fenced blocks with `exec` in the info string
|
|
8
|
-
*
|
|
9
|
-
* Everything else is passive text.
|
|
10
|
-
*/
|
|
11
|
-
// ---------------------------------------------------------------------------
|
|
12
|
-
// Info string parsing (spec §3.5)
|
|
13
|
-
// ---------------------------------------------------------------------------
|
|
14
|
-
export function parseInfoString(infoString) {
|
|
15
|
-
const tokens = infoString.trim().split(/\s+/);
|
|
16
|
-
const language = tokens[0] ?? "";
|
|
17
|
-
const modifiers = [];
|
|
18
|
-
for (let i = 1; i < tokens.length; i++) {
|
|
19
|
-
const token = tokens[i];
|
|
20
|
-
// Bracket params: sample[model=phi3-mini]
|
|
21
|
-
const bracketIdx = token.indexOf("[");
|
|
22
|
-
if (bracketIdx >= 0) {
|
|
23
|
-
const closeBracket = token.indexOf("]", bracketIdx);
|
|
24
|
-
if (closeBracket >= 0) {
|
|
25
|
-
modifiers.push({
|
|
26
|
-
name: token.slice(0, bracketIdx),
|
|
27
|
-
params: token.slice(bracketIdx + 1, closeBracket),
|
|
28
|
-
});
|
|
29
|
-
continue;
|
|
30
|
-
}
|
|
31
|
-
}
|
|
32
|
-
// Plain params: timeout=30s
|
|
33
|
-
const eqIdx = token.indexOf("=");
|
|
34
|
-
if (eqIdx >= 0) {
|
|
35
|
-
modifiers.push({
|
|
36
|
-
name: token.slice(0, eqIdx),
|
|
37
|
-
params: token.slice(eqIdx + 1),
|
|
38
|
-
});
|
|
39
|
-
}
|
|
40
|
-
else {
|
|
41
|
-
modifiers.push({ name: token });
|
|
42
|
-
}
|
|
43
|
-
}
|
|
44
|
-
return {
|
|
45
|
-
language,
|
|
46
|
-
modifiers,
|
|
47
|
-
executable: modifiers.some((m) => m.name === "exec" || m.name === "eval"),
|
|
48
|
-
};
|
|
49
|
-
}
|
|
50
|
-
function computeLineStarts(text) {
|
|
51
|
-
const starts = [0];
|
|
52
|
-
for (let i = 0; i < text.length; i++) {
|
|
53
|
-
if (text[i] === "\n") {
|
|
54
|
-
starts.push(i + 1);
|
|
55
|
-
}
|
|
56
|
-
}
|
|
57
|
-
return starts;
|
|
58
|
-
}
|
|
59
|
-
function positionAt(index, offset) {
|
|
60
|
-
const { lineStarts, origin } = index;
|
|
61
|
-
let low = 0;
|
|
62
|
-
let high = lineStarts.length - 1;
|
|
63
|
-
while (low < high) {
|
|
64
|
-
const mid = (low + high + 1) >> 1;
|
|
65
|
-
if (lineStarts[mid] <= offset) {
|
|
66
|
-
low = mid;
|
|
67
|
-
}
|
|
68
|
-
else {
|
|
69
|
-
high = mid - 1;
|
|
70
|
-
}
|
|
71
|
-
}
|
|
72
|
-
const localLine = low + 1;
|
|
73
|
-
const column = offset - lineStarts[low] + 1;
|
|
74
|
-
return {
|
|
75
|
-
path: origin?.path,
|
|
76
|
-
offset: (origin?.baseOffset ?? 0) + offset,
|
|
77
|
-
line: (origin?.baseLine ?? 1) + localLine - 1,
|
|
78
|
-
column,
|
|
79
|
-
};
|
|
80
|
-
}
|
|
81
|
-
/**
|
|
82
|
-
* Scan raw markdown text into segments.
|
|
83
|
-
*
|
|
84
|
-
* Identifies component invocations (`<PascalCase ...>`), executable code
|
|
85
|
-
* blocks (fenced with `exec` in info string), and everything else as text.
|
|
86
|
-
*
|
|
87
|
-
* When `origin` is provided, component invocations carry `position` values
|
|
88
|
-
* expressed in the original file's coordinates.
|
|
89
|
-
*/
|
|
90
|
-
export function scanSegments(text, origin) {
|
|
91
|
-
const index = { origin, lineStarts: computeLineStarts(text) };
|
|
92
|
-
const segments = [];
|
|
93
|
-
let pos = 0;
|
|
94
|
-
let textStart = 0;
|
|
95
|
-
while (pos < text.length) {
|
|
96
|
-
// Check for fenced code block
|
|
97
|
-
const fenceMatch = matchFenceOpen(text, pos);
|
|
98
|
-
if (fenceMatch) {
|
|
99
|
-
// Flush text before fence
|
|
100
|
-
if (pos > textStart) {
|
|
101
|
-
pushText(segments, text.slice(textStart, pos));
|
|
102
|
-
}
|
|
103
|
-
const fenceEnd = findFenceClose(text, fenceMatch.contentStart, fenceMatch.fenceChar, fenceMatch.fenceLen);
|
|
104
|
-
const content = text.slice(fenceMatch.contentStart, fenceEnd.contentEnd);
|
|
105
|
-
const fullFence = text.slice(pos, fenceEnd.fenceEnd);
|
|
106
|
-
const parsed = parseInfoString(fenceMatch.infoString);
|
|
107
|
-
if (parsed.executable) {
|
|
108
|
-
segments.push({
|
|
109
|
-
type: "codeBlock",
|
|
110
|
-
language: parsed.language,
|
|
111
|
-
content,
|
|
112
|
-
modifiers: parsed.modifiers,
|
|
113
|
-
executable: true,
|
|
114
|
-
});
|
|
115
|
-
}
|
|
116
|
-
else {
|
|
117
|
-
// Non-executable code block: preserve as text
|
|
118
|
-
pushText(segments, fullFence);
|
|
119
|
-
}
|
|
120
|
-
pos = fenceEnd.fenceEnd;
|
|
121
|
-
textStart = pos;
|
|
122
|
-
continue;
|
|
123
|
-
}
|
|
124
|
-
// Skip inline code spans — content inside backticks is inert
|
|
125
|
-
if (text[pos] === "`") {
|
|
126
|
-
const codeEnd = skipInlineCode(text, pos);
|
|
127
|
-
if (codeEnd !== -1) {
|
|
128
|
-
pos = codeEnd;
|
|
129
|
-
continue;
|
|
130
|
-
}
|
|
131
|
-
}
|
|
132
|
-
// Check for component invocation: `<` followed by uppercase letter
|
|
133
|
-
if (text[pos] === "<" && pos + 1 < text.length && /[A-Z]/.test(text[pos + 1])) {
|
|
134
|
-
// Make sure we're not inside a fenced code block (handled above)
|
|
135
|
-
const component = parseComponentTag(text, pos, index);
|
|
136
|
-
if (component) {
|
|
137
|
-
// Flush text before component
|
|
138
|
-
if (pos > textStart) {
|
|
139
|
-
pushText(segments, text.slice(textStart, pos));
|
|
140
|
-
}
|
|
141
|
-
segments.push(component.segment);
|
|
142
|
-
pos = component.end;
|
|
143
|
-
textStart = pos;
|
|
144
|
-
continue;
|
|
145
|
-
}
|
|
146
|
-
}
|
|
147
|
-
pos++;
|
|
148
|
-
}
|
|
149
|
-
// Flush remaining text
|
|
150
|
-
if (textStart < text.length) {
|
|
151
|
-
pushText(segments, text.slice(textStart));
|
|
152
|
-
}
|
|
153
|
-
return segments;
|
|
154
|
-
}
|
|
155
|
-
/**
|
|
156
|
-
* Check if position is at the start of a fenced code block opening.
|
|
157
|
-
* Must be at the start of a line (pos === 0 or preceded by newline).
|
|
158
|
-
* Supports both backtick and tilde fences, with optional leading spaces (0-3).
|
|
159
|
-
*/
|
|
160
|
-
function matchFenceOpen(text, pos) {
|
|
161
|
-
// Must be at start of line
|
|
162
|
-
if (pos > 0 && text[pos - 1] !== "\n")
|
|
163
|
-
return null;
|
|
164
|
-
let i = pos;
|
|
165
|
-
// Optional leading spaces (0-3)
|
|
166
|
-
let spaces = 0;
|
|
167
|
-
while (spaces < 3 && i < text.length && text[i] === " ") {
|
|
168
|
-
spaces++;
|
|
169
|
-
i++;
|
|
170
|
-
}
|
|
171
|
-
if (i >= text.length)
|
|
172
|
-
return null;
|
|
173
|
-
const fenceChar = text[i];
|
|
174
|
-
if (fenceChar !== "`" && fenceChar !== "~")
|
|
175
|
-
return null;
|
|
176
|
-
// Count fence characters (minimum 3)
|
|
177
|
-
let fenceLen = 0;
|
|
178
|
-
while (i < text.length && text[i] === fenceChar) {
|
|
179
|
-
fenceLen++;
|
|
180
|
-
i++;
|
|
181
|
-
}
|
|
182
|
-
if (fenceLen < 3)
|
|
183
|
-
return null;
|
|
184
|
-
// Info string goes to end of line
|
|
185
|
-
const lineEnd = text.indexOf("\n", i);
|
|
186
|
-
const infoString = lineEnd === -1 ? text.slice(i).trim() : text.slice(i, lineEnd).trim();
|
|
187
|
-
// Backtick fences: info string must not contain backticks
|
|
188
|
-
if (fenceChar === "`" && infoString.includes("`"))
|
|
189
|
-
return null;
|
|
190
|
-
const contentStart = lineEnd === -1 ? text.length : lineEnd + 1;
|
|
191
|
-
return { fenceChar, fenceLen, infoString, contentStart };
|
|
192
|
-
}
|
|
193
|
-
/**
|
|
194
|
-
* Find the closing fence for an open code block.
|
|
195
|
-
*/
|
|
196
|
-
function findFenceClose(text, contentStart, fenceChar, fenceLen) {
|
|
197
|
-
let pos = contentStart;
|
|
198
|
-
while (pos < text.length) {
|
|
199
|
-
// Must be at start of line
|
|
200
|
-
const lineStart = pos;
|
|
201
|
-
// Optional leading spaces (0-3)
|
|
202
|
-
let spaces = 0;
|
|
203
|
-
while (spaces < 3 && pos < text.length && text[pos] === " ") {
|
|
204
|
-
spaces++;
|
|
205
|
-
pos++;
|
|
206
|
-
}
|
|
207
|
-
// Check for closing fence
|
|
208
|
-
if (pos < text.length && text[pos] === fenceChar) {
|
|
209
|
-
let closeFenceLen = 0;
|
|
210
|
-
while (pos < text.length && text[pos] === fenceChar) {
|
|
211
|
-
closeFenceLen++;
|
|
212
|
-
pos++;
|
|
213
|
-
}
|
|
214
|
-
// Closing fence must be at least as long as opening and followed by
|
|
215
|
-
// only optional spaces then newline or end of string
|
|
216
|
-
if (closeFenceLen >= fenceLen) {
|
|
217
|
-
// Skip optional trailing spaces
|
|
218
|
-
while (pos < text.length && text[pos] === " ") {
|
|
219
|
-
pos++;
|
|
220
|
-
}
|
|
221
|
-
if (pos >= text.length || text[pos] === "\n") {
|
|
222
|
-
const fenceEnd = pos < text.length ? pos + 1 : pos;
|
|
223
|
-
return { contentEnd: lineStart, fenceEnd };
|
|
224
|
-
}
|
|
225
|
-
}
|
|
226
|
-
}
|
|
227
|
-
// Skip to next line
|
|
228
|
-
const nextNewline = text.indexOf("\n", pos);
|
|
229
|
-
if (nextNewline === -1) {
|
|
230
|
-
// No closing fence found — content goes to end
|
|
231
|
-
return { contentEnd: text.length, fenceEnd: text.length };
|
|
232
|
-
}
|
|
233
|
-
pos = nextNewline + 1;
|
|
234
|
-
}
|
|
235
|
-
return { contentEnd: text.length, fenceEnd: text.length };
|
|
236
|
-
}
|
|
237
|
-
/**
|
|
238
|
-
* Parse a JSX component tag starting at `<` (pos).
|
|
239
|
-
* Returns the parsed component invocation and the position after it,
|
|
240
|
-
* or null if this is not a valid component tag.
|
|
241
|
-
*/
|
|
242
|
-
function parseComponentTag(text, start, index) {
|
|
243
|
-
let pos = start + 1; // Skip '<'
|
|
244
|
-
// Parse tag name — must start with uppercase, can contain dots
|
|
245
|
-
const nameStart = pos;
|
|
246
|
-
if (pos >= text.length || !/[A-Z]/.test(text[pos]))
|
|
247
|
-
return null;
|
|
248
|
-
while (pos < text.length && /[A-Za-z0-9._]/.test(text[pos])) {
|
|
249
|
-
pos++;
|
|
250
|
-
}
|
|
251
|
-
const name = text.slice(nameStart, pos);
|
|
252
|
-
if (!name)
|
|
253
|
-
return null;
|
|
254
|
-
// Parse attributes
|
|
255
|
-
const { props, expressions, end: attrEnd } = parseAttributes(text, pos);
|
|
256
|
-
if (attrEnd === -1)
|
|
257
|
-
return null;
|
|
258
|
-
pos = attrEnd;
|
|
259
|
-
// Skip whitespace
|
|
260
|
-
pos = skipWhitespace(text, pos);
|
|
261
|
-
// Self-closing tag?
|
|
262
|
-
if (pos < text.length && text[pos] === "/") {
|
|
263
|
-
pos++; // Skip '/'
|
|
264
|
-
if (pos >= text.length || text[pos] !== ">")
|
|
265
|
-
return null;
|
|
266
|
-
pos++; // Skip '>'
|
|
267
|
-
return {
|
|
268
|
-
segment: {
|
|
269
|
-
type: "component",
|
|
270
|
-
name,
|
|
271
|
-
props,
|
|
272
|
-
expressions,
|
|
273
|
-
children: [],
|
|
274
|
-
selfClosing: true,
|
|
275
|
-
position: positionAt(index, start),
|
|
276
|
-
},
|
|
277
|
-
end: pos,
|
|
278
|
-
};
|
|
279
|
-
}
|
|
280
|
-
// Opening tag: must end with '>'
|
|
281
|
-
if (pos >= text.length || text[pos] !== ">")
|
|
282
|
-
return null;
|
|
283
|
-
pos++; // Skip '>'
|
|
284
|
-
// Parse children until closing tag
|
|
285
|
-
const { children, end: childEnd } = parseChildren(text, pos, name, index);
|
|
286
|
-
if (childEnd === -1)
|
|
287
|
-
return null;
|
|
288
|
-
return {
|
|
289
|
-
segment: {
|
|
290
|
-
type: "component",
|
|
291
|
-
name,
|
|
292
|
-
props,
|
|
293
|
-
expressions,
|
|
294
|
-
children,
|
|
295
|
-
selfClosing: false,
|
|
296
|
-
position: positionAt(index, start),
|
|
297
|
-
},
|
|
298
|
-
end: childEnd,
|
|
299
|
-
};
|
|
300
|
-
}
|
|
301
|
-
function parseAttributes(text, pos) {
|
|
302
|
-
const props = {};
|
|
303
|
-
const expressions = {};
|
|
304
|
-
while (pos < text.length) {
|
|
305
|
-
pos = skipWhitespace(text, pos);
|
|
306
|
-
if (pos >= text.length)
|
|
307
|
-
return { props, expressions, end: -1 };
|
|
308
|
-
// End of attributes?
|
|
309
|
-
if (text[pos] === "/" || text[pos] === ">") {
|
|
310
|
-
return { props, expressions, end: pos };
|
|
311
|
-
}
|
|
312
|
-
// Spread props: {...expr}
|
|
313
|
-
if (text[pos] === "{" &&
|
|
314
|
-
pos + 2 < text.length &&
|
|
315
|
-
text[pos + 1] === "." &&
|
|
316
|
-
text[pos + 2] === ".") {
|
|
317
|
-
// Skip spread — consume the expression
|
|
318
|
-
const exprEnd = findMatchingBrace(text, pos);
|
|
319
|
-
if (exprEnd === -1)
|
|
320
|
-
return { props, expressions, end: -1 };
|
|
321
|
-
// We don't evaluate spread props — just skip them
|
|
322
|
-
pos = exprEnd + 1;
|
|
323
|
-
continue;
|
|
324
|
-
}
|
|
325
|
-
// Attribute name
|
|
326
|
-
const attrNameStart = pos;
|
|
327
|
-
while (pos < text.length && /[A-Za-z0-9_-]/.test(text[pos])) {
|
|
328
|
-
pos++;
|
|
329
|
-
}
|
|
330
|
-
const attrName = text.slice(attrNameStart, pos);
|
|
331
|
-
if (!attrName)
|
|
332
|
-
return { props, expressions, end: -1 };
|
|
333
|
-
pos = skipWhitespace(text, pos);
|
|
334
|
-
// Boolean attribute (no value): `verbose`
|
|
335
|
-
if (pos >= text.length ||
|
|
336
|
-
text[pos] === "/" ||
|
|
337
|
-
text[pos] === ">" ||
|
|
338
|
-
/[A-Za-z{]/.test(text[pos])) {
|
|
339
|
-
// Check it's not `=`
|
|
340
|
-
if (pos < text.length && text[pos] !== "=") {
|
|
341
|
-
props[attrName] = true;
|
|
342
|
-
continue;
|
|
343
|
-
}
|
|
344
|
-
}
|
|
345
|
-
// Must have '='
|
|
346
|
-
if (pos >= text.length || text[pos] !== "=") {
|
|
347
|
-
// Boolean attribute before /> or >
|
|
348
|
-
props[attrName] = true;
|
|
349
|
-
continue;
|
|
350
|
-
}
|
|
351
|
-
pos++; // Skip '='
|
|
352
|
-
pos = skipWhitespace(text, pos);
|
|
353
|
-
// Attribute value
|
|
354
|
-
if (pos >= text.length)
|
|
355
|
-
return { props, expressions, end: -1 };
|
|
356
|
-
if (text[pos] === '"') {
|
|
357
|
-
// String attribute: "value"
|
|
358
|
-
const strEnd = findClosingQuote(text, pos + 1, '"');
|
|
359
|
-
if (strEnd === -1)
|
|
360
|
-
return { props, expressions, end: -1 };
|
|
361
|
-
props[attrName] = text.slice(pos + 1, strEnd);
|
|
362
|
-
pos = strEnd + 1;
|
|
363
|
-
}
|
|
364
|
-
else if (text[pos] === "'") {
|
|
365
|
-
// String attribute: 'value'
|
|
366
|
-
const strEnd = findClosingQuote(text, pos + 1, "'");
|
|
367
|
-
if (strEnd === -1)
|
|
368
|
-
return { props, expressions, end: -1 };
|
|
369
|
-
props[attrName] = text.slice(pos + 1, strEnd);
|
|
370
|
-
pos = strEnd + 1;
|
|
371
|
-
}
|
|
372
|
-
else if (text[pos] === "{") {
|
|
373
|
-
// Expression attribute: {expr}
|
|
374
|
-
const exprEnd = findMatchingBrace(text, pos);
|
|
375
|
-
if (exprEnd === -1)
|
|
376
|
-
return { props, expressions, end: -1 };
|
|
377
|
-
const exprText = text.slice(pos + 1, exprEnd).trim();
|
|
378
|
-
const result = parseExpressionValue(exprText);
|
|
379
|
-
if (result.kind === "resolved") {
|
|
380
|
-
props[attrName] = result.value;
|
|
381
|
-
}
|
|
382
|
-
else {
|
|
383
|
-
expressions[attrName] = result.expression;
|
|
384
|
-
}
|
|
385
|
-
pos = exprEnd + 1;
|
|
386
|
-
}
|
|
387
|
-
else {
|
|
388
|
-
return { props, expressions, end: -1 };
|
|
389
|
-
}
|
|
390
|
-
}
|
|
391
|
-
return { props, expressions, end: -1 };
|
|
392
|
-
}
|
|
393
|
-
/**
|
|
394
|
-
* Find matching closing brace, respecting nested braces, strings, and
|
|
395
|
-
* template literals.
|
|
396
|
-
*/
|
|
397
|
-
function findMatchingBrace(text, start) {
|
|
398
|
-
let depth = 0;
|
|
399
|
-
let pos = start;
|
|
400
|
-
while (pos < text.length) {
|
|
401
|
-
const ch = text[pos];
|
|
402
|
-
if (ch === "{") {
|
|
403
|
-
depth++;
|
|
404
|
-
}
|
|
405
|
-
else if (ch === "}") {
|
|
406
|
-
depth--;
|
|
407
|
-
if (depth === 0)
|
|
408
|
-
return pos;
|
|
409
|
-
}
|
|
410
|
-
else if (ch === '"' || ch === "'") {
|
|
411
|
-
// Skip string literal
|
|
412
|
-
const end = findClosingQuote(text, pos + 1, ch);
|
|
413
|
-
if (end === -1)
|
|
414
|
-
return -1;
|
|
415
|
-
pos = end;
|
|
416
|
-
}
|
|
417
|
-
else if (ch === "`") {
|
|
418
|
-
// Skip template literal
|
|
419
|
-
pos = skipTemplateLiteral(text, pos);
|
|
420
|
-
if (pos === -1)
|
|
421
|
-
return -1;
|
|
422
|
-
}
|
|
423
|
-
pos++;
|
|
424
|
-
}
|
|
425
|
-
return -1;
|
|
426
|
-
}
|
|
427
|
-
/**
|
|
428
|
-
* Skip a template literal starting at the opening backtick.
|
|
429
|
-
* Returns the position of the closing backtick, or -1.
|
|
430
|
-
*/
|
|
431
|
-
function skipTemplateLiteral(text, start) {
|
|
432
|
-
let pos = start + 1; // Skip opening `
|
|
433
|
-
while (pos < text.length) {
|
|
434
|
-
const ch = text[pos];
|
|
435
|
-
if (ch === "`") {
|
|
436
|
-
return pos;
|
|
437
|
-
}
|
|
438
|
-
else if (ch === "\\" && pos + 1 < text.length) {
|
|
439
|
-
pos += 2; // Skip escape sequence
|
|
440
|
-
continue;
|
|
441
|
-
}
|
|
442
|
-
else if (ch === "$" && pos + 1 < text.length && text[pos + 1] === "{") {
|
|
443
|
-
// Template expression — find matching brace
|
|
444
|
-
const braceEnd = findMatchingBrace(text, pos + 1);
|
|
445
|
-
if (braceEnd === -1)
|
|
446
|
-
return -1;
|
|
447
|
-
pos = braceEnd + 1;
|
|
448
|
-
continue;
|
|
449
|
-
}
|
|
450
|
-
pos++;
|
|
451
|
-
}
|
|
452
|
-
return -1;
|
|
453
|
-
}
|
|
454
|
-
function findClosingQuote(text, start, quote) {
|
|
455
|
-
let pos = start;
|
|
456
|
-
while (pos < text.length) {
|
|
457
|
-
if (text[pos] === "\\") {
|
|
458
|
-
pos += 2;
|
|
459
|
-
continue;
|
|
460
|
-
}
|
|
461
|
-
if (text[pos] === quote)
|
|
462
|
-
return pos;
|
|
463
|
-
pos++;
|
|
464
|
-
}
|
|
465
|
-
return -1;
|
|
466
|
-
}
|
|
467
|
-
/**
|
|
468
|
-
* Parse a JSX expression value.
|
|
469
|
-
*
|
|
470
|
-
* JSON-compatible literals (numbers, booleans, null, objects, arrays) are
|
|
471
|
-
* resolved at scan time. Everything else is an eval expression — raw
|
|
472
|
-
* expression text to be evaluated against env.values at expansion time.
|
|
473
|
-
*/
|
|
474
|
-
export function parseExpressionValue(expr) {
|
|
475
|
-
const trimmed = expr.trim();
|
|
476
|
-
// Try JSON-compatible parse for objects, arrays
|
|
477
|
-
try {
|
|
478
|
-
// Handle some JSX expression patterns
|
|
479
|
-
// Convert single-quoted strings to double-quoted for JSON
|
|
480
|
-
const jsonCandidate = trimmed
|
|
481
|
-
// Object/array shorthand: { key: "value" } → {"key": "value"}
|
|
482
|
-
.replace(/(\{|,)\s*([A-Za-z_]\w*)\s*:/g, '$1 "$2":')
|
|
483
|
-
// Single-quoted string values inside objects
|
|
484
|
-
.replace(/:\s*'([^']*)'/g, ': "$1"');
|
|
485
|
-
const parsed = JSON.parse(jsonCandidate);
|
|
486
|
-
return { kind: "resolved", value: parsed };
|
|
487
|
-
}
|
|
488
|
-
catch {
|
|
489
|
-
// Fall through
|
|
490
|
-
}
|
|
491
|
-
// Number
|
|
492
|
-
if (/^-?\d+(\.\d+)?$/.test(trimmed)) {
|
|
493
|
-
return { kind: "resolved", value: Number(trimmed) };
|
|
494
|
-
}
|
|
495
|
-
// Boolean
|
|
496
|
-
if (trimmed === "true")
|
|
497
|
-
return { kind: "resolved", value: true };
|
|
498
|
-
if (trimmed === "false")
|
|
499
|
-
return { kind: "resolved", value: false };
|
|
500
|
-
// null/undefined
|
|
501
|
-
if (trimmed === "null" || trimmed === "undefined") {
|
|
502
|
-
return { kind: "resolved", value: null };
|
|
503
|
-
}
|
|
504
|
-
// Everything else is an eval expression — evaluate at expansion time
|
|
505
|
-
return { kind: "eval", expression: trimmed };
|
|
506
|
-
}
|
|
507
|
-
function parseChildren(text, start, tagName, index) {
|
|
508
|
-
let pos = start;
|
|
509
|
-
let textStart = pos;
|
|
510
|
-
const children = [];
|
|
511
|
-
// Build the closing tag pattern
|
|
512
|
-
const closingTag = `</${tagName}>`;
|
|
513
|
-
while (pos < text.length) {
|
|
514
|
-
// Check for closing tag
|
|
515
|
-
if (text.startsWith(closingTag, pos)) {
|
|
516
|
-
// Flush remaining text as child
|
|
517
|
-
if (pos > textStart) {
|
|
518
|
-
pushText(children, text.slice(textStart, pos));
|
|
519
|
-
}
|
|
520
|
-
return { children, end: pos + closingTag.length };
|
|
521
|
-
}
|
|
522
|
-
// Check for fenced code block inside children
|
|
523
|
-
const fenceMatch = matchFenceOpen(text, pos);
|
|
524
|
-
if (fenceMatch) {
|
|
525
|
-
if (pos > textStart) {
|
|
526
|
-
pushText(children, text.slice(textStart, pos));
|
|
527
|
-
}
|
|
528
|
-
const fenceEnd = findFenceClose(text, fenceMatch.contentStart, fenceMatch.fenceChar, fenceMatch.fenceLen);
|
|
529
|
-
const content = text.slice(fenceMatch.contentStart, fenceEnd.contentEnd);
|
|
530
|
-
const fullFence = text.slice(pos, fenceEnd.fenceEnd);
|
|
531
|
-
const parsed = parseInfoString(fenceMatch.infoString);
|
|
532
|
-
if (parsed.executable) {
|
|
533
|
-
children.push({
|
|
534
|
-
type: "codeBlock",
|
|
535
|
-
language: parsed.language,
|
|
536
|
-
content,
|
|
537
|
-
modifiers: parsed.modifiers,
|
|
538
|
-
executable: true,
|
|
539
|
-
});
|
|
540
|
-
}
|
|
541
|
-
else {
|
|
542
|
-
pushText(children, fullFence);
|
|
543
|
-
}
|
|
544
|
-
pos = fenceEnd.fenceEnd;
|
|
545
|
-
textStart = pos;
|
|
546
|
-
continue;
|
|
547
|
-
}
|
|
548
|
-
// Skip inline code spans — content inside backticks is inert
|
|
549
|
-
if (text[pos] === "`") {
|
|
550
|
-
const codeEnd = skipInlineCode(text, pos);
|
|
551
|
-
if (codeEnd !== -1) {
|
|
552
|
-
pos = codeEnd;
|
|
553
|
-
continue;
|
|
554
|
-
}
|
|
555
|
-
}
|
|
556
|
-
// Check for nested component
|
|
557
|
-
if (text[pos] === "<" && pos + 1 < text.length && /[A-Z]/.test(text[pos + 1])) {
|
|
558
|
-
const nested = parseComponentTag(text, pos, index);
|
|
559
|
-
if (nested) {
|
|
560
|
-
if (pos > textStart) {
|
|
561
|
-
pushText(children, text.slice(textStart, pos));
|
|
562
|
-
}
|
|
563
|
-
children.push(nested.segment);
|
|
564
|
-
pos = nested.end;
|
|
565
|
-
textStart = pos;
|
|
566
|
-
continue;
|
|
567
|
-
}
|
|
568
|
-
}
|
|
569
|
-
pos++;
|
|
570
|
-
}
|
|
571
|
-
// No closing tag found
|
|
572
|
-
return { children, end: -1 };
|
|
573
|
-
}
|
|
574
|
-
// ---------------------------------------------------------------------------
|
|
575
|
-
// Inline code span skipping
|
|
576
|
-
// ---------------------------------------------------------------------------
|
|
577
|
-
/**
|
|
578
|
-
* If `pos` is at a backtick, skip past the closing backtick sequence
|
|
579
|
-
* of the inline code span. Returns the position after the closing
|
|
580
|
-
* backticks, or -1 if no matching close is found (unclosed code span).
|
|
581
|
-
*
|
|
582
|
-
* Handles multi-backtick sequences per CommonMark: the opening and
|
|
583
|
-
* closing sequences must have the same number of backticks.
|
|
584
|
-
*/
|
|
585
|
-
function skipInlineCode(text, pos) {
|
|
586
|
-
if (text[pos] !== "`")
|
|
587
|
-
return -1;
|
|
588
|
-
// Count opening backticks
|
|
589
|
-
let backtickLen = 0;
|
|
590
|
-
let p = pos;
|
|
591
|
-
while (p < text.length && text[p] === "`") {
|
|
592
|
-
backtickLen++;
|
|
593
|
-
p++;
|
|
594
|
-
}
|
|
595
|
-
// Search for matching closing sequence (same length)
|
|
596
|
-
while (p < text.length) {
|
|
597
|
-
if (text[p] === "`") {
|
|
598
|
-
let closeLen = 0;
|
|
599
|
-
while (p < text.length && text[p] === "`") {
|
|
600
|
-
closeLen++;
|
|
601
|
-
p++;
|
|
602
|
-
}
|
|
603
|
-
if (closeLen === backtickLen) {
|
|
604
|
-
return p; // position after closing backticks
|
|
605
|
-
}
|
|
606
|
-
// Wrong length — keep searching
|
|
607
|
-
continue;
|
|
608
|
-
}
|
|
609
|
-
p++;
|
|
610
|
-
}
|
|
611
|
-
return -1; // unclosed code span
|
|
612
|
-
}
|
|
613
|
-
// ---------------------------------------------------------------------------
|
|
614
|
-
// Helpers
|
|
615
|
-
// ---------------------------------------------------------------------------
|
|
616
|
-
function skipWhitespace(text, pos) {
|
|
617
|
-
while (pos < text.length && /\s/.test(text[pos])) {
|
|
618
|
-
pos++;
|
|
619
|
-
}
|
|
620
|
-
return pos;
|
|
621
|
-
}
|
|
622
|
-
function pushText(segments, content) {
|
|
623
|
-
if (content.length === 0)
|
|
624
|
-
return;
|
|
625
|
-
// Merge with previous text segment if possible
|
|
626
|
-
const last = segments[segments.length - 1];
|
|
627
|
-
if (last && last.type === "text") {
|
|
628
|
-
last.content += content;
|
|
629
|
-
return;
|
|
630
|
-
}
|
|
631
|
-
segments.push({ type: "text", content });
|
|
632
|
-
}
|
|
@@ -1,68 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Temp-file eval block compiler middleware for Node and Bun.
|
|
3
|
-
*
|
|
4
|
-
* Compiles eval block source into generator functions by writing
|
|
5
|
-
* temporary .ts files to `.xmd-eval/` and dynamically importing them.
|
|
6
|
-
* Bun handles .ts natively; Node requires tsx.
|
|
7
|
-
*
|
|
8
|
-
* Standard imports (Effection, executable.md APIs) are captured in the middleware
|
|
9
|
-
* closure — they are not part of the Compiler API interface.
|
|
10
|
-
*
|
|
11
|
-
* Installed automatically by `execute` when running on Node or Bun.
|
|
12
|
-
*/
|
|
13
|
-
import { call } from "effection";
|
|
14
|
-
import { API } from "@executablemd/runtime";
|
|
15
|
-
// STANDARD_IMPORTS below resolve at runtime from generated eval modules;
|
|
16
|
-
// without these static anchors, `deno compile --exclude-unused-npm` prunes
|
|
17
|
-
// the packages from the binary and every eval block using them fails.
|
|
18
|
-
import "@effectionx/converge";
|
|
19
|
-
import "@effectionx/fetch";
|
|
20
|
-
import { writeFile, unlink, mkdir } from "node:fs/promises";
|
|
21
|
-
import { resolve } from "node:path";
|
|
22
|
-
import { randomUUID } from "node:crypto";
|
|
23
|
-
/**
|
|
24
|
-
* Standard import statements prepended to every generated eval module.
|
|
25
|
-
* Captured in the middleware closure — not exposed on the public API.
|
|
26
|
-
*/
|
|
27
|
-
const STANDARD_IMPORTS = [
|
|
28
|
-
'import { sleep, spawn, call, resource, useScope, createChannel, each, suspend, createSignal } from "effection";',
|
|
29
|
-
'import { when } from "@effectionx/converge";',
|
|
30
|
-
'import { fetch } from "@effectionx/fetch";',
|
|
31
|
-
'import { useContent, Sample } from "@executablemd/core";',
|
|
32
|
-
'import { findFreePort } from "@executablemd/runtime";',
|
|
33
|
-
];
|
|
34
|
-
const EVAL_DIR = ".xmd-eval";
|
|
35
|
-
/**
|
|
36
|
-
* Install the temp-file compiler as middleware on the current scope.
|
|
37
|
-
*
|
|
38
|
-
* Works on Node (via tsx) and Bun (native .ts support).
|
|
39
|
-
* Writes each compiled module to `.xmd-eval/<uuid>.ts`, imports it,
|
|
40
|
-
* captures the default export, and deletes the file.
|
|
41
|
-
*/
|
|
42
|
-
export function* useTempFileCompiler() {
|
|
43
|
-
// Ensure the eval directory exists
|
|
44
|
-
yield* call(() => mkdir(EVAL_DIR, { recursive: true }));
|
|
45
|
-
yield* API.Compiler.around({
|
|
46
|
-
*compile([source, options], next) {
|
|
47
|
-
void next; // terminal middleware — does not delegate
|
|
48
|
-
const userImports = options?.imports ?? [];
|
|
49
|
-
const allImports = [...STANDARD_IMPORTS, ...userImports];
|
|
50
|
-
const importLines = allImports.join("\n");
|
|
51
|
-
const moduleSource = [importLines, `export default function*(env) {`, source, `}`].join("\n");
|
|
52
|
-
const tmpPath = resolve(EVAL_DIR, `${randomUUID()}.ts`);
|
|
53
|
-
yield* call(() => writeFile(tmpPath, moduleSource, "utf-8"));
|
|
54
|
-
try {
|
|
55
|
-
const fileUrl = new URL(`file://${tmpPath}`).href;
|
|
56
|
-
const mod = yield* call(() => import(fileUrl));
|
|
57
|
-
if (typeof mod.default !== "function") {
|
|
58
|
-
throw new Error(`useTempFileCompiler: expected default export to be a generator function, got ${typeof mod.default}`);
|
|
59
|
-
}
|
|
60
|
-
return mod.default;
|
|
61
|
-
}
|
|
62
|
-
finally {
|
|
63
|
-
// Clean up temp file — don't await, fire and forget
|
|
64
|
-
unlink(tmpPath).catch(() => { });
|
|
65
|
-
}
|
|
66
|
-
},
|
|
67
|
-
});
|
|
68
|
-
}
|