@executablemd/testing 0.3.1 → 0.4.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.
Files changed (94) hide show
  1. package/esm/{testing/npm/src/src → src}/assertions.js +15 -24
  2. package/esm/{testing/npm/src/src → src}/handlers.js +140 -10
  3. package/esm/{testing/npm/src/src → src}/vocabulary.js +3 -0
  4. package/package.json +3 -3
  5. package/types/src/assertions.d.ts +11 -1
  6. package/types/src/handlers.d.ts +1 -0
  7. package/esm/core/mod.js +0 -55
  8. package/esm/core/src/api.js +0 -31
  9. package/esm/core/src/collect.js +0 -24
  10. package/esm/core/src/component-api.js +0 -47
  11. package/esm/core/src/content-context.js +0 -23
  12. package/esm/core/src/deno-compiler.js +0 -51
  13. package/esm/core/src/errors.js +0 -13
  14. package/esm/core/src/eval-context.js +0 -13
  15. package/esm/core/src/eval-handler.js +0 -79
  16. package/esm/core/src/eval-interpolate.js +0 -63
  17. package/esm/core/src/eval-transform.js +0 -378
  18. package/esm/core/src/execute.js +0 -378
  19. package/esm/core/src/expand.js +0 -988
  20. package/esm/core/src/frontmatter.js +0 -98
  21. package/esm/core/src/heal.js +0 -29
  22. package/esm/core/src/interpolate.js +0 -39
  23. package/esm/core/src/modifiers/daemon.js +0 -60
  24. package/esm/core/src/modifiers/persist.js +0 -35
  25. package/esm/core/src/modifiers/timeout.js +0 -55
  26. package/esm/core/src/modifiers.js +0 -113
  27. package/esm/core/src/output/normalize.js +0 -33
  28. package/esm/core/src/output/terminal.js +0 -20
  29. package/esm/core/src/render.js +0 -30
  30. package/esm/core/src/replay-stream.js +0 -58
  31. package/esm/core/src/sample-api.js +0 -45
  32. package/esm/core/src/scanner.js +0 -632
  33. package/esm/core/src/temp-file-compiler.js +0 -68
  34. package/esm/core/src/types.js +0 -7
  35. package/esm/core/src/validate.js +0 -82
  36. package/esm/durable-streams/combinators.js +0 -227
  37. package/esm/durable-streams/context.js +0 -13
  38. package/esm/durable-streams/divergence.js +0 -56
  39. package/esm/durable-streams/each.js +0 -193
  40. package/esm/durable-streams/effect.js +0 -273
  41. package/esm/durable-streams/ephemeral.js +0 -80
  42. package/esm/durable-streams/errors.js +0 -92
  43. package/esm/durable-streams/http-stream.js +0 -207
  44. package/esm/durable-streams/mod.js +0 -34
  45. package/esm/durable-streams/operations.js +0 -88
  46. package/esm/durable-streams/replay-guard.js +0 -87
  47. package/esm/durable-streams/replay-index.js +0 -132
  48. package/esm/durable-streams/run.js +0 -137
  49. package/esm/durable-streams/serialize.js +0 -65
  50. package/esm/durable-streams/stream.js +0 -56
  51. package/esm/durable-streams/types.js +0 -10
  52. package/esm/runtime/apis.js +0 -237
  53. package/esm/runtime/find-free-port.js +0 -33
  54. package/esm/runtime/mod.js +0 -20
  55. /package/esm/{testing/npm/src/_dnt.polyfills.js → _dnt.polyfills.js} +0 -0
  56. /package/esm/{testing/npm/src/_dnt.shims.js → _dnt.shims.js} +0 -0
  57. /package/esm/{testing/npm/src/deps → deps}/jsr.io/@std/assert/1.0.19/almost_equals.js +0 -0
  58. /package/esm/{testing/npm/src/deps → deps}/jsr.io/@std/assert/1.0.19/array_includes.js +0 -0
  59. /package/esm/{testing/npm/src/deps → deps}/jsr.io/@std/assert/1.0.19/assert.js +0 -0
  60. /package/esm/{testing/npm/src/deps → deps}/jsr.io/@std/assert/1.0.19/assertion_error.js +0 -0
  61. /package/esm/{testing/npm/src/deps → deps}/jsr.io/@std/assert/1.0.19/equal.js +0 -0
  62. /package/esm/{testing/npm/src/deps → deps}/jsr.io/@std/assert/1.0.19/equals.js +0 -0
  63. /package/esm/{testing/npm/src/deps → deps}/jsr.io/@std/assert/1.0.19/exists.js +0 -0
  64. /package/esm/{testing/npm/src/deps → deps}/jsr.io/@std/assert/1.0.19/fail.js +0 -0
  65. /package/esm/{testing/npm/src/deps → deps}/jsr.io/@std/assert/1.0.19/false.js +0 -0
  66. /package/esm/{testing/npm/src/deps → deps}/jsr.io/@std/assert/1.0.19/greater.js +0 -0
  67. /package/esm/{testing/npm/src/deps → deps}/jsr.io/@std/assert/1.0.19/greater_or_equal.js +0 -0
  68. /package/esm/{testing/npm/src/deps → deps}/jsr.io/@std/assert/1.0.19/instance_of.js +0 -0
  69. /package/esm/{testing/npm/src/deps → deps}/jsr.io/@std/assert/1.0.19/is_error.js +0 -0
  70. /package/esm/{testing/npm/src/deps → deps}/jsr.io/@std/assert/1.0.19/less.js +0 -0
  71. /package/esm/{testing/npm/src/deps → deps}/jsr.io/@std/assert/1.0.19/less_or_equal.js +0 -0
  72. /package/esm/{testing/npm/src/deps → deps}/jsr.io/@std/assert/1.0.19/match.js +0 -0
  73. /package/esm/{testing/npm/src/deps → deps}/jsr.io/@std/assert/1.0.19/mod.js +0 -0
  74. /package/esm/{testing/npm/src/deps → deps}/jsr.io/@std/assert/1.0.19/not_equals.js +0 -0
  75. /package/esm/{testing/npm/src/deps → deps}/jsr.io/@std/assert/1.0.19/not_instance_of.js +0 -0
  76. /package/esm/{testing/npm/src/deps → deps}/jsr.io/@std/assert/1.0.19/not_match.js +0 -0
  77. /package/esm/{testing/npm/src/deps → deps}/jsr.io/@std/assert/1.0.19/not_strict_equals.js +0 -0
  78. /package/esm/{testing/npm/src/deps → deps}/jsr.io/@std/assert/1.0.19/object_match.js +0 -0
  79. /package/esm/{testing/npm/src/deps → deps}/jsr.io/@std/assert/1.0.19/rejects.js +0 -0
  80. /package/esm/{testing/npm/src/deps → deps}/jsr.io/@std/assert/1.0.19/strict_equals.js +0 -0
  81. /package/esm/{testing/npm/src/deps → deps}/jsr.io/@std/assert/1.0.19/string_includes.js +0 -0
  82. /package/esm/{testing/npm/src/deps → deps}/jsr.io/@std/assert/1.0.19/throws.js +0 -0
  83. /package/esm/{testing/npm/src/deps → deps}/jsr.io/@std/assert/1.0.19/unimplemented.js +0 -0
  84. /package/esm/{testing/npm/src/deps → deps}/jsr.io/@std/assert/1.0.19/unreachable.js +0 -0
  85. /package/esm/{testing/npm/src/deps → deps}/jsr.io/@std/internal/1.0.14/build_message.js +0 -0
  86. /package/esm/{testing/npm/src/deps → deps}/jsr.io/@std/internal/1.0.14/diff.js +0 -0
  87. /package/esm/{testing/npm/src/deps → deps}/jsr.io/@std/internal/1.0.14/diff_str.js +0 -0
  88. /package/esm/{testing/npm/src/deps → deps}/jsr.io/@std/internal/1.0.14/format.js +0 -0
  89. /package/esm/{testing/npm/src/deps → deps}/jsr.io/@std/internal/1.0.14/styles.js +0 -0
  90. /package/esm/{testing/npm/src/deps → deps}/jsr.io/@std/internal/1.0.14/types.js +0 -0
  91. /package/esm/{testing/npm/src/mod.js → mod.js} +0 -0
  92. /package/esm/{testing/npm/src/src → src}/journal.js +0 -0
  93. /package/esm/{testing/npm/src/src → src}/test-api.js +0 -0
  94. /package/esm/{testing/npm/src/src → src}/use-testing.js +0 -0
