@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/expand.js
DELETED
|
@@ -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
|
-
}
|