@@ -1,988 +0,0 @@
1
- /**
2
- * Expansion engine (spec §5).
3
- *
4
- * Term-rewriting process: each component invocation is replaced by the
5
- * component's body, with <Content /> (and <Content slot="name" />)
6
- * substituted by the invocation's children and {meta.key}/{props.key}
7
- * resolved.
8
- *
9
- * Top-down expansion with raw child substitution: children are
10
- * substituted into the component body as raw (unexpanded) segments,
11
- * then the entire substituted body is expanded in document order.
12
- * This ensures code blocks before <Content /> (e.g., provider
13
- * middleware installation) execute before children's code blocks.
14
- */
15
- import { scoped } from "effection";
16
- import { interpolate } from "./interpolate.ts";
17
- import { interpolateEvalBindings } from "./eval-interpolate.ts";
18
- import { Component, applyModifiers, env, evalScope, expandInvocation, importComponent, raise, } from "./component-api.ts";
19
- import { DocumentationError } from "./errors.ts";
20
- import { useEvalScope, unbox } from "@effectionx/scope-eval";
21
- import { validateProps } from "./validate.ts";
22
- import { healSegment } from "./heal.ts";
23
- import { scanSegments } from "./scanner.ts";
24
- import { renderSegments } from "./render.ts";
25
- import { remark } from "remark";
26
- import { select as cssSelect } from "unist-util-select";
27
- import { toString as mdastToString } from "mdast-util-to-string";
28
- export function createBlockCounter() {
29
- let id = 0;
30
- return { next: () => id++ };
31
- }
32
- // Providers install at "min" inside scoped() so nested components override
33
- // ancestors (innermost min runs first) without leaking into siblings.
34
- function provideEnv(value) {
35
- return Component.around({ env: () => value }, { at: "min" });
36
- }
37
- function provideEvalScope(value) {
38
- return Component.around({ evalScope: () => value }, { at: "min" });
39
- }
40
- // ---------------------------------------------------------------------------
41
- // Expansion algorithm (spec §5.1)
42
- // ---------------------------------------------------------------------------
43
- const MAX_EXPANSION_DEPTH = 64;
44
- const IDENTIFIER_RE = /^[a-zA-Z_$][a-zA-Z0-9_$]*$/;
45
- /**
46
- * Expand an array of segments, resolving components and executing code blocks.
47
- *
48
- * Component import, modifier execution, bindings, and error policy are all
49
- * delivered contextually through the Component Api — install providers with
50
- * `Component.around(..., { at: "min" })` before expanding.
51
- *
52
- * @param counter - Optional block ID counter. If omitted, a local counter
53
- * is created. For per-segment emission (§9), pass a shared counter so
54
- * IDs are stable across calls.
55
- */
56
- export function* expandSegments(segments, parentMeta, parentProps, hideSet, counter = createBlockCounter()) {
57
- const result = [];
58
- for (const segment of segments) {
59
- switch (segment.type) {
60
- case "text": {
61
- // Heal incomplete markdown constructs at segment boundaries (spec §2.3)
62
- // Runs synchronously — no yield, no journal entry
63
- const healed = healSegment(segment.content);
64
- // Interpolate {meta.key} and {props.key} — runtime, no journal
65
- const interpolated = interpolate(healed, parentMeta, parentProps);
66
- // Interpolate bare {name} refs from eval bindings (spec §6.4/§6.6).
67
- // Runs after meta/props interpolation so component contract takes
68
- // precedence. Only runs when a binding environment is in scope.
69
- const textEvalEnv = yield* env;
70
- const final = textEvalEnv
71
- ? interpolateEvalBindings(interpolated, textEvalEnv.values)
72
- : interpolated;
73
- result.push({ type: "text", content: final });
74
- break;
75
- }
76
- case "component": {
77
- // Extension hook: an installed vocabulary may claim this invocation
78
- // before built-in expansion. Returned error segments follow the
79
- // ambient raise policy, like any component-produced error.
80
- const handling = yield* expandInvocation(segment, {
81
- meta: parentMeta,
82
- props: parentProps,
83
- projectedEnv: segment.projectedEnv,
84
- expand: (segments) => expandSegments(segments, parentMeta, parentProps, hideSet, counter),
85
- });
86
- if (handling) {
87
- for (const handled of handling.segments) {
88
- if (handled.type === "error") {
89
- result.push(yield* raise(handled));
90
- }
91
- else {
92
- result.push(handled);
93
- }
94
- }
95
- break;
96
- }
97
- if (segment.name === "Output") {
98
- // Definition-owned <Output> is consumed by buildBody before it
99
- // reaches here. Reaching this branch means a misplaced or
100
- // dynamically scanned <Output> (e.g. render(markdown) content) —
101
- // diagnose it defensively per the ambient policy.
102
- result.push(yield* raise(misplacedOutputError()));
103
- break;
104
- }
105
- if (segment.name === "Capture") {
106
- const captureResult = yield* expandCapture(segment, parentMeta, parentProps, hideSet, counter);
107
- if (captureResult) {
108
- result.push(yield* raise(captureResult));
109
- }
110
- break;
111
- }
112
- const expanded = yield* expandComponent(segment.name, segment.props, segment.expressions, segment.children, hideSet, counter, segment.projectedEnv);
113
- // Consumer boundary: re-raise transported error segments under the
114
- // ambient policy before appending them (spec §6.9).
115
- for (const expandedSegment of expanded) {
116
- if (expandedSegment.type === "error") {
117
- result.push(yield* raise(expandedSegment));
118
- }
119
- else {
120
- result.push(expandedSegment);
121
- }
122
- }
123
- break;
124
- }
125
- case "codeBlock": {
126
- // Interpolate eval bindings into content before the modifier chain.
127
- // A binding environment may not be in scope (e.g., blocks outside
128
- // component expansion) — fall back to the original content.
129
- //
130
- // Skip interpolation for eval blocks — they access bindings directly
131
- // via the env preamble (const { name } = env;). Interpolating would
132
- // mangle JS template literals like `${name}` into `$<value>`.
133
- const evalEnv = yield* env;
134
- const lastModifier = segment.modifiers[segment.modifiers.length - 1];
135
- const isEvalTerminal = lastModifier !== undefined && lastModifier.name === "eval";
136
- const interpolatedContent = evalEnv && !isEvalTerminal
137
- ? interpolateEvalBindings(segment.content, evalEnv.values)
138
- : segment.content;
139
- // Compose modifier chain from info string and run it.
140
- // blockId uses counter.next() for deterministic IDs that
141
- // survive per-segment expansion (see spec §6.1 Block ID counter).
142
- const context = {
143
- language: segment.language,
144
- content: interpolatedContent,
145
- blockId: `eval:${parentMeta["componentName"] ?? "root"}:${counter.next()}`,
146
- componentName: parentMeta["componentName"],
147
- };
148
- try {
149
- const codeResult = yield* applyModifiers(segment.modifiers, context);
150
- if (codeResult.exitCode !== 0 && codeResult.output === "") {
151
- result.push(yield* raise({
152
- type: "error",
153
- message: `Command failed (exit ${codeResult.exitCode}): ${codeResult.stderr}`,
154
- source: segment.content,
155
- }));
156
- }
157
- else if (codeResult.output !== "") {
158
- result.push({
159
- type: "execOutput",
160
- command: segment.content,
161
- result: {
162
- exitCode: codeResult.exitCode,
163
- stdout: codeResult.output,
164
- stderr: codeResult.stderr,
165
- },
166
- });
167
- }
168
- // If output is empty and exit code is 0, nothing added (e.g., silent)
169
- }
170
- catch (error) {
171
- // A DocumentationError from nested expansion (e.g. renderChildren
172
- // inside an eval block) is our own fail-fast — never swallow it.
173
- if (error instanceof DocumentationError) {
174
- throw error;
175
- }
176
- result.push(yield* raise({
177
- type: "error",
178
- message: error instanceof Error ? error.message : String(error),
179
- source: segment.content,
180
- }));
181
- }
182
- break;
183
- }
184
- default: {
185
- if (segment.type === "error") {
186
- // Pre-existing error segments (e.g. slot/substitution errors) follow
187
- // the ambient policy.
188
- result.push(yield* raise(segment));
189
- }
190
- else {
191
- result.push(segment);
192
- }
193
- }
194
- }
195
- }
196
- return result;
197
- }
198
- function* expandCapture(segment, parentMeta, parentProps, hideSet, counter) {
199
- if (segment.selfClosing || segment.children.length === 0) {
200
- return {
201
- type: "error",
202
- message: '<Capture> must have content. Use <Capture as="x">...</Capture>.',
203
- source: "Capture",
204
- };
205
- }
206
- const propNames = Object.keys(segment.props);
207
- if (propNames.some((name) => name !== "as" && name !== "select")) {
208
- return {
209
- type: "error",
210
- message: '<Capture> only accepts "as" and "select" props.',
211
- source: "Capture",
212
- };
213
- }
214
- const expressionNames = Object.keys(segment.expressions);
215
- if (expressionNames.length > 0) {
216
- if (expressionNames.includes("as")) {
217
- return {
218
- type: "error",
219
- message: '<Capture as={...}> is invalid: "as" must be a string literal.',
220
- source: "Capture",
221
- };
222
- }
223
- if (!expressionNames.every((n) => n === "select")) {
224
- return {
225
- type: "error",
226
- message: '<Capture> only accepts "as" and "select" props.',
227
- source: "Capture",
228
- };
229
- }
230
- }
231
- if (segment.props.as === undefined) {
232
- return {
233
- type: "error",
234
- message: '<Capture> requires an "as" prop (non-empty string).',
235
- source: "Capture",
236
- };
237
- }
238
- const asBinding = validateBindingName(segment.props.as);
239
- if (!asBinding.ok) {
240
- return {
241
- type: "error",
242
- message: asBinding.error,
243
- source: "Capture",
244
- };
245
- }
246
- const bindingName = asBinding.value;
247
- if (bindingName === undefined) {
248
- return {
249
- type: "error",
250
- message: '<Capture> requires an "as" prop (non-empty string).',
251
- source: "Capture",
252
- };
253
- }
254
- const expandedChildren = yield* expandSegments(segment.children, parentMeta, parentProps, hideSet, counter);
255
- const rendered = renderSegments(expandedChildren).replace(/\s+$/, "");
256
- // Apply CSS selector if select prop is present (spec §6.5)
257
- let captured = rendered;
258
- const selectProp = segment.props.select;
259
- if (typeof selectProp === "string" && selectProp.length > 0) {
260
- const tree = remark().parse(captured);
261
- // deno-lint-ignore no-explicit-any
262
- const node = cssSelect(selectProp, tree);
263
- if (node) {
264
- captured = "value" in node ? String(node.value) : mdastToString(node);
265
- }
266
- }
267
- const bindingEnv = yield* env;
268
- if (!bindingEnv) {
269
- return {
270
- type: "error",
271
- message: "<Capture> requires an evaluation environment.",
272
- source: "Capture",
273
- };
274
- }
275
- bindingEnv.values[bindingName] = captured;
276
- return undefined;
277
- }
278
- // ---------------------------------------------------------------------------
279
- // Component expansion with cycle detection (spec §5.2)
280
- // ---------------------------------------------------------------------------
281
- function* expandComponent(name, props, expressions, children, hideSet, counter, projectedEnv) {
282
- // Cycle detection — Prosser's algorithm
283
- if (hideSet.has(name)) {
284
- return [
285
- yield* raise({
286
- type: "error",
287
- message: `Cycle detected: ${name} is already being expanded (hide set: ${[...hideSet].join(" → ")})`,
288
- source: name,
289
- }),
290
- ];
291
- }
292
- if (hideSet.size >= MAX_EXPANSION_DEPTH) {
293
- return [
294
- yield* raise({
295
- type: "error",
296
- message: `Maximum expansion depth (${MAX_EXPANSION_DEPTH}) exceeded`,
297
- source: name,
298
- }),
299
- ];
300
- }
301
- let imported;
302
- try {
303
- imported = yield* importComponent(name);
304
- }
305
- catch (error) {
306
- return [
307
- yield* raise({
308
- type: "error",
309
- message: error instanceof Error
310
- ? `Failed to import component ${name}: ${error.message}`
311
- : `Failed to import component ${name}: ${String(error)}`,
312
- source: name,
313
- }),
314
- ];
315
- }
316
- // Function component: call the generator function directly
317
- if (imported.kind === "function") {
318
- return yield* expandFunctionComponent(name, props, expressions, children, imported, hideSet, counter, projectedEnv);
319
- }
320
- const definition = imported;
321
- // Structural preflight (spec §6.9): validate <Output> placement against the
322
- // component's own source AST before any part of its body executes. A
323
- // structurally invalid component runs no eval, exec, Capture, nested
324
- // components, or other side effects.
325
- const placementError = validateOutputPlacement(definition.bodySegments);
326
- if (placementError) {
327
- return [yield* raise(placementError)];
328
- }
329
- // Resolve eval expression props against env.values using the shared
330
- // VM context. This must happen before validation so that resolved
331
- // values can be type-checked. See spec §5.1 (expression prop evaluation).
332
- let resolvedProps;
333
- try {
334
- resolvedProps = yield* resolveExpressionProps(props, expressions, name, projectedEnv);
335
- }
336
- catch (error) {
337
- return [
338
- yield* raise({
339
- type: "error",
340
- message: error instanceof Error ? error.message : String(error),
341
- source: name,
342
- }),
343
- ];
344
- }
345
- if ("as" in expressions) {
346
- return [
347
- yield* raise({
348
- type: "error",
349
- message: `Prop "as" on <${name} /> must be a string literal.`,
350
- source: name,
351
- }),
352
- ];
353
- }
354
- // Validate props against declared inputs.
355
- // Strip the `slot` prop before validation — it is consumed by the
356
- // expansion engine for slot assignment, not forwarded to the child.
357
- let validatedProps;
358
- let asBinding;
359
- try {
360
- const binding = validateBindingName(resolvedProps.as);
361
- if (!binding.ok) {
362
- throw new Error(`Prop "as" on <${name} /> ${binding.error}`);
363
- }
364
- asBinding = binding.value;
365
- const { slot: _slot, as: _as, ...propsForValidation } = resolvedProps;
366
- validatedProps = validateProps(name, propsForValidation, definition.inputs);
367
- }
368
- catch (error) {
369
- return [
370
- yield* raise({
371
- type: "error",
372
- message: error instanceof Error ? error.message : String(error),
373
- source: name,
374
- }),
375
- ];
376
- }
377
- // Capture the caller's eval environment before creating the component's
378
- // own env. Children are caller-provided content — expression props like
379
- // {pr} should resolve against the scope where the JSX was written, not
380
- // the component that renders <Content />.
381
- //
382
- // For multi-level nesting (Root → Provider → Instruction → ReviewBody),
383
- // the projectedEnv from the outer caller must be merged with the current
384
- // context env so that ancestor bindings propagate through all levels.
385
- // The current context env's bindings take precedence (innermost-wins).
386
- const contextEnv = yield* env;
387
- const callerEvalEnv = projectedEnv
388
- ? { values: { ...projectedEnv.values, ...(contextEnv?.values ?? {}) } }
389
- : contextEnv;
390
- // Recurse with augmented hide set.
391
- // Each component gets its own fresh binding environment so that
392
- // eval blocks within a component share bindings but don't leak
393
- // into parent or sibling components. This is critical for the
394
- // provider pattern where each provider has isolated port/URL bindings.
395
- //
396
- // Each component also gets its own EvalScope, created as a child of
397
- // the parent component's eval scope. This ensures that middleware
398
- // installed via `persist eval` blocks (e.g., Sample.around()) is
399
- // scoped to the component. Nested providers produce a scope chain
400
- // where innermost middleware runs first (innermost-wins), and
401
- // next() delegates to the parent scope's middleware.
402
- const newHideSet = new Set([...hideSet, name]);
403
- const componentEnv = { values: { ...validatedProps } };
404
- // Create per-component eval scope as a child of the parent eval scope.
405
- // By creating it via parentEvalScope.eval(), the child's spawned task
406
- // lives inside the parent's scope — Effection's scope prototype chain
407
- // ensures scope.reduce() walks child → parent when resolving middleware.
408
- const parentEvalScope = yield* evalScope;
409
- let childEvalScope = undefined;
410
- if (parentEvalScope) {
411
- const result = yield* parentEvalScope.eval(() => useEvalScope());
412
- childEvalScope = unbox(result);
413
- }
414
- // Inject render closures into the component's binding environment.
415
- // These are generator functions that eval blocks can yield* to render
416
- // content within the current expansion context.
417
- //
418
- // renderChildren() — expands and renders this component's children.
419
- // render(markdown) — scans, expands, and renders arbitrary markdown.
420
- //
421
- // Both use parentEvalScope, not childEvalScope. Children are
422
- // caller-provided content — they expand in the caller's scope
423
- // context. The component's childEvalScope and its sequential
424
- // channel are for the component's own persist eval blocks
425
- // (middleware installation, etc.), not for expanding caller content.
426
- //
427
- // Children may contain operations that create resources (nested
428
- // components, persist eval blocks, daemons), but those resources
429
- // are scoped to the expansion — their lifecycle is bound by their
430
- // place in the structured concurrency tree. Inner components create
431
- // their own child scopes off parentEvalScope, and ancestor
432
- // middleware is visible through Effection's scope prototype chain.
433
- //
434
- // Both install env/evalScope middleware inside a fresh scope so the full
435
- // expansion context is available regardless of which task the closure
436
- // runs in (e.g., inside evalScope.eval()).
437
- //
438
- // These are non-serializable (functions) so serializeExports silently
439
- // omits them from the journal.
440
- const capturedMeta = definition.meta;
441
- const capturedProps = validatedProps;
442
- // Children are caller-provided content, not the component's own body.
443
- // Use the parent's hide set (without the current component name) so
444
- // that caller-provided children can reference the same component name
445
- // without triggering false cycle detection. True cycles in a component's
446
- // body are still caught because body expansion uses newHideSet.
447
- const capturedChildrenHideSet = hideSet;
448
- const capturedParentEvalScope = parentEvalScope;
449
- // Children are caller-provided content. Use the caller's eval env so
450
- // expression props (e.g., {pr}) resolve against the scope where the
451
- // JSX was written, not the wrapping component's env. Falls back to
452
- const capturedCallerEnv = callerEvalEnv ?? componentEnv;
453
- const renderInCallerScope = (segments) => scoped(function* () {
454
- yield* provideEnv(capturedCallerEnv);
455
- if (capturedParentEvalScope) {
456
- yield* provideEvalScope(capturedParentEvalScope);
457
- }
458
- const expanded = yield* expandSegments(segments, capturedMeta, capturedProps, capturedChildrenHideSet, counter);
459
- return renderSegments(expanded);
460
- });
461
- componentEnv.values.renderChildren = () => renderInCallerScope(children);
462
- componentEnv.values.render = (markdown) => renderInCallerScope(scanSegments(markdown));
463
- const expanded = yield* scoped(function* () {
464
- yield* provideEnv(componentEnv);
465
- if (childEvalScope) {
466
- yield* provideEvalScope(childEvalScope);
467
- }
468
- return yield* expandBody(definition.bodySegments, children, definition.meta, validatedProps, newHideSet, counter, callerEvalEnv ?? undefined);
469
- });
470
- if (asBinding) {
471
- const parentEnv = yield* env;
472
- if (!parentEnv) {
473
- return [
474
- yield* raise({
475
- type: "error",
476
- message: `Prop "as" on <${name} /> requires a parent evaluation environment.`,
477
- source: name,
478
- }),
479
- ];
480
- }
481
- // Consumer boundary (spec §6.9): raise transported errors unconditionally
482
- // so documentation never captures and hides an error comment.
483
- for (const capturedSegment of expanded) {
484
- if (capturedSegment.type === "error") {
485
- yield* raise(capturedSegment);
486
- }
487
- }
488
- parentEnv.values[asBinding] = renderSegments(expanded);
489
- return [];
490
- }
491
- return expanded;
492
- }
493
- // ---------------------------------------------------------------------------
494
- // Function component expansion (spec §5.3)
495
- // ---------------------------------------------------------------------------
496
- /**
497
- * Expand a function component (.ts file).
498
- *
499
- * Function components are generator functions that return a rendered
500
- * string. They receive validated props, raw child segments, and an
501
- * `expandChildren` helper that renders children.
502
- */
503
- function* expandFunctionComponent(name, props, expressions, children, definition, hideSet, counter, projectedEnv) {
504
- if ("as" in expressions) {
505
- return [
506
- yield* raise({
507
- type: "error",
508
- message: `Prop "as" on <${name} /> must be a string literal.`,
509
- source: name,
510
- }),
511
- ];
512
- }
513
- // Resolve expression props
514
- let resolvedProps;
515
- try {
516
- resolvedProps = yield* resolveExpressionProps(props, expressions, name, projectedEnv);
517
- }
518
- catch (error) {
519
- return [
520
- yield* raise({
521
- type: "error",
522
- message: error instanceof Error ? error.message : String(error),
523
- source: name,
524
- }),
525
- ];
526
- }
527
- // Strip slot prop before validation
528
- const asBindingResult = validateBindingName(resolvedProps.as);
529
- if (!asBindingResult.ok) {
530
- return [
531
- yield* raise({
532
- type: "error",
533
- message: `Prop "as" on <${name} /> ${asBindingResult.error}`,
534
- source: name,
535
- }),
536
- ];
537
- }
538
- const asBinding = asBindingResult.value;
539
- const { slot: _slot, as: _as, ...propsForValidation } = resolvedProps;
540
- // Validate props
541
- let validatedProps;
542
- try {
543
- validatedProps = validateProps(name, propsForValidation, definition.inputs);
544
- }
545
- catch (error) {
546
- return [
547
- yield* raise({
548
- type: "error",
549
- message: error instanceof Error ? error.message : String(error),
550
- source: name,
551
- }),
552
- ];
553
- }
554
- const slots = partitionBySlot(children);
555
- // Call the function component with content middleware in scope so it can
556
- // render children via `yield* useContent()` / `useContent("slot")`.
557
- try {
558
- const output = yield* scoped(function* () {
559
- yield* Component.around({
560
- *content([slotName], _next) {
561
- if (slotName !== undefined) {
562
- const slotChildren = (slots.named.get(slotName) ?? []).map(stripSlotProp);
563
- if (slotChildren.length === 0) {
564
- return "";
565
- }
566
- const expanded = yield* expandSegments(slotChildren, {}, {}, hideSet, counter);
567
- return renderSegments(expanded);
568
- }
569
- const expanded = yield* expandSegments(slots.default, {}, {}, hideSet, counter);
570
- return renderSegments(expanded);
571
- },
572
- }, { at: "min" });
573
- return yield* definition.fn(validatedProps);
574
- });
575
- if (asBinding) {
576
- const parentEnv = yield* env;
577
- if (!parentEnv) {
578
- return [
579
- yield* raise({
580
- type: "error",
581
- message: `Prop "as" on <${name} /> requires a parent evaluation environment.`,
582
- source: name,
583
- }),
584
- ];
585
- }
586
- parentEnv.values[asBinding] = output;
587
- return [];
588
- }
589
- return [{ type: "text", content: output }];
590
- }
591
- catch (error) {
592
- // A DocumentationError from a content-rendering path (useContent) is
593
- // fail-fast — propagate it unchanged.
594
- if (error instanceof DocumentationError) {
595
- throw error;
596
- }
597
- return [
598
- yield* raise({
599
- type: "error",
600
- message: error instanceof Error
601
- ? `Function component ${name} error: ${error.message}`
602
- : `Function component ${name} error: ${String(error)}`,
603
- source: name,
604
- }),
605
- ];
606
- }
607
- }
608
- function validateBindingName(value) {
609
- if (value === undefined) {
610
- return { ok: true };
611
- }
612
- if (typeof value !== "string") {
613
- return { ok: false, error: "must be a non-empty string literal." };
614
- }
615
- if (value.length === 0) {
616
- return { ok: false, error: "must be non-empty." };
617
- }
618
- if (!IDENTIFIER_RE.test(value)) {
619
- return {
620
- ok: false,
621
- error: `must be a valid JavaScript identifier. Got: "${value}"`,
622
- };
623
- }
624
- return { ok: true, value };
625
- }
626
- // ---------------------------------------------------------------------------
627
- // Expression prop evaluation (spec §5.1)
628
- // ---------------------------------------------------------------------------
629
- /**
630
- * Resolve eval expression props against env.values using the shared VM
631
- * context. Merges resolved values into the props record.
632
- *
633
- * Expression props are stored as raw expression text in the
634
- * `expressions` field of `ComponentInvocation`. At expansion time,
635
- * they are evaluated as JavaScript using `new Function()` with
636
- * `env.values` destructured into scope.
637
- *
638
- * Errors are thrown (not ErrorSegments), consistent with PropValidationError.
639
- *
640
- * Uses `new Function()` instead of `node:vm` — Deno's permission model
641
- * provides the security boundary. The expression text comes from the
642
- * document author (trusted), and results must pass serialization check.
643
- */
644
- function* resolveExpressionProps(props, expressions, componentName, explicitEnv) {
645
- // Start with already-resolved props
646
- const resolved = { ...props };
647
- // Nothing to evaluate
648
- if (Object.keys(expressions).length === 0) {
649
- return resolved;
650
- }
651
- const contextEnv = yield* env;
652
- // For projected children (substituted via <Content />), merge the
653
- // caller's env (explicitEnv) with the wrapping component's env
654
- // (contextEnv). The component's env takes priority because its eval
655
- // blocks run before <Content /> and may define bindings that children
656
- // reference. The caller's env provides fallback bindings from the
657
- const evalEnv = explicitEnv && contextEnv
658
- ? { values: { ...explicitEnv.values, ...contextEnv.values } }
659
- : (contextEnv ?? explicitEnv);
660
- if (!evalEnv) {
661
- const names = Object.keys(expressions).join(", ");
662
- throw new Error(`Expression props (${names}) on <${componentName} /> cannot be ` +
663
- `resolved: no eval context available. Expression props require ` +
664
- `a preceding eval block that defines the referenced bindings.`);
665
- }
666
- const envKeys = Object.keys(evalEnv.values);
667
- const envValues = envKeys.map((k) => evalEnv.values[k]);
668
- for (const [propName, expression] of Object.entries(expressions)) {
669
- try {
670
- // Evaluate expression with env.values destructured into scope
671
- // via new Function() parameter injection.
672
- const fn = new Function(...envKeys, `return (${expression})`);
673
- const result = fn(...envValues);
674
- if (typeof result === "function" || typeof result === "undefined") {
675
- throw new Error(`Expression prop "${propName}" on <${componentName} /> evaluated ` +
676
- `to a non-serializable value (${typeof result}). Props must be ` +
677
- `JSON-serializable.`);
678
- }
679
- let serialized;
680
- try {
681
- serialized = JSON.parse(JSON.stringify(result));
682
- }
683
- catch {
684
- throw new Error(`Expression prop "${propName}" on <${componentName} /> evaluated ` +
685
- `to a non-serializable value (${typeof result}). Props must be ` +
686
- `JSON-serializable.`);
687
- }
688
- resolved[propName] = serialized;
689
- }
690
- catch (error) {
691
- if (error instanceof Error && error.message.includes("non-serializable")) {
692
- throw error;
693
- }
694
- throw new Error(`Failed to evaluate expression prop "${propName}={${expression}}" ` +
695
- `on <${componentName} />: ${error instanceof Error ? error.message : String(error)}`);
696
- }
697
- }
698
- return resolved;
699
- }
700
- // ---------------------------------------------------------------------------
701
- // Named slot support (spec §6.3)
702
- // ---------------------------------------------------------------------------
703
- /**
704
- * Slot name validation pattern: must start with a letter, followed by
705
- * letters, digits, underscores, or hyphens.
706
- */
707
- const SLOT_NAME_RE = /^[a-zA-Z][a-zA-Z0-9_-]*$/;
708
- /**
709
- * Validate a slot name. Returns an ErrorSegment if invalid, undefined if ok.
710
- */
711
- function validateSlotName(name, source) {
712
- if (name === "") {
713
- return {
714
- type: "error",
715
- message: "Invalid slot name: slot name must not be empty",
716
- source,
717
- };
718
- }
719
- if (!SLOT_NAME_RE.test(name)) {
720
- return {
721
- type: "error",
722
- message: `Invalid slot name "${name}": must match [a-zA-Z][a-zA-Z0-9_-]*`,
723
- source,
724
- };
725
- }
726
- return undefined;
727
- }
728
- /**
729
- * Slot assignment: returns the slot name if the segment is a component
730
- * invocation with a `slot` prop, undefined otherwise.
731
- *
732
- * Only ComponentInvocation segments can carry a `slot` prop. Text
733
- * segments and code blocks are always default-slot content.
734
- */
735
- function getSlotAssignment(segment) {
736
- if (segment.type === "component" && segment.props.slot !== undefined) {
737
- return String(segment.props.slot);
738
- }
739
- return undefined;
740
- }
741
- /**
742
- * Partition children into slot buckets. Only ComponentInvocation segments
743
- * with a `slot` prop are assigned to named slots. Everything else goes
744
- * to the default slot.
745
- *
746
- * Invalid slot names produce ErrorSegments in the `errors` array.
747
- */
748
- export function partitionBySlot(children) {
749
- const named = new Map();
750
- const defaultSlot = [];
751
- const errors = [];
752
- for (const child of children) {
753
- const slotName = getSlotAssignment(child);
754
- if (slotName !== undefined) {
755
- const error = validateSlotName(slotName, `slot="${slotName}"`);
756
- if (error) {
757
- errors.push(error);
758
- continue;
759
- }
760
- let bucket = named.get(slotName);
761
- if (!bucket) {
762
- bucket = [];
763
- named.set(slotName, bucket);
764
- }
765
- bucket.push(child);
766
- }
767
- else {
768
- defaultSlot.push(child);
769
- }
770
- }
771
- return { default: defaultSlot, named, errors };
772
- }
773
- /**
774
- * Strip the `slot` prop from a segment. Returns a shallow clone with
775
- * `slot` removed from props. Non-component segments pass through unchanged.
776
- */
777
- export function stripSlotProp(segment) {
778
- if (segment.type === "component" && "slot" in segment.props) {
779
- const { slot: _, ...rest } = segment.props;
780
- return { ...segment, props: rest };
781
- }
782
- return segment;
783
- }
784
- /**
785
- * Build the projection function that tags substituted children with the
786
- * caller's eval env so their expression props resolve in the caller's scope.
787
- */
788
- function makeProjectFn(callerEnv) {
789
- const project = (segments) => {
790
- if (!callerEnv) {
791
- return segments;
792
- }
793
- return segments.map((seg) => {
794
- if (seg.type === "component") {
795
- return {
796
- ...seg,
797
- projectedEnv: callerEnv,
798
- children: project(seg.children),
799
- };
800
- }
801
- return seg;
802
- });
803
- };
804
- return project;
805
- }
806
- /**
807
- * Replace `<Content />` / `<Content slot="X" />` in a segment list with the
808
- * caller's children (partitioned by slot) and interpolate {meta}/{props} in
809
- * text. Slot validation errors are emitted once, at the first projection
810
- * point, tracked via the shared `state`.
811
- */
812
- function substituteSegmentList(segments, slots, meta, props, project, state) {
813
- return segments.flatMap((segment) => {
814
- if (segment.type === "component" && segment.name === "Content") {
815
- const targetSlot = segment.props.slot;
816
- const pendingErrors = !state.errorsEmitted ? slots.errors : [];
817
- if (pendingErrors.length > 0) {
818
- state.errorsEmitted = true;
819
- }
820
- if (targetSlot !== undefined) {
821
- const slotKey = String(targetSlot);
822
- return [...pendingErrors, ...project((slots.named.get(slotKey) ?? []).map(stripSlotProp))];
823
- }
824
- return [...pendingErrors, ...project(slots.default)];
825
- }
826
- if (segment.type === "text") {
827
- return [{ ...segment, content: interpolate(segment.content, meta, props) }];
828
- }
829
- return [segment];
830
- });
831
- }
832
- /**
833
- * Replace `<Content />` and `<Content slot="X" />` invocations with the
834
- * caller's children, partitioned by slot assignment.
835
- * Also interpolates {meta.key} and {props.key} in text segments.
836
- *
837
- * When no `slot` props are present anywhere, this behaves identically
838
- * to the original single-slot substituteContent.
839
- */
840
- function substituteContent(bodySegments, children, meta, props, callerEnv) {
841
- const slots = partitionBySlot(children);
842
- const state = { errorsEmitted: false };
843
- const project = makeProjectFn(callerEnv);
844
- return substituteSegmentList(bodySegments, slots, meta, props, project, state);
845
- }
846
- function isTopLevelOutput(segment) {
847
- return segment.type === "component" && segment.name === "Output";
848
- }
849
- export function bodyHasOutput(bodySegments) {
850
- return bodySegments.some(isTopLevelOutput);
851
- }
852
- function misplacedOutputError() {
853
- return {
854
- type: "error",
855
- message: "<Output> must be a direct top-level child of the component or document " +
856
- "that declares it. For conditional rendering, use <Show> inside <Output>.",
857
- source: "Output",
858
- };
859
- }
860
- function previewOutput(segment) {
861
- const text = segment.children
862
- .filter((child) => child.type === "text")
863
- .map((child) => child.content)
864
- .join(" ")
865
- .replace(/\s+/g, " ")
866
- .trim();
867
- if (text.length === 0) {
868
- return "<Output> (empty)";
869
- }
870
- const clipped = text.slice(0, 40);
871
- return `<Output> containing "${clipped}${text.length > 40 ? "…" : ""}"`;
872
- }
873
- /**
874
- * Structural preflight (spec §6.9). Validates `<Output>` placement against the
875
- * body's own source AST. Only a direct top-level `<Output>` is a valid
876
- * declaration; any `<Output>` at depth > 0 — including inside unreachable or
877
- * discarded children — is a placement violation. All violations are combined
878
- * into a single aggregate ErrorSegment. Returns undefined when placement is
879
- * valid.
880
- */
881
- export function validateOutputPlacement(bodySegments) {
882
- const violations = [];
883
- const walk = (segments, depth) => {
884
- for (const segment of segments) {
885
- if (segment.type !== "component") {
886
- continue;
887
- }
888
- if (segment.name === "Output" && depth > 0) {
889
- violations.push(previewOutput(segment));
890
- }
891
- walk(segment.children, depth + 1);
892
- }
893
- };
894
- walk(bodySegments, 0);
895
- if (violations.length === 0) {
896
- return undefined;
897
- }
898
- const list = violations.map((entry) => ` - ${entry}`).join("\n");
899
- return {
900
- type: "error",
901
- message: "<Output> must be a direct top-level child of the component or document " +
902
- "that declares it. For conditional rendering, use <Show> inside " +
903
- `<Output>. Misplaced <Output> found:\n${list}`,
904
- source: "Output",
905
- };
906
- }
907
- function validateOutputProps(segment) {
908
- const hasProps = Object.keys(segment.props).length > 0;
909
- const hasExpressions = Object.keys(segment.expressions).length > 0;
910
- if (hasProps || hasExpressions) {
911
- return { type: "error", message: "<Output> accepts no props.", source: "Output" };
912
- }
913
- return undefined;
914
- }
915
- /**
916
- * Partition a definition body into ordered chunks (spec §6.9). Output policy
917
- * is determined by definition provenance — top-level `<Output>` segments in
918
- * the source, before `<Content />` substitution — so caller-projected
919
- * `<Output>` can neither activate nor alter it. `<Content />` inside a
920
- * top-level `<Output>` is substituted one level in; slot errors are emitted
921
- * once across the whole body via the shared substitution state.
922
- */
923
- function buildBody(bodySegments, children, meta, props, callerEnv) {
924
- const slots = partitionBySlot(children);
925
- const state = { errorsEmitted: false };
926
- const project = makeProjectFn(callerEnv);
927
- const chunks = [];
928
- for (const segment of bodySegments) {
929
- if (segment.type === "component" && segment.name === "Output") {
930
- const propsError = validateOutputProps(segment);
931
- if (propsError) {
932
- chunks.push({ output: true, segments: [propsError] });
933
- continue;
934
- }
935
- const outputSegments = substituteSegmentList(segment.children, slots, meta, props, project, state);
936
- chunks.push({ output: true, segments: outputSegments });
937
- continue;
938
- }
939
- const docSegments = substituteSegmentList([segment], slots, meta, props, project, state);
940
- chunks.push({ output: false, segments: docSegments });
941
- }
942
- return chunks;
943
- }
944
- /**
945
- * Expand a definition body (spec §6.9). Without a top-level `<Output>`, the
946
- * whole body renders (backward compatible). With `<Output>`, only the declared
947
- * regions render; documentation executes for its side effects under a throwing
948
- * raise policy (fail-fast) and its rendered result is discarded; output
949
- * regions install a collecting raise that shadows any inherited throwing
950
- * middleware (innermost min runs first), so their errors render as comments.
951
- * Regions and documentation run in document order, so output can depend on
952
- * bindings computed by preceding documentation.
953
- */
954
- export function* expandBody(bodySegments, children, meta, props, hideSet, counter, callerEnv) {
955
- if (!bodyHasOutput(bodySegments)) {
956
- const substituted = substituteContent(bodySegments, children, meta, props, callerEnv);
957
- return yield* expandSegments(substituted, meta, props, hideSet, counter);
958
- }
959
- const chunks = buildBody(bodySegments, children, meta, props, callerEnv);
960
- const output = [];
961
- for (const chunk of chunks) {
962
- if (chunk.output) {
963
- const expanded = yield* scoped(function* () {
964
- yield* Component.around({
965
- // deno-lint-ignore require-yield
966
- *raise([error], _next) {
967
- return error;
968
- },
969
- }, { at: "min" });
970
- return yield* expandSegments(chunk.segments, meta, props, hideSet, counter);
971
- });
972
- output.push(...expanded);
973
- }
974
- else {
975
- // Documentation: execute for side effects, discard rendered output.
976
- yield* scoped(function* () {
977
- yield* Component.around({
978
- // deno-lint-ignore require-yield
979
- *raise([error], _next) {
980
- throw new DocumentationError(error);
981
- },
982
- }, { at: "min" });
983
- return yield* expandSegments(chunk.segments, meta, props, hideSet, counter);
984
- });
985
- }
986
- }
987
- return output;
988
- }