From 152a5e524f9eddb4a978a81e9f5de4ea4699a136 Mon Sep 17 00:00:00 2001 From: Taras Mankovski <74687+taras@users.noreply.github.com> Date: Sat, 18 Jul 2026 15:33:44 -0400 Subject: [PATCH 1/9] feat: component-declared output via (#88) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Components render their entire body, so documentation prose leaks into consumers. A component (or root) now declares its rendered region with a top-level boundary; everything outside is documentation that executes for side effects but never renders. - error-policy.ts: contextual ErrorPolicyCtx (collect/throw), raise, and DocumentationError for documentation fail-fast. - expand.ts: source-AST structural preflight (validateOutputPlacement), provenance-based buildBody/expandBody, misplaced- defensive branch, and consumer-boundary re-raise of transported errors. - run-document.ts: root buffers when it declares (emit once after success, none on failure, none when empty); streams otherwise. - spec §5.4/§6.1/§6.2 pseudocode, new §6.9, decision row 68, test-plan rows. - Tests: expand + run-document coverage; smoke Show.md/OutputDemo.md. --- AGENTS.md | 23 +- core/src/error-policy.ts | 55 +++ core/src/expand.ts | 579 +++++++++++++++++++++++--------- core/src/run-document.ts | 42 ++- core/tests/expand.test.ts | 271 +++++++++++++++ core/tests/run-document.test.ts | 124 +++++++ core/tests/smoke.test.ts | 8 + smoke-test/OutputDemo.md | 26 ++ smoke-test/README.md | 12 + smoke-test/Show.md | 16 + specs/executable-mdx-spec.md | 211 ++++++++++-- 11 files changed, 1179 insertions(+), 188 deletions(-) create mode 100644 core/src/error-policy.ts create mode 100644 smoke-test/OutputDemo.md create mode 100644 smoke-test/Show.md diff --git a/AGENTS.md b/AGENTS.md index ee46e49..1d7cb95 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -31,6 +31,8 @@ Do not commit if any check fails. Fix the issue first, then re-run all three. language in specifications or source code. 6. Parse to infer type; Do not type cast with `as`. 7. Do not use braceless `if` statements. +8. Keep the release spec current — changes to the release configuration + require changes to specs/release-process-spec.md to match. ## PR Process @@ -38,4 +40,23 @@ Do not commit if any check fails. Fix the issue first, then re-run all three. 2. After PR is open, monitor PR for 1. CI failures 2. Comments with feedback - 3. Integrate changes feedback appears \ No newline at end of file + 3. Integrate changes feedback appears + +## Agent Roles + +If you're an Opus model, you're an Implementor agent. +If you're a GPT model, you're an Planner agent. +If you're a Fabel model, you're a Problem solver agent. + +### Implementor agent + +Writes code following Code Rules. + +### Planner agent + +##### When reviewing Implementor agent's plans** + +**User will ask you**: Review ; verdict; prompt on failure. +**Respond by:** +* Interviewing user to resolve ambiguity; do not ask the Implementor agent to make decisions. +* Writing a feedback prompt that user will handoff to the Implementor agent diff --git a/core/src/error-policy.ts b/core/src/error-policy.ts new file mode 100644 index 0000000..23ec243 --- /dev/null +++ b/core/src/error-policy.ts @@ -0,0 +1,55 @@ +/** + * Contextual error policy for expansion (spec §6.9). + * + * Inside a rendered region — an `` region, or anywhere when no + * `` is declared — an ErrorSegment renders as an HTML comment + * ("collect"). While executing suppressed documentation, the first + * ErrorSegment produced throws immediately ("throw") so it propagates to be + * handled above. + */ + +import { createContext } from "effection"; +import type { Operation } from "effection"; +import type { ErrorSegment, Segment } from "./types.ts"; + +export type ErrorPolicy = "collect" | "throw"; + +/** + * Effection context holding the ambient error policy. Unset defaults to + * "collect" — today's behavior where ErrorSegments render as comments. + */ +export const ErrorPolicyCtx = createContext("errorPolicy"); + +/** + * Thrown when an ErrorSegment is produced or transported into a suppressed + * documentation region. Carries the offending segment so callers above can + * inspect it. + */ +export class DocumentationError extends Error { + readonly segment: ErrorSegment; + + constructor(segment: ErrorSegment) { + super(segment.message); + this.name = "DocumentationError"; + this.segment = segment; + } +} + +/** Read the ambient error policy, defaulting to "collect" when unset. */ +export function* currentErrorPolicy(): Operation { + const policy = yield* ErrorPolicyCtx.get(); + return policy ?? "collect"; +} + +/** + * Route an ErrorSegment through the ambient policy. Under "throw" it raises a + * DocumentationError (fail-fast); under "collect" it returns the segment so + * the caller renders it as a comment. + */ +export function* raise(segment: ErrorSegment): Operation { + const policy = yield* currentErrorPolicy(); + if (policy === "throw") { + throw new DocumentationError(segment); + } + return [segment]; +} diff --git a/core/src/expand.ts b/core/src/expand.ts index 96dfb5b..26c61b8 100644 --- a/core/src/expand.ts +++ b/core/src/expand.ts @@ -19,7 +19,9 @@ import { ContentCtx } from "./content-context.ts"; import type { ContentHandle } from "./content-context.ts"; import type { Segment, + TextSegment, ErrorSegment, + ComponentInvocation, ComponentDefinition, FunctionComponentDefinition, Json, @@ -31,6 +33,7 @@ import { interpolate } from "./interpolate.ts"; import { interpolateEvalBindings } from "./eval-interpolate.ts"; import { EvalEnvCtx, EvalScopeCtx } from "./eval-env.ts"; import type { EvalEnv } from "./eval-env.ts"; +import { ErrorPolicyCtx, DocumentationError, currentErrorPolicy, raise } from "./error-policy.ts"; import { useEvalScope, unbox } from "@effectionx/scope-eval"; import type { EvalScope } from "@effectionx/scope-eval"; import { validateProps } from "./validate.ts"; @@ -124,6 +127,15 @@ export function* expandSegments( } case "component": { + if (segment.name === "Output") { + // Definition-owned is consumed by buildBody before it + // reaches here. Reaching this branch means a misplaced or + // dynamically scanned (e.g. render(markdown) content) — + // diagnose it defensively per the ambient policy. + result.push(...(yield* raise(misplacedOutputError()))); + break; + } + if (segment.name === "Capture") { const captureResult = yield* expandCapture( segment, @@ -134,7 +146,7 @@ export function* expandSegments( counter, ); if (captureResult) { - result.push(captureResult); + result.push(...(yield* raise(captureResult))); } break; } @@ -149,7 +161,15 @@ export function* expandSegments( counter, segment.projectedEnv, ); - result.push(...expanded); + // Consumer boundary: re-raise transported error segments under the + // ambient policy before appending them (spec §6.9). + for (const expandedSegment of expanded) { + if (expandedSegment.type === "error") { + result.push(...(yield* raise(expandedSegment))); + } else { + result.push(expandedSegment); + } + } break; } @@ -183,11 +203,13 @@ export function* expandSegments( const codeResult = yield* ctx.runModifierChain(segment.modifiers, context); if (codeResult.exitCode !== 0 && codeResult.output === "") { - result.push({ - type: "error", - message: `Command failed (exit ${codeResult.exitCode}): ${codeResult.stderr}`, - source: segment.content, - }); + result.push( + ...(yield* raise({ + type: "error", + message: `Command failed (exit ${codeResult.exitCode}): ${codeResult.stderr}`, + source: segment.content, + })), + ); } else if (codeResult.output !== "") { result.push({ type: "execOutput", @@ -201,17 +223,31 @@ export function* expandSegments( } // If output is empty and exit code is 0, nothing added (e.g., silent) } catch (error) { - result.push({ - type: "error", - message: error instanceof Error ? error.message : String(error), - source: segment.content, - }); + // A DocumentationError from nested expansion (e.g. renderChildren + // inside an eval block) is our own fail-fast — never swallow it. + if (error instanceof DocumentationError) { + throw error; + } + result.push( + ...(yield* raise({ + type: "error", + message: error instanceof Error ? error.message : String(error), + source: segment.content, + })), + ); } break; } - default: - result.push(segment); + default: { + if (segment.type === "error") { + // Pre-existing error segments (e.g. slot/substitution errors) follow + // the ambient policy. + result.push(...(yield* raise(segment))); + } else { + result.push(segment); + } + } } } @@ -336,39 +372,33 @@ function* expandComponent( ): Operation { // Cycle detection — Prosser's algorithm if (hideSet.has(name)) { - return [ - { - type: "error", - message: `Cycle detected: ${name} is already being expanded (hide set: ${[...hideSet].join(" → ")})`, - source: name, - }, - ]; + return yield* raise({ + type: "error", + message: `Cycle detected: ${name} is already being expanded (hide set: ${[...hideSet].join(" → ")})`, + source: name, + }); } if (hideSet.size >= MAX_EXPANSION_DEPTH) { - return [ - { - type: "error", - message: `Maximum expansion depth (${MAX_EXPANSION_DEPTH}) exceeded`, - source: name, - }, - ]; + return yield* raise({ + type: "error", + message: `Maximum expansion depth (${MAX_EXPANSION_DEPTH}) exceeded`, + source: name, + }); } let imported: ComponentDefinition | FunctionComponentDefinition; try { imported = yield* ctx.importComponent(name); } catch (error) { - return [ - { - type: "error", - message: - error instanceof Error - ? `Failed to import component ${name}: ${error.message}` - : `Failed to import component ${name}: ${String(error)}`, - source: name, - }, - ]; + return yield* raise({ + type: "error", + message: + error instanceof Error + ? `Failed to import component ${name}: ${error.message}` + : `Failed to import component ${name}: ${String(error)}`, + source: name, + }); } // Function component: call the generator function directly @@ -388,6 +418,15 @@ function* expandComponent( const definition = imported; + // Structural preflight (spec §6.9): validate placement against the + // component's own source AST before any part of its body executes. A + // structurally invalid component runs no eval, exec, Capture, nested + // components, or other side effects. + const placementError = validateOutputPlacement(definition.bodySegments); + if (placementError) { + return yield* raise(placementError); + } + // Resolve eval expression props against env.values using the shared // VM context. This must happen before validation so that resolved // values can be type-checked. See spec §5.1 (expression prop evaluation). @@ -395,23 +434,19 @@ function* expandComponent( try { resolvedProps = yield* resolveExpressionProps(props, expressions, name, projectedEnv); } catch (error) { - return [ - { - type: "error", - message: error instanceof Error ? error.message : String(error), - source: name, - }, - ]; + return yield* raise({ + type: "error", + message: error instanceof Error ? error.message : String(error), + source: name, + }); } if ("as" in expressions) { - return [ - { - type: "error", - message: `Prop "as" on <${name} /> must be a string literal.`, - source: name, - }, - ]; + return yield* raise({ + type: "error", + message: `Prop "as" on <${name} /> must be a string literal.`, + source: name, + }); } // Validate props against declared inputs. @@ -429,13 +464,11 @@ function* expandComponent( const { slot: _slot, as: _as, ...propsForValidation } = resolvedProps; validatedProps = validateProps(name, propsForValidation, definition.inputs); } catch (error) { - return [ - { - type: "error", - message: error instanceof Error ? error.message : String(error), - source: name, - }, - ]; + return yield* raise({ + type: "error", + message: error instanceof Error ? error.message : String(error), + source: name, + }); } // Capture the caller's eval environment before creating the component's @@ -452,19 +485,6 @@ function* expandComponent( ? { values: { ...projectedEnv.values, ...(contextEnv?.values ?? {}) } } : contextEnv; - // Substitute raw children into positions. Children are NOT - // pre-expanded — they expand in document order when the component body - // is expanded. This ensures eval blocks before (e.g., - // provider middleware installation) run before children's code blocks. - // Children segments are tagged with callerEvalEnv so expression props - const substituted = substituteContent( - definition.bodySegments, - children, - definition.meta, - validatedProps, - callerEvalEnv ?? undefined, - ); - // Recurse with augmented hide set. // Each component gets its own fresh binding environment so that // eval blocks within a component share bindings but don't leak @@ -588,40 +608,49 @@ function* expandComponent( }); }; + // Expand the body through expandBody, which renders only the declared + // regions (executing documentation for side effects) when the + // definition declares output, and renders the whole body otherwise. const expanded = yield* EvalEnvCtx.with(componentEnv, function* () { if (childEvalScope) { return yield* EvalScopeCtx.with(childEvalScope, function* () { - return yield* expandSegments( - substituted, + return yield* expandBody( + definition.bodySegments, + children, definition.meta, validatedProps, newHideSet, ctx, counter, + callerEvalEnv ?? undefined, ); }); } - return yield* expandSegments( - substituted, + return yield* expandBody( + definition.bodySegments, + children, definition.meta, validatedProps, newHideSet, ctx, counter, + callerEvalEnv ?? undefined, ); }); if (asBinding) { const parentEnv = yield* EvalEnvCtx.get(); if (!parentEnv) { - return [ - { - type: "error", - message: `Prop "as" on <${name} /> requires a parent evaluation environment.`, - source: name, - }, - ]; + return yield* raise({ + type: "error", + message: `Prop "as" on <${name} /> requires a parent evaluation environment.`, + source: name, + }); } + // Consumer boundary (spec §6.9): re-raise transported errors under the + // ambient policy so documentation never captures and hides an error + // comment. + yield* consume(expanded); parentEnv.values[asBinding] = renderSegments(expanded); return []; } @@ -652,13 +681,11 @@ function* expandFunctionComponent( projectedEnv?: EvalEnv, ): Operation { if ("as" in expressions) { - return [ - { - type: "error", - message: `Prop "as" on <${name} /> must be a string literal.`, - source: name, - }, - ]; + return yield* raise({ + type: "error", + message: `Prop "as" on <${name} /> must be a string literal.`, + source: name, + }); } // Resolve expression props @@ -666,25 +693,21 @@ function* expandFunctionComponent( try { resolvedProps = yield* resolveExpressionProps(props, expressions, name, projectedEnv); } catch (error) { - return [ - { - type: "error", - message: error instanceof Error ? error.message : String(error), - source: name, - }, - ]; + return yield* raise({ + type: "error", + message: error instanceof Error ? error.message : String(error), + source: name, + }); } // Strip slot prop before validation const asBindingResult = validateBindingName(resolvedProps.as); if (!asBindingResult.ok) { - return [ - { - type: "error", - message: `Prop "as" on <${name} /> ${asBindingResult.error}`, - source: name, - }, - ]; + return yield* raise({ + type: "error", + message: `Prop "as" on <${name} /> ${asBindingResult.error}`, + source: name, + }); } const asBinding = asBindingResult.value; const { slot: _slot, as: _as, ...propsForValidation } = resolvedProps; @@ -694,13 +717,11 @@ function* expandFunctionComponent( try { validatedProps = validateProps(name, propsForValidation, definition.inputs); } catch (error) { - return [ - { - type: "error", - message: error instanceof Error ? error.message : String(error), - source: name, - }, - ]; + return yield* raise({ + type: "error", + message: error instanceof Error ? error.message : String(error), + source: name, + }); } // Set ContentCtx on the scope so the function component can @@ -730,29 +751,30 @@ function* expandFunctionComponent( if (asBinding) { const parentEnv = yield* EvalEnvCtx.get(); if (!parentEnv) { - return [ - { - type: "error", - message: `Prop "as" on <${name} /> requires a parent evaluation environment.`, - source: name, - }, - ]; + return yield* raise({ + type: "error", + message: `Prop "as" on <${name} /> requires a parent evaluation environment.`, + source: name, + }); } parentEnv.values[asBinding] = output; return []; } return [{ type: "text", content: output }]; } catch (error) { - return [ - { - type: "error", - message: - error instanceof Error - ? `Function component ${name} error: ${error.message}` - : `Function component ${name} error: ${String(error)}`, - source: name, - }, - ]; + // A DocumentationError from a content-rendering path (renderDefault / + // useContent) is fail-fast — propagate it unchanged. + if (error instanceof DocumentationError) { + throw error; + } + return yield* raise({ + type: "error", + message: + error instanceof Error + ? `Function component ${name} error: ${error.message}` + : `Function component ${name} error: ${String(error)}`, + source: name, + }); } } @@ -982,6 +1004,72 @@ export function stripSlotProp(segment: Segment): Segment { // Content slot substitution (spec §6.3) // --------------------------------------------------------------------------- +/** Projects the caller's eval env onto substituted children. */ +type ProjectFn = (segments: Segment[]) => Segment[]; + +/** Mutable flag so slot validation errors are emitted only once. */ +interface SubstitutionState { + errorsEmitted: boolean; +} + +/** + * Build the projection function that tags substituted children with the + * caller's eval env so their expression props resolve in the caller's scope. + */ +function makeProjectFn(callerEnv: EvalEnv | undefined): ProjectFn { + const project: ProjectFn = (segments) => { + if (!callerEnv) { + return segments; + } + return segments.map((seg) => { + if (seg.type === "component") { + return { + ...seg, + projectedEnv: callerEnv, + children: project(seg.children), + }; + } + return seg; + }); + }; + return project; +} + +/** + * Replace `` / `` in a segment list with the + * caller's children (partitioned by slot) and interpolate {meta}/{props} in + * text. Slot validation errors are emitted once, at the first projection + * point, tracked via the shared `state`. + */ +function substituteSegmentList( + segments: Segment[], + slots: SlotMap, + meta: Record, + props: Record, + project: ProjectFn, + state: SubstitutionState, +): Segment[] { + return segments.flatMap((segment) => { + if (segment.type === "component" && segment.name === "Content") { + const targetSlot = segment.props.slot; + const pendingErrors = !state.errorsEmitted ? slots.errors : []; + if (pendingErrors.length > 0) { + state.errorsEmitted = true; + } + + if (targetSlot !== undefined) { + const slotKey = String(targetSlot); + return [...pendingErrors, ...project((slots.named.get(slotKey) ?? []).map(stripSlotProp))]; + } + return [...pendingErrors, ...project(slots.default)]; + } + if (segment.type === "text") { + return [{ ...segment, content: interpolate(segment.content, meta, props) }]; + } + return [segment]; + }); +} + /** * Replace `` and `` invocations with the * caller's children, partitioned by slot assignment. @@ -998,49 +1086,210 @@ function substituteContent( callerEnv?: EvalEnv, ): Segment[] { const slots = partitionBySlot(children); - // Track whether errors have been emitted (only emit once) - let errorsEmitted = false; + const state: SubstitutionState = { errorsEmitted: false }; + const project = makeProjectFn(callerEnv); + return substituteSegmentList(bodySegments, slots, meta, props, project, state); +} - // Tag projected children with the caller's eval env so expression - const tagProjected = (segments: Segment[]): Segment[] => { - if (!callerEnv) return segments; - return segments.map((seg) => { - if (seg.type === "component") { - return { - ...seg, - projectedEnv: callerEnv, - children: tagProjected(seg.children), - }; +// --------------------------------------------------------------------------- +// Component-declared output: (spec §6.9) +// --------------------------------------------------------------------------- + +/** A body chunk produced by buildBody: a rendered output region or documentation. */ +interface BodyChunk { + output: boolean; + segments: Segment[]; +} + +/** True when a top-level segment is an `` declaration. */ +function isTopLevelOutput(segment: Segment): boolean { + return segment.type === "component" && segment.name === "Output"; +} + +/** True when the definition body declares at least one top-level ``. */ +export function bodyHasOutput(bodySegments: Segment[]): boolean { + return bodySegments.some(isTopLevelOutput); +} + +/** The diagnostic for a misplaced `` tag. */ +function misplacedOutputError(): ErrorSegment { + return { + type: "error", + message: + " must be a direct top-level child of the component or document " + + "that declares it. For conditional rendering, use inside .", + source: "Output", + }; +} + +/** A short, stable preview of an `` region for aggregate diagnostics. */ +function previewOutput(segment: ComponentInvocation): string { + const text = segment.children + .filter((child): child is TextSegment => child.type === "text") + .map((child) => child.content) + .join(" ") + .replace(/\s+/g, " ") + .trim(); + if (text.length === 0) { + return " (empty)"; + } + const clipped = text.slice(0, 40); + return ` containing "${clipped}${text.length > 40 ? "…" : ""}"`; +} + +/** + * Structural preflight (spec §6.9). Validates `` placement against the + * body's own source AST. Only a direct top-level `` is a valid + * declaration; any `` at depth > 0 — including inside unreachable or + * discarded children — is a placement violation. All violations are combined + * into a single aggregate ErrorSegment. Returns undefined when placement is + * valid. + */ +export function validateOutputPlacement(bodySegments: Segment[]): ErrorSegment | undefined { + const violations: string[] = []; + + const walk = (segments: Segment[], depth: number): void => { + for (const segment of segments) { + if (segment.type !== "component") { + continue; } - return seg; - }); + if (segment.name === "Output" && depth > 0) { + violations.push(previewOutput(segment)); + } + walk(segment.children, depth + 1); + } }; - return bodySegments.flatMap((segment) => { - if (segment.type === "component" && segment.name === "Content") { - const targetSlot = segment.props.slot as string | undefined; - // Emit slot validation errors at the first Content projection point - const pendingErrors = !errorsEmitted ? slots.errors : []; - if (pendingErrors.length > 0) errorsEmitted = true; + walk(bodySegments, 0); - if (targetSlot !== undefined) { - // Named slot projection — strip slot prop from each child - return [ - ...pendingErrors, - ...tagProjected((slots.named.get(targetSlot) ?? []).map(stripSlotProp)), - ]; + if (violations.length === 0) { + return undefined; + } + + const list = violations.map((entry) => ` - ${entry}`).join("\n"); + return { + type: "error", + message: + " must be a direct top-level child of the component or document " + + "that declares it. For conditional rendering, use inside " + + `. Misplaced found:\n${list}`, + source: "Output", + }; +} + +/** Validate that a top-level `` carries no props. */ +function validateOutputProps(segment: ComponentInvocation): ErrorSegment | undefined { + const hasProps = Object.keys(segment.props).length > 0; + const hasExpressions = Object.keys(segment.expressions).length > 0; + if (hasProps || hasExpressions) { + return { type: "error", message: " accepts no props.", source: "Output" }; + } + return undefined; +} + +/** + * Partition a definition body into ordered chunks (spec §6.9). Output policy + * is determined by definition provenance — top-level `` segments in + * the source, before `` substitution — so caller-projected + * `` can neither activate nor alter it. `` inside a + * top-level `` is substituted one level in; slot errors are emitted + * once across the whole body via the shared substitution state. + */ +function buildBody( + bodySegments: Segment[], + children: Segment[], + meta: Record, + props: Record, + callerEnv: EvalEnv | undefined, +): BodyChunk[] { + const slots = partitionBySlot(children); + const state: SubstitutionState = { errorsEmitted: false }; + const project = makeProjectFn(callerEnv); + const chunks: BodyChunk[] = []; + + for (const segment of bodySegments) { + if (segment.type === "component" && segment.name === "Output") { + const propsError = validateOutputProps(segment); + if (propsError) { + chunks.push({ output: true, segments: [propsError] }); + continue; } - // Default slot projection - return [...pendingErrors, ...tagProjected(slots.default)]; + const outputSegments = substituteSegmentList( + segment.children, + slots, + meta, + props, + project, + state, + ); + chunks.push({ output: true, segments: outputSegments }); + continue; } - if (segment.type === "text") { - return [ - { - ...segment, - content: interpolate(segment.content, meta, props), - }, - ]; + + const docSegments = substituteSegmentList([segment], slots, meta, props, project, state); + chunks.push({ output: false, segments: docSegments }); + } + + return chunks; +} + +/** + * Expand a definition body (spec §6.9). Without a top-level ``, the + * whole body renders (backward compatible). With ``, only the declared + * regions render; documentation executes for its side effects under a "throw" + * policy (fail-fast) and its rendered result is discarded; output regions run + * under "collect" (errors render as comments). Regions and documentation run + * in document order, so output can depend on bindings computed by preceding + * documentation. + */ +export function* expandBody( + bodySegments: Segment[], + children: Segment[], + meta: Record, + props: Record, + hideSet: Set, + ctx: ExpansionContext, + counter: BlockCounter, + callerEnv: EvalEnv | undefined, +): Operation { + if (!bodyHasOutput(bodySegments)) { + const substituted = substituteContent(bodySegments, children, meta, props, callerEnv); + return yield* expandSegments(substituted, meta, props, hideSet, ctx, counter); + } + + const chunks = buildBody(bodySegments, children, meta, props, callerEnv); + const output: Segment[] = []; + + for (const chunk of chunks) { + if (chunk.output) { + const expanded = yield* ErrorPolicyCtx.with("collect", function* () { + return yield* expandSegments(chunk.segments, meta, props, hideSet, ctx, counter); + }); + output.push(...expanded); + } else { + // Documentation: execute for side effects, discard rendered output. + yield* ErrorPolicyCtx.with("throw", function* () { + return yield* expandSegments(chunk.segments, meta, props, hideSet, ctx, counter); + }); } - return [segment]; - }); + } + + return output; +} + +/** + * Consumer boundary (spec §6.9). When consuming a component's returned + * segments in a documentation context, re-raise the first transported error + * so it is never captured and hidden. Under "collect" it is a no-op. + */ +function* consume(segments: Segment[]): Operation { + const policy = yield* currentErrorPolicy(); + if (policy !== "throw") { + return; + } + for (const segment of segments) { + if (segment.type === "error") { + throw new DocumentationError(segment); + } + } } diff --git a/core/src/run-document.ts b/core/src/run-document.ts index e8480a9..ace6af4 100644 --- a/core/src/run-document.ts +++ b/core/src/run-document.ts @@ -31,7 +31,13 @@ import type { } from "./types.ts"; import { scanSegments } from "./scanner.ts"; import { parseFrontmatter } from "./frontmatter.ts"; -import { expandSegments, createBlockCounter } from "./expand.ts"; +import { + expandSegments, + expandBody, + bodyHasOutput, + validateOutputPlacement, + createBlockCounter, +} from "./expand.ts"; import type { ExpansionContext } from "./expand.ts"; import { renderSegment } from "./render.ts"; import { DocumentOutput } from "./api.ts"; @@ -285,6 +291,40 @@ function* documentWorkflow( // The EvalEnvCtx.with() wraps the entire loop so all segments share // the same binding environment. const scopedExpansion: Operation = EvalEnvCtx.with(env, function* () { + // Structural preflight (spec §6.9): a root with misplaced + // executes no body side effects; the aggregate diagnostic renders as a + // comment (root policy is "collect"). + const placementError = validateOutputPlacement(root.bodySegments); + if (placementError) { + const text = renderSegment(placementError); + yield* ephemeral(DocumentOutput.operations.output(text)); + return text; + } + + // A root declaring top-level buffers completely (spec §5.4): + // execute the whole body, then emit the selected regions only after + // successful completion. A documentation failure throws before any emit, + // so no partial output is produced. + if (bodyHasOutput(root.bodySegments)) { + const expanded = yield* expandBody( + root.bodySegments, + [], + root.meta, + {}, + new Set(), + ctx, + counter, + undefined, + ); + const text = expanded.map(renderSegment).join(""); + // An empty buffered root emits no output event. + if (text) { + yield* ephemeral(DocumentOutput.operations.output(text)); + } + return text; + } + + // Per-root-segment emission loop for roots without (spec §5.4). const chunks: string[] = []; for (const segment of root.bodySegments) { diff --git a/core/tests/expand.test.ts b/core/tests/expand.test.ts index d41a8fe..7af6920 100644 --- a/core/tests/expand.test.ts +++ b/core/tests/expand.test.ts @@ -358,6 +358,277 @@ describe("expansion", () => { }); }); +// --------------------------------------------------------------------------- +// Component-declared output — (spec §6.9) +// --------------------------------------------------------------------------- + +/** ExpansionContext that records executed code-block contents. */ +function recordingCtx( + components: Record, + codeResult?: CodeBlockResult, +): { ctx: ExpansionContext; execCalls: string[] } { + const execCalls: string[] = []; + const ctx: ExpansionContext = { + importComponent: function* (name: string) { + const comp = components[name]; + if (!comp) throw new Error(`Component not found: ${name}`); + return comp; + }, + runModifierChain: function* (_modifiers: Modifier[], context: CodeBlockContext) { + execCalls.push(context.content); + return codeResult ?? { output: "ran\n", exitCode: 0, stderr: "" }; + }, + }; + return { ctx, execCalls }; +} + +describe("component-declared output", () => { + it("renders only the region, suppressing documentation", function* () { + const comp = makeComponent( + "Warn", + "Docs heading.\n\n\nSHOWN\n\n\nMore docs.\n", + ); + const ctx = makeCtx({ Warn: comp }); + const output = yield* expand(scanSegments(""), ctx); + expect(output).toContain("SHOWN"); + expect(output).not.toContain("Docs heading"); + expect(output).not.toContain("More docs"); + }); + + it("without renders the complete body", function* () { + const comp = makeComponent("Doc", "Alpha then Beta."); + const ctx = makeCtx({ Doc: comp }); + const output = yield* expand(scanSegments(""), ctx); + expect(output).toContain("Alpha then Beta."); + }); + + it("concatenates multiple regions in document order", function* () { + const comp = makeComponent( + "Multi", + "ONE\n\nmiddle docs\n\nTWO\n", + ); + const ctx = makeCtx({ Multi: comp }); + const output = yield* expand(scanSegments(""), ctx); + expect(output).not.toContain("middle docs"); + expect(output.indexOf("ONE")).toBeGreaterThanOrEqual(0); + expect(output.indexOf("ONE")).toBeLessThan(output.indexOf("TWO")); + }); + + it("preserves markdown source inside , including a GitHub admonition", function* () { + const comp = makeComponent( + "Adm", + "docs\n\n\n> [!WARNING]\n> Careful now.\n\n", + ); + const ctx = makeCtx({ Adm: comp }); + const output = yield* expand(scanSegments(""), ctx); + expect(output).toContain("> [!WARNING]"); + expect(output).toContain("> Careful now."); + expect(output).not.toContain("docs"); + }); + + it("treats and as equivalent empty output", function* () { + const selfClosing = makeComponent("A", "before\n\n\n\nafter"); + const paired = makeComponent("B", "before\n\n\n\nafter"); + const ctx = makeCtx({ A: selfClosing, B: paired }); + const a = yield* expand(scanSegments(""), ctx); + const b = yield* expand(scanSegments(""), ctx); + expect(a.trim()).toBe(""); + expect(b.trim()).toBe(""); + }); + + it("rejects props on ", function* () { + const comp = makeComponent("Bad", 'x'); + const ctx = makeCtx({ Bad: comp }); + const output = yield* expand(scanSegments(""), ctx); + expect(output).toContain("ERROR"); + expect(output).toContain("accepts no props"); + }); + + it("rejects expression props on ", function* () { + const comp = makeComponent("Bad", "y"); + const ctx = makeCtx({ Bad: comp }); + const output = yield* expand(scanSegments(""), ctx); + expect(output).toContain("ERROR"); + expect(output).toContain("accepts no props"); + }); + + it("projects caller content through inside ", function* () { + const comp = makeComponent("Wrap", "docs\n\n\n\n\n"); + const ctx = makeCtx({ Wrap: comp }); + const output = yield* expand(scanSegments("PROJECTED"), ctx); + expect(output).toContain("PROJECTED"); + expect(output).not.toContain("docs"); + }); + + it("lets an region read a binding recorded by preceding documentation", function* () { + const comp = makeComponent( + "Dep", + 'HELLO\n\nmsg={msg}', + ); + const ctx = makeCtx({ Dep: comp }); + const output = yield* expand(scanSegments(""), ctx); + expect(output).toContain("msg=HELLO"); + }); + + it("executes exec blocks outside but suppresses their output", function* () { + const comp = makeComponent("Ex", "```bash exec\nDOCRUN\n```\n\nok\n"); + const { ctx, execCalls } = recordingCtx({ Ex: comp }); + const output = yield* expand(scanSegments(""), ctx); + expect(execCalls.some((c) => c.includes("DOCRUN"))).toBe(true); + expect(output).toContain("ok"); + expect(output).not.toContain("ran"); + }); + + it("executes documentation after an region", function* () { + const comp = makeComponent("Post", "ok\n\n```bash exec\nAFTER\n```\n"); + const { ctx, execCalls } = recordingCtx({ Post: comp }); + const output = yield* expand(scanSegments(""), ctx); + expect(execCalls.some((c) => c.includes("AFTER"))).toBe(true); + expect(output).toContain("ok"); + }); + + it("keeps errors inside an region as comments", function* () { + const comp = makeComponent("Err", "\n\n"); + const ctx = makeCtx({ Err: comp }); + const output = yield* expand(scanSegments(""), ctx); + expect(output).toContain("` comment (`raise` under `"collect"`). While executing +documentation, the first `ErrorSegment` **throws immediately** (`raise` under +`"throw"`, a `DocumentationError`) so it propagates to be handled above. Output +regions reset the policy to `"collect"`; documentation sets it to `"throw"`. +The modifier chain runs first (middleware may normalize a failure); the +existing result classification then decides whether an `ErrorSegment` results — +there is no separate raw `exitCode` policy. + +Because a nested component can produce an `ErrorSegment` inside its **own** +valid `` under `"collect"` and return it to a suppressed-documentation +caller, every site that **consumes** a component's returned segments re-applies +the ambient policy: the `expandSegments` component case and markdown `as=` +capture route transported error segments through `raise`/`consume`, and the +`renderChildren()` / `render()` / `useContent()` / function-component paths run +under the ambient policy so transported errors re-raise before being flattened +to strings. Producing an error inside a component's own Output (a comment) is +thus distinct from consuming it from a parent documentation context (a throw). + --- ## 7. Entry point @@ -3799,6 +3953,18 @@ visible warning blocks, collect into a separate error report). | C29 | `` CSS extraction | `` with code fence child stores code block value only | | C30 | `` fallback | `select="code[lang=json]"` with no matching node stores full rendered content | | C31 | `` paragraph | `select="paragraph"` extracts paragraph text content | +| C32 | `` selects region | Only the `` region renders; documentation outside is suppressed | +| C33 | No `` | Whole body renders (backward compatible) | +| C34 | Documentation executes | eval/exec/`` outside `` run; a later `` reads their bindings; documentation after a region still runs | +| C35 | Multiple `` regions | Concatenate in document order | +| C36 | Markdown preserved in `` | A `> [!WARNING]` admonition survives intact | +| C37 | Empty-tag parity | `` and `` both contribute no content | +| C38 | `` props rejected | Props/expression props on `` produce an ErrorSegment | +| C39 | `` in `` | Caller content projects into a top-level `` region | +| C40 | `as=` captures selected output | A component invoked with `as=` captures only its `` regions; documentation is neither rendered nor captured | +| C41 | Structural placement | Nested/misplaced `` (including inside `` or a content-discarding component) produces one aggregate diagnostic and runs no body side effects | +| C42 | Caller-projected `` inert | Projecting `` through `` neither activates nor alters the callee's policy | +| C43 | Documentation fail-fast | A failure in documentation (direct, inside ``, inside a nested component, or a transported error) throws; a modifier-handled failure continues; errors inside `` or with no `` remain comments | ### Tier D — Code execution and modifier middleware @@ -3839,6 +4005,9 @@ visible warning blocks, collect into a separate error report). | E8 | `silent exec` in full document | Command runs, result journaled, output omitted | | E9 | `sample exec` in full document | Command + LLM both journaled, LLM response in output | | E10 | Unclosed bold across component boundary | `**text\n\nmore` → healed bold in first segment, component expanded, `more` unaffected | +| E11 | `` component vs. root consistency | An imported component and a root document apply `` identically; documentation is suppressed in both | +| E12 | Root `` buffering | A root with `` emits once after success; a later documentation failure yields no partial output; an empty selection emits no event; replay reproduces the result | +| E13 | `` inside `` (smoke) | `smoke-test/OutputDemo.md` renders the conditionally-selected region (its `when` binding computed by preceding documentation eval) while its documentation prose does not appear | ### Tier F — Markdown healing (remend) @@ -4261,7 +4430,7 @@ must preserve the trace for diagnosis or remove it before starting a new run. | 65 | Whitespace normalization is middleware, not post-processing | Stateful across calls; composes with other middleware; can be disabled via `--raw`; mutable closure state scoped per `useNormalizedOutput()` call | | 66 | Terminal formatting is middleware, not a separate renderer | Composes with normalization; conditional on TTY; disabled for piped output; uses `marked-terminal` with `async: false` | | 67 | Channel-based delivery, not direct `process.stdout.write` | Decouples production from consumption; enables buffered collection for piped output; consumer task lifetime tied to document run scope; `channel.close()` in `finally` block guarantees consumer exits cleanly | -| 68 | Per-root-segment emission, not full-document batch | Enables streaming UX; root segments are sequential and independent; component-internal expansion remains recursive and buffered; `documentWorkflow` returns `""` — output already emitted via Api | +| 68 | Per-root-segment emission for roots without ``; full buffering for roots that declare it | Streaming UX for the common case — root segments are sequential and independent, and component-internal expansion is recursive and buffered. A root declaring top-level `` (§6.9) buffers completely and emits the selected regions only after successful expansion, so a later documentation failure yields no partial output; an empty selection emits nothing | | 69 | `blockId` counter threaded through expansion context | Per-segment expansion resets `result.length`; mutable counter preserves unique diagnostic IDs; counter guarded by expansion scope cancellation | | 70 | `output()` wrapped in `ephemeral()` | Output emission is a non-durable side effect; journal records durable effects only; output text is derived from journaled expansion results; all middleware/side effects execute on the ephemeral side | | 71 | Middleware installation order: normalize outer, terminal inner, channel innermost | `scope.around` later-installed handlers wrap earlier ones; execution flows outer → inner: normalize → terminal → channel; install order is reverse of execution order; must be documented to prevent reordering | From e47ac3f991594fc924d420cc659898d583bfea9e Mon Sep 17 00:00:00 2001 From: Taras Mankovski <74687+taras@users.noreply.github.com> Date: Sat, 18 Jul 2026 15:52:18 -0400 Subject: [PATCH 2/9] =?UTF-8?q?review:=20fix=20=C2=A76.9=20nested=20fences?= =?UTF-8?q?;=20trim=20restating=20comments?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - spec §6.9: use a four-backtick outer markdown fence so the inner ```ts eval block renders inside one code block. - expand.ts: drop JSDoc/comments that merely restate names/types/control flow (per AGENTS.md rule 4); keep the provenance and transported-error rationale. --- core/src/expand.ts | 11 +---------- specs/executable-mdx-spec.md | 4 ++-- 2 files changed, 3 insertions(+), 12 deletions(-) diff --git a/core/src/expand.ts b/core/src/expand.ts index 26c61b8..124f38b 100644 --- a/core/src/expand.ts +++ b/core/src/expand.ts @@ -608,9 +608,6 @@ function* expandComponent( }); }; - // Expand the body through expandBody, which renders only the declared - // regions (executing documentation for side effects) when the - // definition declares output, and renders the whole body otherwise. const expanded = yield* EvalEnvCtx.with(componentEnv, function* () { if (childEvalScope) { return yield* EvalScopeCtx.with(childEvalScope, function* () { @@ -1004,7 +1001,6 @@ export function stripSlotProp(segment: Segment): Segment { // Content slot substitution (spec §6.3) // --------------------------------------------------------------------------- -/** Projects the caller's eval env onto substituted children. */ type ProjectFn = (segments: Segment[]) => Segment[]; /** Mutable flag so slot validation errors are emitted only once. */ @@ -1095,23 +1091,20 @@ function substituteContent( // Component-declared output: (spec §6.9) // --------------------------------------------------------------------------- -/** A body chunk produced by buildBody: a rendered output region or documentation. */ interface BodyChunk { + /** true = a rendered `` region; false = documentation (executed, not rendered). */ output: boolean; segments: Segment[]; } -/** True when a top-level segment is an `` declaration. */ function isTopLevelOutput(segment: Segment): boolean { return segment.type === "component" && segment.name === "Output"; } -/** True when the definition body declares at least one top-level ``. */ export function bodyHasOutput(bodySegments: Segment[]): boolean { return bodySegments.some(isTopLevelOutput); } -/** The diagnostic for a misplaced `` tag. */ function misplacedOutputError(): ErrorSegment { return { type: "error", @@ -1122,7 +1115,6 @@ function misplacedOutputError(): ErrorSegment { }; } -/** A short, stable preview of an `` region for aggregate diagnostics. */ function previewOutput(segment: ComponentInvocation): string { const text = segment.children .filter((child): child is TextSegment => child.type === "text") @@ -1177,7 +1169,6 @@ export function validateOutputPlacement(bodySegments: Segment[]): ErrorSegment | }; } -/** Validate that a top-level `` carries no props. */ function validateOutputProps(segment: ComponentInvocation): ErrorSegment | undefined { const hasProps = Object.keys(segment.props).length > 0; const hasExpressions = Object.keys(segment.expressions).length > 0; diff --git a/specs/executable-mdx-spec.md b/specs/executable-mdx-spec.md index 5f7ab8b..633b4d2 100644 --- a/specs/executable-mdx-spec.md +++ b/specs/executable-mdx-spec.md @@ -3232,7 +3232,7 @@ run, `` populates bindings, nested components run — but its rendered result never reaches the consumer. Without ``, the whole body renders, so existing components are unaffected. -```markdown +````markdown # Release Config Files The following files participate in the release process. (Documentation — it @@ -3256,7 +3256,7 @@ const releaseChanged = files.filter((p) => releaseConfigFiles.includes(`- ${p}`) -``` +```` #### Placement is a source-level structural rule From 92e35b56de92e3ad08f4d51aa728ca9e0799dd08 Mon Sep 17 00:00:00 2001 From: Taras Mankovski <74687+taras@users.noreply.github.com> Date: Sat, 18 Jul 2026 16:29:24 -0400 Subject: [PATCH 3/9] review: spec describes behavior, not implementation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Revert the source-shaped additions to the documentWorkflow, expandSegments, and expandComponent pseudocode blocks, and rewrite §6.9 to state observable behavior without private symbols (validateOutputPlacement, buildBody, expandBody, ErrorPolicyCtx, raise, consume, DocumentationError) or internal control flow. Keep the authoring example, the normative prose (placement, definition-owned structure, execution order, error behavior, root buffering, consistency, multiple regions, as= capture, empty-tag parity), and the C32–C43 / E11–E13 conformance cases. --- deno.lock | 2 + specs/executable-mdx-spec.md | 207 +++++++++++++---------------------- 2 files changed, 80 insertions(+), 129 deletions(-) diff --git a/deno.lock b/deno.lock index fd8c702..358048b 100644 --- a/deno.lock +++ b/deno.lock @@ -74,8 +74,10 @@ "npm:marked@^17.0.4": "17.0.4", "npm:mdast-util-to-string@4": "4.0.0", "npm:oxfmt@0.41": "0.41.0", + "npm:oxfmt@0.41.0": "0.41.0", "npm:oxlint-tsgolint@0.17": "0.17.4", "npm:oxlint@1": "1.56.0_oxlint-tsgolint@0.17.4", + "npm:oxlint@1.56.0": "1.56.0_oxlint-tsgolint@0.17.4", "npm:preact-render-to-string@^6.6.3": "6.7.0_preact@10.29.7", "npm:preact@^10.28.2": "10.29.7_preact-render-to-string@6.7.0", "npm:preact@^10.29.1": "10.29.7_preact-render-to-string@6.7.0", diff --git a/specs/executable-mdx-spec.md b/specs/executable-mdx-spec.md index 633b4d2..7b5ec25 100644 --- a/specs/executable-mdx-spec.md +++ b/specs/executable-mdx-spec.md @@ -1961,30 +1961,6 @@ function* documentWorkflow(docPath: string): Workflow { // per-segment expansion calls (see §6.1 for details). const counter = createBlockCounter(); - // Structural preflight (§6.9): a misplaced aborts before any body - // side effects; the aggregate diagnostic renders as a comment. - const placementError = validateOutputPlacement(root.bodySegments); - if (placementError) { - const text = renderSegment(placementError); - yield* ephemeral(output(text)); - return text; - } - - // A root declaring top-level buffers completely: execute the whole - // body, then emit the selected regions only after success. A documentation - // failure throws before any emit, so no partial output is produced. An empty - // buffered root emits no output event. - if (bodyHasOutput(root.bodySegments)) { - const expanded = yield* expandBody( - root.bodySegments, [], root.meta, {}, new Set(), ctx, counter, undefined, - ); - const text = expanded.map(renderSegment).join(""); - if (text) { - yield* ephemeral(output(text)); - } - return text; - } - // Per-root-segment emission loop — each root segment is expanded // independently and its output emitted through the Document Output Api (§9). // Root segments are sequential and independent in document order. @@ -2079,15 +2055,6 @@ function* expandSegments( } case "component": { - if (segment.name === "Output") { - // Definition-owned is consumed by buildBody before it - // reaches here (§6.9). Reaching this branch means a misplaced or - // dynamically scanned — diagnose it under the ambient - // error policy via raise(). - result.push(...(yield* raise(misplacedOutputError()))); - break; - } - if (segment.name === "Capture") { const asBinding = segment.props.as; if (typeof asBinding !== "string" || asBinding.length === 0) { @@ -2145,15 +2112,7 @@ function* expandSegments( segment.children, hideSet, ); - // Consumer boundary (§6.9): re-raise transported error segments under - // the ambient policy so documentation never appends a hidden error. - for (const s of expanded) { - if (s.type === "error") { - result.push(...(yield* raise(s))); - } else { - result.push(s); - } - } + result.push(...expanded); break; } @@ -2178,18 +2137,14 @@ function* expandSegments( const chain = composeModifierChain( segment.modifiers, context, registry, ); - // The modifier chain (middleware) runs first and gets the first - // opportunity to handle or normalize a failure. The result is then - // classified under the existing rules; a resulting error follows the - // ambient error policy via raise() (§6.9) — no separate exitCode path. const codeResult = yield* chain(); if (codeResult.exitCode !== 0 && codeResult.output === "") { - result.push(...(yield* raise({ + result.push({ type: "error", message: `Command failed (exit ${codeResult.exitCode}): ${codeResult.stderr}`, source: segment.content, - }))); + }); } else if (codeResult.output !== "") { result.push({ type: "execOutput", @@ -2248,15 +2203,6 @@ function* expandComponent( // Import — single durable effect (resolve + read) const definition = yield* durableImportComponent(name); - // Structural preflight (§6.9): validate placement against the - // component's own source AST before any body side effects run. A - // structurally invalid component executes no eval, exec, Capture, or nested - // components — the aggregate diagnostic follows the ambient policy. - const placementError = validateOutputPlacement(definition.bodySegments); - if (placementError) { - return yield* raise(placementError); - } - // Extract reserved props before validation. // `slot` and `as` are consumed by the expansion engine. const asBinding = props.as; @@ -2273,6 +2219,21 @@ function* expandComponent( ? { values: { ...projectedEnv.values, ...(contextEnv?.values ?? {}) } } : contextEnv; + // Substitute raw children into positions. + // Children are NOT pre-expanded — they expand in document order + // when the substituted body is expanded. This ensures code blocks + // before (e.g., provider middleware installation) run + // before children's code blocks. + // Children segments are tagged with callerEvalEnv so expression + // props resolve in the caller's lexical scope (DEC-91). + const substituted = substituteContent( + definition.bodySegments, + children, + definition.meta, + validatedProps, + callerEvalEnv ?? undefined, + ); + // Recurse with augmented hide set. // Each component gets its own fresh binding environment so that // eval blocks within a component share bindings but don't leak @@ -2291,22 +2252,14 @@ function* expandComponent( componentEnv.values.renderChildren = function* () { /* ... */ }; componentEnv.values.render = function* (markdown) { /* ... */ }; - // Expand the body via expandBody (§6.9): it substitutes , then - // renders only the declared regions (executing documentation for - // its side effects) when the definition declares output, or renders the - // whole body otherwise. const expanded = yield* EvalEnvCtx.with( componentEnv, function* () { - return yield* expandBody( - definition.bodySegments, - children, + return yield* expandSegments( + substituted, definition.meta, validatedProps, newHideSet, - ctx, - counter, - callerEvalEnv ?? undefined, ); }, ); @@ -2325,9 +2278,6 @@ function* expandComponent( ]); } - // Consumer boundary (§6.9): re-raise transported errors under the ambient - // policy so documentation never captures and hides an error comment. - yield* consume(expanded); const parentEnv = yield* ephemeral(EvalEnvCtx.expect()); parentEnv.values[asBinding] = renderSegments(expanded); return []; @@ -3258,67 +3208,66 @@ const releaseChanged = files.filter((p) => releaseConfigFiles.includes(`- ${p}`) ```` -#### Placement is a source-level structural rule - -Only a **direct top-level** `` node is a valid declaration. -`validateOutputPlacement` traverses the component's own parsed source AST — -including unreachable or discarded children (inside ``, -passed to a component that has no ``, nested inside another -``, or enclosed by anything that declines to render its children) — and -reports every `` at depth > 0. All violations combine into **one -aggregate `ErrorSegment`** advising that `` must be a direct top-level -declaration and that conditional rendering uses `` inside ``. - -Structural validation runs **before** any body execution. A structurally -invalid component or root executes no eval, exec, ``, nested -components, or other side effects. Placement is owned by the declaring -component: child expansion cannot introduce, remove, promote, repair, or -redefine it, and a caller-projected `` (a depth > 0 node in the -caller's own AST) is diagnosed in the caller and never becomes the callee's +#### Placement + +Only a **direct top-level** `` is a valid declaration. Placement is +checked against the component's (or root's) source structure, including regions +that never render — content inside ``, content passed to a +component that has no ``, an `` nested inside another +``, or the children of any component that declines to render them. An +`` anywhere other than the top level is misplaced; all misplaced +occurrences in a single component are reported together as one diagnostic that +advises `` must be a direct top-level declaration and that conditional +rendering uses `` inside ``. + +Placement is owned by the declaring component. Child expansion cannot +introduce, remove, or redefine it, and an `` a caller passes as content +is diagnosed against the caller's own structure — it never becomes the callee's declaration. -#### Provenance and rendering - -Output policy is determined from the **definition body**, before `` -substitution — never from the post-substitution array. `buildBody` walks the -definition's top level in document order and produces ordered chunks: each -top-level `` becomes a rendered region (its children with `` -substituted one level in — `` belonging to unrelated nested -components is untouched); everything else is documentation. `` accepts -no props. `` and `` are equivalent and contribute no -content. Multiple regions concatenate in document order. A component invoked -with `as="binding"` captures only its selected output regions; its -documentation executes but is neither rendered nor captured. - -The root document obeys the same rules (§5.4). A root declaring top-level -`` is **fully buffered**: it executes to completion and emits the -selected regions only after success — a documentation failure produces no -partial output, and an empty selection emits no output event. Roots without -`` keep per-segment streaming. Buffering defers only the `output()` -emission, not the durable execution, so replay is deterministic. - -#### Contextual error policy - -An `ErrorPolicy` (`"collect"` or `"throw"`) threads through expansion via -`ErrorPolicyCtx` (unset defaults to `"collect"`). Inside a rendered region, and -everywhere when no `` is declared, an `ErrorSegment` renders as an -`` comment (`raise` under `"collect"`). While executing -documentation, the first `ErrorSegment` **throws immediately** (`raise` under -`"throw"`, a `DocumentationError`) so it propagates to be handled above. Output -regions reset the policy to `"collect"`; documentation sets it to `"throw"`. -The modifier chain runs first (middleware may normalize a failure); the -existing result classification then decides whether an `ErrorSegment` results — -there is no separate raw `exitCode` policy. - -Because a nested component can produce an `ErrorSegment` inside its **own** -valid `` under `"collect"` and return it to a suppressed-documentation -caller, every site that **consumes** a component's returned segments re-applies -the ambient policy: the `expandSegments` component case and markdown `as=` -capture route transported error segments through `raise`/`consume`, and the -`renderChildren()` / `render()` / `useContent()` / function-component paths run -under the ambient policy so transported errors re-raise before being flattened -to strings. Producing an error inside a component's own Output (a comment) is -thus distinct from consuming it from a parent documentation context (a throw). +#### Definition-owned structure and rendering + +A component's output regions are fixed by its own source, independent of the +content a caller projects through ``, so caller content can neither +add an output region nor suppress the declared body. `` inside a +top-level `` projects the caller's content into that region. +`` accepts no props. `` and `` are +equivalent and contribute no rendered content. Multiple top-level `` +regions render in document order and concatenate. A component invoked with +`as="binding"` captures only its selected output; its documentation executes +but is neither rendered nor captured. + +#### Execution order and error behavior + +Documentation and output regions execute in document order, so an output region +can use bindings a preceding documentation block computed, and documentation +after a region still runs. The required sequencing: + +- Structural placement is validated before any body content executes; a + structurally invalid component or root runs no eval, exec, ``, or + nested components and produces only the diagnostic. +- Documentation and output regions execute in document order. +- The first error produced while executing documentation stops that body's + execution immediately and propagates to the caller. +- An error produced while rendering an output region — or anywhere in a body + that declares no `` — retains normal `ErrorSegment` rendering (an + `` comment). +- A root containing `` emits its selected output only after the whole + body completes successfully; a documentation failure yields no partial + output, and an empty selection emits nothing. + +An error a nested component renders inside its own output region is a normal +comment when that component renders normally; but when that component is +executed as a parent's documentation, the parent's documentation fail-fast +applies and the error propagates rather than being hidden. + +#### Root and component consistency + +A root document obeys exactly the same rules as an imported component (§5.4). +Because selecting output requires the whole body, a root that declares +`` is buffered — executed to completion, then emitted once on success — +while a root without `` keeps per-segment streaming. Buffering defers +only when output is emitted, not what executes, so replay is deterministic. --- From 99037e49e5622683e2f026f751d046ba9488e489 Mon Sep 17 00:00:00 2001 From: Taras Mankovski <74687+taras@users.noreply.github.com> Date: Sat, 18 Jul 2026 16:37:44 -0400 Subject: [PATCH 4/9] review: replace Output-affected reference blocks with behavioral prose MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The documentWorkflow (§5.4), segment-expansion (§6.1), and component-expansion (§6.2) pseudocode blocks were implementation snapshots. Remove them and state the externally significant behavior as normative prose: streaming vs. buffered root emission, per-segment rewriting and error rendering, and cycle/depth guards + placement-before-execution + as= capture. Cross-reference the dedicated sections (§6.3 slots, §6.5 prop validation, §6.9 ) instead of duplicating them. Keep the authoring example and the C32–C43 / E11–E13 cases. --- specs/executable-mdx-spec.md | 341 +++++------------------------------ 1 file changed, 50 insertions(+), 291 deletions(-) diff --git a/specs/executable-mdx-spec.md b/specs/executable-mdx-spec.md index 7b5ec25..f7292a0 100644 --- a/specs/executable-mdx-spec.md +++ b/specs/executable-mdx-spec.md @@ -1944,50 +1944,16 @@ The entry point treats the root document through the same import pipeline as any component. This gives it uniform resolution, parsing, and error handling. -The root obeys the same `` rules as any component (§6.9): it is -structurally validated before execution, and a root declaring a top-level -`` renders only its declared regions. Because output selection requires -the whole body, a root with `` is **fully buffered** — it executes to -completion and emits only after success; a root without `` keeps the -per-segment streaming path. - -```typescript -function* documentWorkflow(docPath: string): Workflow { - // Import root — same pipeline as any component. - // The host installs Resolve middleware that maps "__root__" → docPath - const root = yield* durableImportComponent("__root__"); - - // Mutable counter preserves deterministic blockIds across - // per-segment expansion calls (see §6.1 for details). - const counter = createBlockCounter(); - - // Per-root-segment emission loop — each root segment is expanded - // independently and its output emitted through the Document Output Api (§9). - // Root segments are sequential and independent in document order. - // Component-internal expansion remains recursive and buffered. - for (const segment of root.bodySegments) { - const expanded = yield* expandSegments( - [segment], - root.meta, - {}, // No props for root - new Set(), // Empty hide set - ctx, - counter, - ); - - for (const resolved of expanded) { - const text = renderSegment(resolved); - if (text) { - yield* ephemeral(output(text)); - } - } - } - - // Return value is empty — output has already been emitted - // incrementally via the Output Api. - return ""; -} -``` +The root obeys the same `` rules as any component (§6.9), and how its +output is emitted depends on whether it declares one. Without ``, the +root's top-level segments are expanded in document order and each segment's +rendered text is emitted incrementally through the Document Output Api (§9) as +it is produced. With ``, the whole body is expanded before anything is +emitted; only the selected content is emitted, and only after the body has +completed successfully — a failure while executing documentation produces no +emission, and an empty selection emits nothing. A component invoked within the +root expands recursively and its result is buffered into the surrounding +output in both cases. --- @@ -2032,260 +1998,53 @@ increments occur, preventing state leaks on abort. #### Algorithm -```typescript -function* expandSegments( - segments: Segment[], - parentMeta: Record, - parentProps: Record, - hideSet: Set, - ctx: ExpansionContext, - counter: BlockCounter, -): Workflow { - const result: Segment[] = []; - - for (const segment of segments) { - switch (segment.type) { - case "text": { - // Heal incomplete markdown constructs at segment boundaries - const healed = healSegment(segment.content); - // Interpolate {meta.key} and {props.key} — runtime, no journal - const interpolated = interpolate(healed, parentMeta, parentProps); - result.push({ type: "text", content: interpolated }); - break; - } +Segment expansion rewrites a list of segments into rendered segments, in +document order: - case "component": { - if (segment.name === "Capture") { - const asBinding = segment.props.as; - if (typeof asBinding !== "string" || asBinding.length === 0) { - result.push({ - type: "error", - message: ' requires an "as" prop (non-empty string).', - source: "Capture", - }); - break; - } - if (!isValidIdentifier(asBinding)) { - result.push({ - type: "error", - message: `: as must be a valid JavaScript identifier.`, - source: "Capture", - }); - break; - } - if (segment.selfClosing || segment.children.length === 0) { - result.push({ - type: "error", - message: ' must have content. Use ....', - source: "Capture", - }); - break; - } - - // Capture expands in the current env/scope (no fresh boundary). - const expandedChildren = yield* expandSegments( - segment.children, - parentMeta, - parentProps, - hideSet, - ctx, - counter, - ); - let captured = renderSegments(expandedChildren).replace(/\s+$/, ""); - - // If select prop is present, parse rendered content as markdown - // and apply CSS selector to extract matching node's text content. - // Falls back to full rendered content if no node matches. - // See §6.5 for selector syntax and node extraction rules. - if (segment.props.select is a non-empty string) { - captured = applyCssSelector(captured, segment.props.select); - } - - const env = yield* ephemeral(EvalEnvCtx.expect()); - env.values[asBinding] = captured; - break; - } - - const expanded = yield* expandComponent( - segment.name, - segment.props, - segment.children, - hideSet, - ); - result.push(...expanded); - break; - } +- **Text** is healed at segment boundaries (§2.3), then interpolated for + `{meta.key}` / `{props.key}` (§6.4) and for eval bindings (§6.6). +- **``** expands its children in the current scope and stores the + rendered result in the named binding — optionally narrowed by a `select` + prop (§6.5) — and itself renders nothing. `` is replaced by the + caller's projected children (§6.3). +- **Any other component** is expanded (§6.2) and replaced by its result. +- **Executable code blocks** run their modifier chain (§3.3); a block + contributes its emitted output, or an `ErrorSegment` when it fails with no + output. - case "codeBlock": { - // Interpolate eval bindings into content before the modifier chain. - // EvalEnvCtx may not be set (e.g., blocks outside component expansion), - // so we use .get() and fall back to the original content. - const evalEnv = yield* EvalEnvCtx.get(); - const interpolatedContent = evalEnv - ? interpolateEvalBindings(segment.content, evalEnv.values) - : segment.content; - - // Compose modifier chain from info string and run it. - // blockId uses counter.next() for deterministic IDs that - // survive per-segment expansion (see §6.1 Block ID counter). - const context: CodeBlockContext = { - language: segment.language, - content: interpolatedContent, - blockId: `eval:${ctx.componentName ?? "root"}:${counter.next()}`, - componentName: ctx.componentName, - }; - const chain = composeModifierChain( - segment.modifiers, context, registry, - ); - const codeResult = yield* chain(); - - if (codeResult.exitCode !== 0 && codeResult.output === "") { - result.push({ - type: "error", - message: `Command failed (exit ${codeResult.exitCode}): ${codeResult.stderr}`, - source: segment.content, - }); - } else if (codeResult.output !== "") { - result.push({ - type: "execOutput", - command: segment.content, - result: { - exitCode: codeResult.exitCode, - stdout: codeResult.output, - stderr: codeResult.stderr, - }, - }); - } - break; - } +Errors are represented as `ErrorSegment`s and render as HTML comments by +default (§11.2). Deterministic `blockId` values come from the block counter +above, so per-segment expansion produces stable diagnostic identifiers. - default: - result.push(segment); - } - } +Where a component (or the root) declares ``, this same rewriting drives +each of its regions, but only the content of declared output regions is +retained; content outside them executes for its effects without rendering, and +the first error produced while executing that non-rendered documentation stops +the body immediately (§6.9). - return result; -} -``` - -The modifier chain composition and handler registration are defined -in §3.3. The expansion code above composes the chain from the info -string and runs it via `composeModifierChain`. +The modifier chain composition and handler registration are defined in §3.3. ### 6.2 Component expansion with cycle detection -```typescript -const MAX_EXPANSION_DEPTH = 64; - -function* expandComponent( - name: string, - props: Record, - children: Segment[], - hideSet: Set, -): Workflow { - // Cycle detection — Prosser's algorithm - if (hideSet.has(name)) { - return [{ - type: "error", - message: `Cycle detected: ${name} is already being expanded (hide set: ${[...hideSet].join(" → ")})`, - source: name, - }]; - } - - if (hideSet.size >= MAX_EXPANSION_DEPTH) { - return [{ - type: "error", - message: `Maximum expansion depth (${MAX_EXPANSION_DEPTH}) exceeded`, - source: name, - }]; - } - - // Import — single durable effect (resolve + read) - const definition = yield* durableImportComponent(name); - - // Extract reserved props before validation. - // `slot` and `as` are consumed by the expansion engine. - const asBinding = props.as; - const { slot: _slot, as: _as, ...propsForValidation } = props; - - // Validate props against declared inputs - const validatedProps = validateProps(name, propsForValidation, definition.inputs); - - // Capture the caller's eval env. For multi-level nesting, merge - // projectedEnv (from outer caller) with the current context env - // so ancestor bindings propagate through all levels (DEC-91, 92). - const contextEnv = yield* EvalEnvCtx.get(); - const callerEvalEnv = projectedEnv - ? { values: { ...projectedEnv.values, ...(contextEnv?.values ?? {}) } } - : contextEnv; - - // Substitute raw children into positions. - // Children are NOT pre-expanded — they expand in document order - // when the substituted body is expanded. This ensures code blocks - // before (e.g., provider middleware installation) run - // before children's code blocks. - // Children segments are tagged with callerEvalEnv so expression - // props resolve in the caller's lexical scope (DEC-91). - const substituted = substituteContent( - definition.bodySegments, - children, - definition.meta, - validatedProps, - callerEvalEnv ?? undefined, - ); - - // Recurse with augmented hide set. - // Each component gets its own fresh binding environment so that - // eval blocks within a component share bindings but don't leak - // into parent or sibling components. This is critical for the - // provider pattern (§6.6) where each provider has isolated - // port/URL bindings. - // - // Props are pre-populated into env.values (DEC-EX-09) so they - // are available as bare bindings in eval blocks. - const newHideSet = new Set([...hideSet, name]); - const componentEnv: EvalEnv = { values: { ...validatedProps } }; - - // Inject renderChildren() and render() closures (§4.8). - // These capture the expansion context so they work correctly - // when called from within evalScope.eval(). - componentEnv.values.renderChildren = function* () { /* ... */ }; - componentEnv.values.render = function* (markdown) { /* ... */ }; - - const expanded = yield* EvalEnvCtx.with( - componentEnv, - function* () { - return yield* expandSegments( - substituted, - definition.meta, - validatedProps, - newHideSet, - ); - }, - ); - - // Binding capture: route rendered output to parent env and suppress - // document output at the invocation site. - if (asBinding !== undefined) { - if (typeof asBinding !== "string" || asBinding.length === 0) { - throw new PropValidationError(name, [ - 'Prop "as" on <' + name + ' /> must be a non-empty string literal', - ]); - } - if (!isValidIdentifier(asBinding)) { - throw new PropValidationError(name, [ - `Prop "as" on <${name} /> must be a valid JavaScript identifier`, - ]); - } - - const parentEnv = yield* ephemeral(EvalEnvCtx.expect()); - parentEnv.values[asBinding] = renderSegments(expanded); - return []; - } - - return expanded; -} -``` +Expanding a component invocation proceeds as: + +- **Cycle and depth guards.** A component already being expanded on the active + expansion path, or an expansion nested beyond the maximum depth (64), + produces an `ErrorSegment` instead of expanding — preventing infinite and + runaway expansion. +- **Import and props.** The component is imported (§5.3); the reserved `slot` + and `as` props are consumed by the engine and stripped before the remaining + props are validated against the declared inputs (§6.3.5, §6.5). +- **Body expansion.** The caller's children are substituted into `` + positions (§6.3), and the body is expanded in a fresh binding environment + seeded with the validated props, exposing `renderChildren()` / `render()` + (§4.8) to eval blocks. Expression props resolve in the caller's scope. When + the component declares ``, its placement is validated before any body + content executes and only its declared regions render (§6.9). +- **Capture (`as=`).** With `as="binding"`, the rendered result is written to + that binding in the caller's environment and nothing is emitted at the call + site — capturing only the selected output when the component declares + ``. Cycle detection and depth limiting are runtime operations — no journal entries. They are deterministic from the component dependency graph read in From d89f6b2e77e5006f1374e50d43b9c5123a901a30 Mon Sep 17 00:00:00 2001 From: Taras Mankovski <74687+taras@users.noreply.github.com> Date: Sat, 18 Jul 2026 18:14:37 -0400 Subject: [PATCH 5/9] refactor: replace expansion context plumbing with ComponentApi MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit One public Component context API (backed by @effectionx/context-api) replaces ExpansionContext and the raw operational context keys. Context-dependent behavior installs as scope-local middleware; nested providers override ancestors without leaking into siblings. - component-api.ts: Component/ComponentApi plus direct operations (importComponent, applyModifiers, raise, env, evalScope, codeBlock, persistent, content) with missing-provider defaults. - expand.ts: ctx parameter removed from all expansion functions; error policy becomes raise() middleware (documentation throws, Output installs a collecting provider that shadows it); consumer boundaries raise transported errors unconditionally. - run-document.ts: document providers install at min before durableRun; root imports via importComponent("__root__"); journal shape unchanged (verified by replay/journal tests). - modifiers/persist/daemon/eval-handler/content-context: read state through the operations; useCodeBlock/useContent stay as aliases. - mod.ts: export the new public surface; drop CodeBlockCtx, EvalEnvCtx, EvalScopeCtx, EvalCtxKey, createEvalContext. - tests: makeCtx/recordingCtx replaced with Component middleware helpers; new component-api.test.ts (defaults, missing-provider diagnostics, scoped overrides, nested precedence, persist flag, content dispatch) and a journal-shape integration test. - specs: new §5.5 Component Api contract + observable scoping; raw-key sections restated behaviorally; DEC-012 records the architectural decision. --- core/mod.ts | 23 +- core/src/component-api.ts | 102 ++++++ core/src/content-context.ts | 56 +-- core/src/error-policy.ts | 49 +-- core/src/eval-context.ts | 35 +- core/src/eval-env.ts | 49 +-- core/src/eval-handler.ts | 38 ++- core/src/expand.ts | 475 +++++++++++++------------- core/src/modifiers.ts | 58 ++-- core/src/modifiers/daemon.ts | 11 +- core/src/modifiers/persist.ts | 25 +- core/src/run-document.ts | 90 ++--- core/tests/component-api.test.ts | 214 ++++++++++++ core/tests/daemon.test.ts | 28 +- core/tests/eval-middleware.test.ts | 2 +- core/tests/expand.test.ts | 298 +++++++++------- core/tests/expression-props.test.ts | 51 --- core/tests/heal.test.ts | 59 ++-- core/tests/named-slots.test.ts | 175 +++++----- core/tests/run-document.test.ts | 30 ++ core/tests/text-interpolation.test.ts | 159 +++++---- specs/decisions.md | 91 +++-- specs/executable-mdx-spec.md | 281 +++++++-------- 23 files changed, 1346 insertions(+), 1053 deletions(-) create mode 100644 core/src/component-api.ts create mode 100644 core/tests/component-api.test.ts diff --git a/core/mod.ts b/core/mod.ts index 7ed1784..0871a0b 100644 --- a/core/mod.ts +++ b/core/mod.ts @@ -39,17 +39,32 @@ export type { ModifierMiddleware, CodeBlockWorkflow, } from "./src/modifiers.ts"; -export { useCodeBlock, CodeBlockCtx } from "./src/modifiers.ts"; +export { useCodeBlock } from "./src/modifiers.ts"; + +// --------------------------------------------------------------------------- +// Component Api — contextual operations for component expansion +// --------------------------------------------------------------------------- + +export type { ComponentApi } from "./src/component-api.ts"; +export { + Component, + importComponent, + applyModifiers, + raise, + env, + evalScope, + codeBlock, + persistent, + content, +} from "./src/component-api.ts"; // --------------------------------------------------------------------------- // Eval system (generator eval blocks) // --------------------------------------------------------------------------- export type { EvalEnv } from "./src/eval-env.ts"; -export { EvalEnvCtx, EvalScopeCtx } from "./src/eval-env.ts"; -export type { EvalContext } from "./src/eval-context.ts"; -export { EvalCtxKey, createEvalContext, compileBlock } from "./src/eval-context.ts"; +export { compileBlock } from "./src/eval-context.ts"; // --------------------------------------------------------------------------- // executable.md Globals (for generated eval modules) diff --git a/core/src/component-api.ts b/core/src/component-api.ts new file mode 100644 index 0000000..3e82333 --- /dev/null +++ b/core/src/component-api.ts @@ -0,0 +1,102 @@ +/** + * Component Api — contextual operations for component expansion. + * + * One public Api replaces the former dependency container (ExpansionContext) + * and raw Effection context keys. Context-dependent behavior is installed as + * scope-local middleware via `Component.around(...)`: + * + * - Runtime implementations (document import, modifier execution, component + * state) install at `{ at: "min" }`. Middleware installed in a nested scope + * runs before inherited middleware, so a component that installs its own + * `env` shadows its ancestors without leaking into siblings — install + * inside `scoped()` for automatic removal. + * - Caller instrumentation and overrides wrap at the default `"max"`. + */ + +import { createApi } from "@effectionx/context-api"; +import type { Operation } from "effection"; +import type { EvalScope } from "@effectionx/scope-eval"; +import type { + CodeBlockContext, + CodeBlockResult, + ComponentDefinition, + ErrorSegment, + FunctionComponentDefinition, + Modifier, +} from "./types.ts"; +import type { EvalEnv } from "./eval-env.ts"; + +export interface ComponentApi { + /** `"__root__"` imports the root document. */ + importComponent(name: string): Operation; + applyModifiers(modifiers: Modifier[], block: CodeBlockContext): Operation; + /** + * Report an ErrorSegment under the ambient error policy. The default + * returns the segment for rendering; suppressed-documentation scopes + * install middleware that throws instead (spec §6.9). + */ + raise(error: ErrorSegment): Operation; + env(): Operation; + evalScope(): Operation; + codeBlock(): Operation; + /** Whether the current block runs with persistent resource lifetime. */ + persistent(): Operation; + /** Render the invoking component's children (optionally a named slot). */ + content(slot?: string): Operation; +} + +export const Component = createApi("Component", { + // deno-lint-ignore require-yield + *importComponent(name: string): Operation { + throw new Error( + `Component.importComponent("${name}") has no provider. Install one with ` + + `Component.around({ importComponent }, { at: "min" }) before expansion.`, + ); + }, + // deno-lint-ignore require-yield + *applyModifiers(_modifiers: Modifier[], block: CodeBlockContext): Operation { + throw new Error( + `Component.applyModifiers() has no provider for block "${block.blockId}". Install one ` + + `with Component.around({ applyModifiers }, { at: "min" }) before expansion.`, + ); + }, + // deno-lint-ignore require-yield + *raise(error: ErrorSegment): Operation { + return error; + }, + // deno-lint-ignore require-yield + *env(): Operation { + return undefined; + }, + // deno-lint-ignore require-yield + *evalScope(): Operation { + return undefined; + }, + // deno-lint-ignore require-yield + *codeBlock(): Operation { + throw new Error( + "Component.codeBlock() has no provider: no code block is executing in this scope.", + ); + }, + // deno-lint-ignore require-yield + *persistent(): Operation { + return false; + }, + // deno-lint-ignore require-yield + *content(_slot?: string): Operation { + throw new Error( + "Component.content() has no provider: not inside a function component invocation.", + ); + }, +}); + +export const { + importComponent, + applyModifiers, + raise, + env, + evalScope, + codeBlock, + persistent, + content, +} = Component.operations; diff --git a/core/src/content-context.ts b/core/src/content-context.ts index 1480516..4aeff8b 100644 --- a/core/src/content-context.ts +++ b/core/src/content-context.ts @@ -1,62 +1,26 @@ /** - * Content context for function components. - * - * Provides `useContent()` — the function component equivalent of - * `` in markdown components. The expansion engine sets - * the content handle on the Effection scope before calling the - * function component. Components that need rendered children call - * `yield* useContent()` to retrieve them. - * - * Named slots are supported: `yield* useContent("header")` returns - * the content for the "header" slot, matching `` - * in markdown components. + * useContent() — the function component equivalent of `` in + * markdown components. Ergonomic alias for the Component `content()` + * operation; the expansion engine installs the slot-rendering middleware + * around each function component invocation. */ -import { createContext } from "effection"; import type { Operation } from "effection"; -import type { Segment } from "./types.ts"; - -/** - * Handle providing access to a component's children content. - * Set on the Effection scope by the expansion engine. - */ -export interface ContentHandle { - /** Render the default slot (children without a slot prop). */ - renderDefault: () => Operation; - /** Render a named slot. Returns empty string if the slot has no content. */ - renderSlot: (name: string) => Operation; - /** Raw child segments (all slots combined, for inspection). */ - segments: Segment[]; -} +import { content } from "./component-api.ts"; /** - * Effection context key for the content handle. - */ -export const ContentCtx = createContext("content"); - -/** - * Render children content from the parent scope. - * - * The function component equivalent of `` in markdown components. + * Render children content from the invoking component. * * @param slotName - Optional slot name. If provided, renders only the - * content assigned to that slot. If omitted, renders the default slot - * (children without a `slot` prop). - * @returns The rendered content as a string. + * content assigned to that slot (matching ``). + * If omitted, renders the default slot. * * @example * ```ts - * // Render default content (equivalent to in .md) - * const content = yield* useContent(); - * - * // Render named slot (equivalent to in .md) + * const body = yield* useContent(); * const header = yield* useContent("header"); * ``` */ export function* useContent(slotName?: string): Operation { - const handle = yield* ContentCtx.expect(); - if (slotName !== undefined) { - return yield* handle.renderSlot(slotName); - } - return yield* handle.renderDefault(); + return yield* content(slotName); } diff --git a/core/src/error-policy.ts b/core/src/error-policy.ts index 23ec243..96ae9bf 100644 --- a/core/src/error-policy.ts +++ b/core/src/error-policy.ts @@ -1,30 +1,16 @@ /** - * Contextual error policy for expansion (spec §6.9). + * Documentation failure marker (spec §6.9). * - * Inside a rendered region — an `` region, or anywhere when no - * `` is declared — an ErrorSegment renders as an HTML comment - * ("collect"). While executing suppressed documentation, the first - * ErrorSegment produced throws immediately ("throw") so it propagates to be - * handled above. + * Error policy is contextual middleware on `Component.operations.raise`: + * suppressed documentation installs a throwing implementation, output + * regions install a collecting one that shadows it. DocumentationError is + * the internal throwable those middlewares use — generic error handling in + * the engine rethrows it instead of converting it into an ErrorSegment, so + * documentation fail-fast is never swallowed. */ -import { createContext } from "effection"; -import type { Operation } from "effection"; -import type { ErrorSegment, Segment } from "./types.ts"; +import type { ErrorSegment } from "./types.ts"; -export type ErrorPolicy = "collect" | "throw"; - -/** - * Effection context holding the ambient error policy. Unset defaults to - * "collect" — today's behavior where ErrorSegments render as comments. - */ -export const ErrorPolicyCtx = createContext("errorPolicy"); - -/** - * Thrown when an ErrorSegment is produced or transported into a suppressed - * documentation region. Carries the offending segment so callers above can - * inspect it. - */ export class DocumentationError extends Error { readonly segment: ErrorSegment; @@ -34,22 +20,3 @@ export class DocumentationError extends Error { this.segment = segment; } } - -/** Read the ambient error policy, defaulting to "collect" when unset. */ -export function* currentErrorPolicy(): Operation { - const policy = yield* ErrorPolicyCtx.get(); - return policy ?? "collect"; -} - -/** - * Route an ErrorSegment through the ambient policy. Under "throw" it raises a - * DocumentationError (fail-fast); under "collect" it returns the segment so - * the caller renders it as a comment. - */ -export function* raise(segment: ErrorSegment): Operation { - const policy = yield* currentErrorPolicy(); - if (policy === "throw") { - throw new DocumentationError(segment); - } - return [segment]; -} diff --git a/core/src/eval-context.ts b/core/src/eval-context.ts index 34034ea..56cf0ae 100644 --- a/core/src/eval-context.ts +++ b/core/src/eval-context.ts @@ -1,43 +1,10 @@ /** - * Eval block compilation context (spec §5). + * Eval block compilation (spec §5). */ -import { createContext as createEffectionContext } from "effection"; import type { Operation } from "effection"; import { compile as runtimeCompile } from "@executablemd/runtime"; -// --------------------------------------------------------------------------- -// EvalContext — lightweight context for the eval system -// --------------------------------------------------------------------------- - -/** - * Eval context for document runs. - * - * In the Deno model, there is no VM context — eval blocks are compiled - * into data: URI modules that import their dependencies. The EvalContext - * exists as a marker that the eval system has been initialized. - */ -export interface EvalContext { - /** Placeholder for future per-document eval configuration. */ - initialized: true; -} - -/** - * Effection context key for the eval context. - */ -export const EvalCtxKey = createEffectionContext("evalContext"); - -/** - * Create an eval context for a document run. - * - * In the Deno model, this is lightweight — no VM sandbox creation. - * The eval context is set on the Effection scope so eval blocks - * can verify the eval system is initialized. - */ -export function createEvalContext(_globals: Record = {}): EvalContext { - return { initialized: true }; -} - /** * Compile transformed source code into a generator function. * diff --git a/core/src/eval-env.ts b/core/src/eval-env.ts index 70b9660..0394958 100644 --- a/core/src/eval-env.ts +++ b/core/src/eval-env.ts @@ -1,30 +1,12 @@ /** - * Eval environment — shared binding environment and scope contexts - * for generator eval blocks (spec generator-eval-spec.md §3.1–3.2). + * Eval environment — shared binding environment for generator eval blocks + * (spec generator-eval-spec.md §3.2). * - * - EvalEnv: mutable record of bindings shared across eval blocks within a component - * - EvalEnvCtx: Effection context for the current component's binding environment - * - EvalScopeCtx: Effection context for the current component's eval scope + * The current environment and eval scope are delivered contextually via the + * Component Api (`Component.operations.env()` / `Component.operations.evalScope()`), + * installed as scope-local middleware by the expansion engine. */ -import { createContext } from "effection"; -import type { EvalScope } from "@effectionx/scope-eval"; - -// --------------------------------------------------------------------------- -// Persist flag (spec §7.1) -// --------------------------------------------------------------------------- - -/** - * When set to true on the scope, the eval handler will run the compiled - * block inside the EvalScope's persistent child scope. Set by the - * persist modifier, read by evalFactory. - */ -export const PersistFlagCtx = createContext("persistFlag"); - -// --------------------------------------------------------------------------- -// Binding environment (spec §3.2) -// --------------------------------------------------------------------------- - /** * Shared binding environment for eval blocks within a single component. * @@ -35,24 +17,3 @@ export const PersistFlagCtx = createContext("persistFlag"); export interface EvalEnv { values: Record; } - -/** - * Effection context holding the current component's binding environment. - * - * Set via `EvalEnvCtx.with()` in the expansion engine when a component - * begins expansion. Handlers access it via `ephemeral(EvalEnvCtx.expect())`. - */ -export const EvalEnvCtx = createContext("evalEnv"); - -// --------------------------------------------------------------------------- -// Eval scope (spec §3.1) -// --------------------------------------------------------------------------- - -/** - * Effection context holding the current component's EvalScope. - * - * The EvalScope (from @effectionx/scope-eval) is created per component - * and allows `persist` blocks to retain resources beyond block lifetime. - * The scope is destroyed when component expansion completes. - */ -export const EvalScopeCtx = createContext("evalScope"); diff --git a/core/src/eval-handler.ts b/core/src/eval-handler.ts index bf0f8b3..73344ec 100644 --- a/core/src/eval-handler.ts +++ b/core/src/eval-handler.ts @@ -4,7 +4,7 @@ import { unbox } from "@effectionx/scope-eval"; import type { Operation } from "effection"; import type { ModifierFactory } from "./modifiers.ts"; import { useCodeBlock } from "./modifiers.ts"; -import { EvalEnvCtx, EvalScopeCtx, PersistFlagCtx } from "./eval-env.ts"; +import { env, evalScope, persistent } from "./component-api.ts"; import { compileBlock } from "./eval-context.ts"; import { transformBlock, serializeExports } from "./eval-transform.ts"; @@ -15,8 +15,13 @@ import { transformBlock, serializeExports } from "./eval-transform.ts"; export const evalFactory: ModifierFactory = (_params) => (_args, _next) => (function* () { const ctx = yield* useCodeBlock(); - const env = yield* ephemeral(EvalEnvCtx.expect()); - const persist = yield* ephemeral(PersistFlagCtx.get()) ?? false; + const evalEnv = yield* ephemeral(env()); + if (!evalEnv) { + throw new Error( + `eval block "${ctx.blockId}" requires a binding environment; none is in scope.`, + ); + } + const persist = yield* ephemeral(persistent()); // Inject output() function into env so eval blocks can produce // rendered output. The function is a plain synchronous call: @@ -26,13 +31,13 @@ export const evalFactory: ModifierFactory = (_params) => (_args, _next) => // the journal. The output text itself is journaled alongside // exports as __output. const outputRef = { text: "" }; - env.values.output = (text: string) => { + evalEnv.values.output = (text: string) => { outputRef.text = String(text); }; - const transformed = transformBlock(ctx.content, ctx.blockId, Object.keys(env.values)); + const transformed = transformBlock(ctx.content, ctx.blockId, Object.keys(evalEnv.values)); - const bindings = serializeExports(env.values, transformed.imports); + const bindings = serializeExports(evalEnv.values, transformed.imports); const result = (yield createDurableOperation( { type: "eval", @@ -41,7 +46,7 @@ export const evalFactory: ModifierFactory = (_params) => (_args, _next) => }, function* (): Operation { // Merge incoming bindings snapshot into env before execution - Object.assign(env.values, bindings); + Object.assign(evalEnv.values, bindings); // Compile the eval block via data: URI module import. // compileBlock is async (returns Operation) — it generates a @@ -49,11 +54,16 @@ export const evalFactory: ModifierFactory = (_params) => (_args, _next) => const fn = yield* compileBlock(transformed.code, transformed.userImports ?? []); if (persist) { - // Persist mode: run the compiled block inside evalScope.eval() + // Persist mode: run the compiled block inside the eval scope // so spawned resources are retained in the persistent EvalScope. - const evalScope = yield* EvalScopeCtx.expect(); - const blockResult = yield* evalScope.eval( - () => fn(env.values) as unknown as Operation, + const scope = yield* evalScope(); + if (!scope) { + throw new Error( + `persist eval block "${ctx.blockId}" requires a component eval scope; none is in scope.`, + ); + } + const blockResult = yield* scope.eval( + () => fn(evalEnv.values) as unknown as Operation, ); const returnValue = unbox(blockResult); if (!outputRef.text && returnValue != null) { @@ -62,13 +72,13 @@ export const evalFactory: ModifierFactory = (_params) => (_args, _next) => } else { // Normal mode: run the compiled block in the current scope. // Resources are torn down when this operation completes. - const returnValue = yield* fn(env.values) as unknown as Operation; + const returnValue = yield* fn(evalEnv.values) as unknown as Operation; if (!outputRef.text && returnValue != null) { outputRef.text = String(returnValue); } } - const exports = serializeExports(env.values, transformed.exports); + const exports = serializeExports(evalEnv.values, transformed.exports); if (outputRef.text) { (exports as Record).__output = outputRef.text; @@ -86,7 +96,7 @@ export const evalFactory: ModifierFactory = (_params) => (_args, _next) => } // Remove __output from exports before assigning to env const { __output: _, ...exports } = restored; - Object.assign(env.values, exports); + Object.assign(evalEnv.values, exports); } return { output: outputRef.text, exitCode: 0, stderr: "" }; diff --git a/core/src/expand.ts b/core/src/expand.ts index 124f38b..4de6cc6 100644 --- a/core/src/expand.ts +++ b/core/src/expand.ts @@ -13,10 +13,8 @@ * middleware installation) execute before children's code blocks. */ -import { useScope } from "effection"; +import { scoped } from "effection"; import type { Operation } from "effection"; -import { ContentCtx } from "./content-context.ts"; -import type { ContentHandle } from "./content-context.ts"; import type { Segment, TextSegment, @@ -26,14 +24,19 @@ import type { FunctionComponentDefinition, Json, CodeBlockContext, - CodeBlockResult, - Modifier, } from "./types.ts"; import { interpolate } from "./interpolate.ts"; import { interpolateEvalBindings } from "./eval-interpolate.ts"; -import { EvalEnvCtx, EvalScopeCtx } from "./eval-env.ts"; import type { EvalEnv } from "./eval-env.ts"; -import { ErrorPolicyCtx, DocumentationError, currentErrorPolicy, raise } from "./error-policy.ts"; +import { + Component, + applyModifiers, + env, + evalScope, + importComponent, + raise, +} from "./component-api.ts"; +import { DocumentationError } from "./error-policy.ts"; import { useEvalScope, unbox } from "@effectionx/scope-eval"; import type { EvalScope } from "@effectionx/scope-eval"; import { validateProps } from "./validate.ts"; @@ -63,24 +66,32 @@ export function createBlockCounter(): BlockCounter { } // --------------------------------------------------------------------------- -// Types for the expansion context +// Scope-local providers — installed inside scoped() so nested components +// override ancestors without leaking into siblings // --------------------------------------------------------------------------- -export type ComponentImporter = ( - name: string, -) => Operation; +function* provideEnv(value: EvalEnv): Operation { + yield* Component.around( + { + // deno-lint-ignore require-yield + *env(_args, _next) { + return value; + }, + }, + { at: "min" }, + ); +} -/** - * Function that executes a modifier chain for a code block. - */ -export type ModifierChainRunner = ( - modifiers: Modifier[], - context: CodeBlockContext, -) => Operation; - -export interface ExpansionContext { - importComponent: ComponentImporter; - runModifierChain: ModifierChainRunner; +function* provideEvalScope(value: EvalScope): Operation { + yield* Component.around( + { + // deno-lint-ignore require-yield + *evalScope(_args, _next) { + return value; + }, + }, + { at: "min" }, + ); } // --------------------------------------------------------------------------- @@ -93,6 +104,10 @@ const IDENTIFIER_RE = /^[a-zA-Z_$][a-zA-Z0-9_$]*$/; /** * Expand an array of segments, resolving components and executing code blocks. * + * Component import, modifier execution, bindings, and error policy are all + * delivered contextually through the Component Api — install providers with + * `Component.around(..., { at: "min" })` before expanding. + * * @param counter - Optional block ID counter. If omitted, a local counter * is created. For per-segment emission (§9), pass a shared counter so * IDs are stable across calls. @@ -102,7 +117,6 @@ export function* expandSegments( parentMeta: Record, parentProps: Record, hideSet: Set, - ctx: ExpansionContext, counter: BlockCounter = createBlockCounter(), ): Operation { const result: Segment[] = []; @@ -117,8 +131,8 @@ export function* expandSegments( const interpolated = interpolate(healed, parentMeta, parentProps); // Interpolate bare {name} refs from eval bindings (spec §6.4/§6.6). // Runs after meta/props interpolation so component contract takes - // precedence. Only runs when an EvalEnv is present on the scope. - const textEvalEnv = yield* EvalEnvCtx.get(); + // precedence. Only runs when a binding environment is in scope. + const textEvalEnv = yield* env(); const final = textEvalEnv ? interpolateEvalBindings(interpolated, textEvalEnv.values) : interpolated; @@ -132,7 +146,7 @@ export function* expandSegments( // reaches here. Reaching this branch means a misplaced or // dynamically scanned (e.g. render(markdown) content) — // diagnose it defensively per the ambient policy. - result.push(...(yield* raise(misplacedOutputError()))); + result.push(yield* raise(misplacedOutputError())); break; } @@ -142,11 +156,10 @@ export function* expandSegments( parentMeta, parentProps, hideSet, - ctx, counter, ); if (captureResult) { - result.push(...(yield* raise(captureResult))); + result.push(yield* raise(captureResult)); } break; } @@ -157,7 +170,6 @@ export function* expandSegments( segment.expressions, segment.children, hideSet, - ctx, counter, segment.projectedEnv, ); @@ -165,7 +177,7 @@ export function* expandSegments( // ambient policy before appending them (spec §6.9). for (const expandedSegment of expanded) { if (expandedSegment.type === "error") { - result.push(...(yield* raise(expandedSegment))); + result.push(yield* raise(expandedSegment)); } else { result.push(expandedSegment); } @@ -175,13 +187,13 @@ export function* expandSegments( case "codeBlock": { // Interpolate eval bindings into content before the modifier chain. - // EvalEnvCtx may not be set (e.g., blocks outside component expansion), - // so we use .get() and fall back to the original content. + // A binding environment may not be in scope (e.g., blocks outside + // component expansion) — fall back to the original content. // // Skip interpolation for eval blocks — they access bindings directly // via the env preamble (const { name } = env;). Interpolating would // mangle JS template literals like `${name}` into `$`. - const evalEnv = yield* EvalEnvCtx.get(); + const evalEnv = yield* env(); const lastModifier = segment.modifiers[segment.modifiers.length - 1]; const isEvalTerminal = lastModifier !== undefined && lastModifier.name === "eval"; const interpolatedContent = @@ -200,15 +212,15 @@ export function* expandSegments( }; try { - const codeResult = yield* ctx.runModifierChain(segment.modifiers, context); + const codeResult = yield* applyModifiers(segment.modifiers, context); if (codeResult.exitCode !== 0 && codeResult.output === "") { result.push( - ...(yield* raise({ + yield* raise({ type: "error", message: `Command failed (exit ${codeResult.exitCode}): ${codeResult.stderr}`, source: segment.content, - })), + }), ); } else if (codeResult.output !== "") { result.push({ @@ -229,11 +241,11 @@ export function* expandSegments( throw error; } result.push( - ...(yield* raise({ + yield* raise({ type: "error", message: error instanceof Error ? error.message : String(error), source: segment.content, - })), + }), ); } break; @@ -243,7 +255,7 @@ export function* expandSegments( if (segment.type === "error") { // Pre-existing error segments (e.g. slot/substitution errors) follow // the ambient policy. - result.push(...(yield* raise(segment))); + result.push(yield* raise(segment)); } else { result.push(segment); } @@ -259,7 +271,6 @@ function* expandCapture( parentMeta: Record, parentProps: Record, hideSet: Set, - ctx: ExpansionContext, counter: BlockCounter, ): Operation { if (segment.selfClosing || segment.children.length === 0) { @@ -327,7 +338,6 @@ function* expandCapture( parentMeta, parentProps, hideSet, - ctx, counter, ); const rendered = renderSegments(expandedChildren).replace(/\s+$/, ""); @@ -344,15 +354,15 @@ function* expandCapture( } } - const env = yield* EvalEnvCtx.get(); - if (!env) { + const bindingEnv = yield* env(); + if (!bindingEnv) { return { type: "error", message: " requires an evaluation environment.", source: "Capture", }; } - env.values[bindingName] = captured; + bindingEnv.values[bindingName] = captured; return undefined; } @@ -366,39 +376,47 @@ function* expandComponent( expressions: Record, children: Segment[], hideSet: Set, - ctx: ExpansionContext, counter: BlockCounter, projectedEnv?: EvalEnv, ): Operation { // Cycle detection — Prosser's algorithm if (hideSet.has(name)) { - return yield* raise({ - type: "error", - message: `Cycle detected: ${name} is already being expanded (hide set: ${[...hideSet].join(" → ")})`, - source: name, - }); + return [ + yield* raise({ + type: "error", + message: `Cycle detected: ${name} is already being expanded (hide set: ${[...hideSet].join(" → ")})`, + source: name, + }), + ]; } if (hideSet.size >= MAX_EXPANSION_DEPTH) { - return yield* raise({ - type: "error", - message: `Maximum expansion depth (${MAX_EXPANSION_DEPTH}) exceeded`, - source: name, - }); + return [ + yield* raise({ + type: "error", + message: `Maximum expansion depth (${MAX_EXPANSION_DEPTH}) exceeded`, + source: name, + }), + ]; } let imported: ComponentDefinition | FunctionComponentDefinition; try { - imported = yield* ctx.importComponent(name); + imported = yield* importComponent(name); } catch (error) { - return yield* raise({ - type: "error", - message: - error instanceof Error - ? `Failed to import component ${name}: ${error.message}` - : `Failed to import component ${name}: ${String(error)}`, - source: name, - }); + if (error instanceof DocumentationError) { + throw error; + } + return [ + yield* raise({ + type: "error", + message: + error instanceof Error + ? `Failed to import component ${name}: ${error.message}` + : `Failed to import component ${name}: ${String(error)}`, + source: name, + }), + ]; } // Function component: call the generator function directly @@ -410,7 +428,6 @@ function* expandComponent( children, imported, hideSet, - ctx, counter, projectedEnv, ); @@ -424,7 +441,7 @@ function* expandComponent( // components, or other side effects. const placementError = validateOutputPlacement(definition.bodySegments); if (placementError) { - return yield* raise(placementError); + return [yield* raise(placementError)]; } // Resolve eval expression props against env.values using the shared @@ -434,19 +451,23 @@ function* expandComponent( try { resolvedProps = yield* resolveExpressionProps(props, expressions, name, projectedEnv); } catch (error) { - return yield* raise({ - type: "error", - message: error instanceof Error ? error.message : String(error), - source: name, - }); + return [ + yield* raise({ + type: "error", + message: error instanceof Error ? error.message : String(error), + source: name, + }), + ]; } if ("as" in expressions) { - return yield* raise({ - type: "error", - message: `Prop "as" on <${name} /> must be a string literal.`, - source: name, - }); + return [ + yield* raise({ + type: "error", + message: `Prop "as" on <${name} /> must be a string literal.`, + source: name, + }), + ]; } // Validate props against declared inputs. @@ -464,11 +485,13 @@ function* expandComponent( const { slot: _slot, as: _as, ...propsForValidation } = resolvedProps; validatedProps = validateProps(name, propsForValidation, definition.inputs); } catch (error) { - return yield* raise({ - type: "error", - message: error instanceof Error ? error.message : String(error), - source: name, - }); + return [ + yield* raise({ + type: "error", + message: error instanceof Error ? error.message : String(error), + source: name, + }), + ]; } // Capture the caller's eval environment before creating the component's @@ -480,7 +503,7 @@ function* expandComponent( // the projectedEnv from the outer caller must be merged with the current // context env so that ancestor bindings propagate through all levels. // The current context env's bindings take precedence (innermost-wins). - const contextEnv = yield* EvalEnvCtx.get(); + const contextEnv = yield* env(); const callerEvalEnv = projectedEnv ? { values: { ...projectedEnv.values, ...(contextEnv?.values ?? {}) } } : contextEnv; @@ -504,7 +527,7 @@ function* expandComponent( // By creating it via parentEvalScope.eval(), the child's spawned task // lives inside the parent's scope — Effection's scope prototype chain // ensures scope.reduce() walks child → parent when resolving middleware. - const parentEvalScope = yield* EvalScopeCtx.get(); + const parentEvalScope = yield* evalScope(); let childEvalScope: EvalScope | undefined = undefined; if (parentEvalScope) { const result = yield* parentEvalScope.eval(() => useEvalScope()); @@ -531,10 +554,9 @@ function* expandComponent( // their own child scopes off parentEvalScope, and ancestor // middleware is visible through Effection's scope prototype chain. // - // Both wrap their expandSegments call in EvalEnvCtx.with() and - // EvalScopeCtx.with() so the full expansion context is available - // regardless of which task the closure runs in (e.g., inside - // evalScope.eval()). + // Both install env/evalScope middleware inside a fresh scope so the full + // expansion context is available regardless of which task the closure + // runs in (e.g., inside evalScope.eval()). // // These are non-serializable (functions) so serializeExports silently // omits them from the journal. @@ -546,82 +568,40 @@ function* expandComponent( // without triggering false cycle detection. True cycles in a component's // body are still caught because body expansion uses newHideSet. const capturedChildrenHideSet = hideSet; - const capturedCtx = ctx; const capturedParentEvalScope = parentEvalScope; // Children are caller-provided content. Use the caller's eval env so // expression props (e.g., {pr}) resolve against the scope where the // JSX was written, not the wrapping component's env. Falls back to const capturedCallerEnv = callerEvalEnv ?? componentEnv; - componentEnv.values.renderChildren = function* () { - return yield* EvalEnvCtx.with(capturedCallerEnv, function* () { + const renderInCallerScope = (segments: Segment[]) => + scoped(function* () { + yield* provideEnv(capturedCallerEnv); if (capturedParentEvalScope) { - return yield* EvalScopeCtx.with(capturedParentEvalScope, function* () { - const expanded = yield* expandSegments( - children, - capturedMeta, - capturedProps, - capturedChildrenHideSet, - capturedCtx, - counter, - ); - return renderSegments(expanded); - }); + yield* provideEvalScope(capturedParentEvalScope); } const expanded = yield* expandSegments( - children, + segments, capturedMeta, capturedProps, capturedChildrenHideSet, - capturedCtx, counter, ); return renderSegments(expanded); }); + + componentEnv.values.renderChildren = function* () { + return yield* renderInCallerScope(children); }; componentEnv.values.render = function* (markdown: string) { - const segments = scanSegments(markdown); - return yield* EvalEnvCtx.with(capturedCallerEnv, function* () { - if (capturedParentEvalScope) { - return yield* EvalScopeCtx.with(capturedParentEvalScope, function* () { - const expanded = yield* expandSegments( - segments, - capturedMeta, - capturedProps, - capturedChildrenHideSet, - capturedCtx, - counter, - ); - return renderSegments(expanded); - }); - } - const expanded = yield* expandSegments( - segments, - capturedMeta, - capturedProps, - capturedChildrenHideSet, - capturedCtx, - counter, - ); - return renderSegments(expanded); - }); + return yield* renderInCallerScope(scanSegments(markdown)); }; - const expanded = yield* EvalEnvCtx.with(componentEnv, function* () { + const expanded = yield* scoped(function* () { + yield* provideEnv(componentEnv); if (childEvalScope) { - return yield* EvalScopeCtx.with(childEvalScope, function* () { - return yield* expandBody( - definition.bodySegments, - children, - definition.meta, - validatedProps, - newHideSet, - ctx, - counter, - callerEvalEnv ?? undefined, - ); - }); + yield* provideEvalScope(childEvalScope); } return yield* expandBody( definition.bodySegments, @@ -629,25 +609,29 @@ function* expandComponent( definition.meta, validatedProps, newHideSet, - ctx, counter, callerEvalEnv ?? undefined, ); }); if (asBinding) { - const parentEnv = yield* EvalEnvCtx.get(); + const parentEnv = yield* env(); if (!parentEnv) { - return yield* raise({ - type: "error", - message: `Prop "as" on <${name} /> requires a parent evaluation environment.`, - source: name, - }); + return [ + yield* raise({ + type: "error", + message: `Prop "as" on <${name} /> requires a parent evaluation environment.`, + source: name, + }), + ]; + } + // Consumer boundary (spec §6.9): raise transported errors unconditionally + // so documentation never captures and hides an error comment. + for (const capturedSegment of expanded) { + if (capturedSegment.type === "error") { + yield* raise(capturedSegment); + } } - // Consumer boundary (spec §6.9): re-raise transported errors under the - // ambient policy so documentation never captures and hides an error - // comment. - yield* consume(expanded); parentEnv.values[asBinding] = renderSegments(expanded); return []; } @@ -673,16 +657,17 @@ function* expandFunctionComponent( children: Segment[], definition: FunctionComponentDefinition, hideSet: Set, - ctx: ExpansionContext, counter: BlockCounter, projectedEnv?: EvalEnv, ): Operation { if ("as" in expressions) { - return yield* raise({ - type: "error", - message: `Prop "as" on <${name} /> must be a string literal.`, - source: name, - }); + return [ + yield* raise({ + type: "error", + message: `Prop "as" on <${name} /> must be a string literal.`, + source: name, + }), + ]; } // Resolve expression props @@ -690,21 +675,25 @@ function* expandFunctionComponent( try { resolvedProps = yield* resolveExpressionProps(props, expressions, name, projectedEnv); } catch (error) { - return yield* raise({ - type: "error", - message: error instanceof Error ? error.message : String(error), - source: name, - }); + return [ + yield* raise({ + type: "error", + message: error instanceof Error ? error.message : String(error), + source: name, + }), + ]; } // Strip slot prop before validation const asBindingResult = validateBindingName(resolvedProps.as); if (!asBindingResult.ok) { - return yield* raise({ - type: "error", - message: `Prop "as" on <${name} /> ${asBindingResult.error}`, - source: name, - }); + return [ + yield* raise({ + type: "error", + message: `Prop "as" on <${name} /> ${asBindingResult.error}`, + source: name, + }), + ]; } const asBinding = asBindingResult.value; const { slot: _slot, as: _as, ...propsForValidation } = resolvedProps; @@ -714,64 +703,71 @@ function* expandFunctionComponent( try { validatedProps = validateProps(name, propsForValidation, definition.inputs); } catch (error) { - return yield* raise({ - type: "error", - message: error instanceof Error ? error.message : String(error), - source: name, - }); + return [ + yield* raise({ + type: "error", + message: error instanceof Error ? error.message : String(error), + source: name, + }), + ]; } - // Set ContentCtx on the scope so the function component can - // access children via `yield* useContent()`. Supports named - // slots: `yield* useContent("header")`. const slots = partitionBySlot(children); - const scope = yield* useScope(); - - const contentHandle: ContentHandle = { - segments: children, - *renderDefault() { - const expanded = yield* expandSegments(slots.default, {}, {}, hideSet, ctx, counter); - return renderSegments(expanded); - }, - *renderSlot(slotName: string) { - const slotChildren = (slots.named.get(slotName) ?? []).map(stripSlotProp); - if (slotChildren.length === 0) return ""; - const expanded = yield* expandSegments(slotChildren, {}, {}, hideSet, ctx, counter); - return renderSegments(expanded); - }, - }; - scope.set(ContentCtx, contentHandle); - // Call the function component + // Call the function component with content middleware in scope so it can + // render children via `yield* useContent()` / `useContent("slot")`. try { - const output = yield* definition.fn(validatedProps); + const output = yield* scoped(function* () { + yield* Component.around( + { + *content([slotName], _next) { + if (slotName !== undefined) { + const slotChildren = (slots.named.get(slotName) ?? []).map(stripSlotProp); + if (slotChildren.length === 0) { + return ""; + } + const expanded = yield* expandSegments(slotChildren, {}, {}, hideSet, counter); + return renderSegments(expanded); + } + const expanded = yield* expandSegments(slots.default, {}, {}, hideSet, counter); + return renderSegments(expanded); + }, + }, + { at: "min" }, + ); + return yield* definition.fn(validatedProps); + }); if (asBinding) { - const parentEnv = yield* EvalEnvCtx.get(); + const parentEnv = yield* env(); if (!parentEnv) { - return yield* raise({ - type: "error", - message: `Prop "as" on <${name} /> requires a parent evaluation environment.`, - source: name, - }); + return [ + yield* raise({ + type: "error", + message: `Prop "as" on <${name} /> requires a parent evaluation environment.`, + source: name, + }), + ]; } parentEnv.values[asBinding] = output; return []; } return [{ type: "text", content: output }]; } catch (error) { - // A DocumentationError from a content-rendering path (renderDefault / - // useContent) is fail-fast — propagate it unchanged. + // A DocumentationError from a content-rendering path (useContent) is + // fail-fast — propagate it unchanged. if (error instanceof DocumentationError) { throw error; } - return yield* raise({ - type: "error", - message: - error instanceof Error - ? `Function component ${name} error: ${error.message}` - : `Function component ${name} error: ${String(error)}`, - source: name, - }); + return [ + yield* raise({ + type: "error", + message: + error instanceof Error + ? `Function component ${name} error: ${error.message}` + : `Function component ${name} error: ${String(error)}`, + source: name, + }), + ]; } } @@ -829,7 +825,7 @@ function* resolveExpressionProps( return resolved; } - const contextEnv = yield* EvalEnvCtx.get(); + const contextEnv = yield* env(); // For projected children (substituted via ), merge the // caller's env (explicitEnv) with the wrapping component's env @@ -1227,11 +1223,12 @@ function buildBody( /** * Expand a definition body (spec §6.9). Without a top-level ``, the * whole body renders (backward compatible). With ``, only the declared - * regions render; documentation executes for its side effects under a "throw" - * policy (fail-fast) and its rendered result is discarded; output regions run - * under "collect" (errors render as comments). Regions and documentation run - * in document order, so output can depend on bindings computed by preceding - * documentation. + * regions render; documentation executes for its side effects under a throwing + * raise policy (fail-fast) and its rendered result is discarded; output + * regions install a collecting raise that shadows any inherited throwing + * middleware (innermost min runs first), so their errors render as comments. + * Regions and documentation run in document order, so output can depend on + * bindings computed by preceding documentation. */ export function* expandBody( bodySegments: Segment[], @@ -1239,13 +1236,12 @@ export function* expandBody( meta: Record, props: Record, hideSet: Set, - ctx: ExpansionContext, counter: BlockCounter, callerEnv: EvalEnv | undefined, ): Operation { if (!bodyHasOutput(bodySegments)) { const substituted = substituteContent(bodySegments, children, meta, props, callerEnv); - return yield* expandSegments(substituted, meta, props, hideSet, ctx, counter); + return yield* expandSegments(substituted, meta, props, hideSet, counter); } const chunks = buildBody(bodySegments, children, meta, props, callerEnv); @@ -1253,34 +1249,35 @@ export function* expandBody( for (const chunk of chunks) { if (chunk.output) { - const expanded = yield* ErrorPolicyCtx.with("collect", function* () { - return yield* expandSegments(chunk.segments, meta, props, hideSet, ctx, counter); + const expanded = yield* scoped(function* () { + yield* Component.around( + { + // deno-lint-ignore require-yield + *raise([error], _next) { + return error; + }, + }, + { at: "min" }, + ); + return yield* expandSegments(chunk.segments, meta, props, hideSet, counter); }); output.push(...expanded); } else { // Documentation: execute for side effects, discard rendered output. - yield* ErrorPolicyCtx.with("throw", function* () { - return yield* expandSegments(chunk.segments, meta, props, hideSet, ctx, counter); + yield* scoped(function* () { + yield* Component.around( + { + // deno-lint-ignore require-yield + *raise([error], _next) { + throw new DocumentationError(error); + }, + }, + { at: "min" }, + ); + return yield* expandSegments(chunk.segments, meta, props, hideSet, counter); }); } } return output; } - -/** - * Consumer boundary (spec §6.9). When consuming a component's returned - * segments in a documentation context, re-raise the first transported error - * so it is never captured and hidden. Under "collect" it is a no-op. - */ -function* consume(segments: Segment[]): Operation { - const policy = yield* currentErrorPolicy(); - if (policy !== "throw") { - return; - } - for (const segment of segments) { - if (segment.type === "error") { - throw new DocumentationError(segment); - } - } -} diff --git a/core/src/modifiers.ts b/core/src/modifiers.ts index ebce5c8..82912f6 100644 --- a/core/src/modifiers.ts +++ b/core/src/modifiers.ts @@ -10,47 +10,24 @@ * - The middleware signature is Middleware<[], CodeBlockWorkflow> */ -import { createContext } from "effection"; +import { scoped } from "effection"; import type { Operation } from "effection"; import { ephemeral } from "@executablemd/durable-streams"; import type { Workflow } from "@executablemd/durable-streams"; import type { Middleware } from "@effectionx/middleware"; import { combine } from "@effectionx/middleware"; +import { Component, codeBlock } from "./component-api.ts"; import type { CodeBlockContext, CodeBlockResult, Modifier } from "./types.ts"; -// --------------------------------------------------------------------------- -// CodeBlock context — Effection scope-inherited value -// --------------------------------------------------------------------------- - -/** - * Effection Context holding the current code block's metadata. - * - * Set on the scope via `CodeBlockCtx.with()` before the modifier chain - * runs. Handlers that need the code block info (language, content, - * componentName) read it via `useCodeBlock()` instead of receiving it - * as a parameter. - */ -export const CodeBlockCtx = createContext("codeBlock"); - /** - * Read the current code block context from the Effection scope. + * Read the current code block context. * - * Returns a Workflow-compatible generator (bridged via `ephemeral`) - * so it can be `yield*`'d inside modifier handlers that run within - * durable workflows. - * - * @example - * ```typescript - * const myFactory: ModifierFactory = (_params) => - * (_args, next) => function* () { - * const ctx = yield* useCodeBlock(); - * console.log(ctx.language); - * return yield* next(); - * }(); - * ``` + * Ergonomic alias for the Component `codeBlock()` operation, bridged via + * `ephemeral` so it can be `yield*`'d inside modifier handlers that run + * within durable workflows. */ export function useCodeBlock(): Workflow { - return ephemeral(CodeBlockCtx.expect()); + return ephemeral(codeBlock()); } // --------------------------------------------------------------------------- @@ -125,9 +102,9 @@ export function createModifierRegistry(parent?: ModifierRegistry): ModifierRegis * leftmost modifier is the outermost wrapper. `combine` handles the * right-to-left reduction internally. * - * The `context` parameter is made available to handlers via Effection's - * `CodeBlockCtx.with()`, which sets the context on the scope for the - * duration of the chain execution and restores it afterward. + * The `context` parameter is made available to handlers via scope-local + * `Component.operations.codeBlock()` middleware, installed for the + * duration of the chain execution and removed when its scope exits. */ export function composeModifierChain( modifiers: Modifier[], @@ -156,13 +133,22 @@ export function composeModifierChain( // Combine all middlewares into a single middleware const composed = combine(middlewares); - // Return a thunk that sets CodeBlockCtx for the duration of the - // chain via Context.with(), then runs the composed middleware. + // Return a thunk that provides the code block contextually for the + // duration of the chain, then runs the composed middleware. // The cast is safe because CodeBlockWorkflow yields DurableEffect // values which extend Effect — structurally compatible with Operation. return function* () { return yield* ephemeral( - CodeBlockCtx.with(context, function* () { + scoped(function* () { + yield* Component.around( + { + // deno-lint-ignore require-yield + *codeBlock(_args, _next) { + return context; + }, + }, + { at: "min" }, + ); return yield* composed([], terminal) as unknown as Operation; }), ); diff --git a/core/src/modifiers/daemon.ts b/core/src/modifiers/daemon.ts index 1da0c31..39c61e9 100644 --- a/core/src/modifiers/daemon.ts +++ b/core/src/modifiers/daemon.ts @@ -15,7 +15,7 @@ import { ephemeral } from "@executablemd/durable-streams"; import { daemon } from "@effectionx/process"; import type { ModifierFactory } from "../modifiers.ts"; import { useCodeBlock } from "../modifiers.ts"; -import { EvalScopeCtx } from "../eval-env.ts"; +import { evalScope } from "../component-api.ts"; // --------------------------------------------------------------------------- // daemonFactory — terminal modifier (spec §3.3) @@ -26,7 +26,7 @@ import { EvalScopeCtx } from "../eval-env.ts"; * * Ignores `next` — this is the terminal handler (like `exec` and `eval`). * Reads code block metadata via useCodeBlock() and the eval scope via - * EvalScopeCtx. + * Component.evalScope(). * * The block's content (already interpolated with eval bindings by the * expansion engine) is used to build the subprocess command. The command @@ -46,14 +46,17 @@ export const daemonFactory: ModifierFactory = (_params) => (_args, _next) => // daemon produces no journal entry, so all its effects are ephemeral. const launchDaemon = { *[Symbol.iterator]() { - const evalScope = yield* EvalScopeCtx.expect(); + const scope = yield* evalScope(); + if (!scope) { + throw new Error("daemon requires a component eval scope; none is in scope."); + } // The block content is a raw shell command (e.g. the body of a // ```bash daemon exec``` block). Pass it directly to daemon() // with shell:true so @effectionx/process invokes the system // shell instead of splitting with shellwords — which would // mangle commands containing nested quotes. - yield* evalScope.eval(function* () { + yield* scope.eval(function* () { yield* daemon(ctx.content, { shell: true }); }); }, diff --git a/core/src/modifiers/persist.ts b/core/src/modifiers/persist.ts index 1369058..07a52d2 100644 --- a/core/src/modifiers/persist.ts +++ b/core/src/modifiers/persist.ts @@ -8,16 +8,17 @@ * evalScope.eval(), which retains spawned resources in the persistent * EvalScope until the component finishes expanding. * - * Implementation: persist sets a context flag (PersistFlagCtx) that the - * eval handler reads. The eval handler then runs fn(env) inside + * Implementation: persist installs Component.persistent() middleware that + * the eval handler reads. The eval handler then runs fn(env) inside * evalScope.eval() instead of directly. This avoids wrapping the entire * modifier chain (which includes durable effects that can't cross the * evalScope channel boundary). */ +import { scoped } from "effection"; import { ephemeral } from "@executablemd/durable-streams"; import type { ModifierFactory } from "../modifiers.ts"; -import { PersistFlagCtx } from "../eval-env.ts"; +import { Component } from "../component-api.ts"; // --------------------------------------------------------------------------- // persistFactory (spec §7.1) @@ -26,17 +27,23 @@ import { PersistFlagCtx } from "../eval-env.ts"; /** * Wrapping modifier that marks a block for persistent resource lifetime. * - * Sets PersistFlagCtx=true on the scope for the duration of the inner - * chain. The eval handler reads this flag and routes the compiled block + * Makes Component.persistent() answer true for the duration of the inner + * chain. The eval handler reads this and routes the compiled block * execution through evalScope.eval() for resource retention. */ export const persistFactory: ModifierFactory = (_params) => (_args, next) => (function* () { - // Set the persist flag on the scope, then delegate to the inner chain. - // evalFactory will read this flag and route execution through - // evalScope.eval() for resource retention. return yield* ephemeral( - PersistFlagCtx.with(true, function* () { + scoped(function* () { + yield* Component.around( + { + // deno-lint-ignore require-yield + *persistent(_args, _next) { + return true; + }, + }, + { at: "min" }, + ); return yield* next() as unknown as import("effection").Operation< import("../types.ts").CodeBlockResult >; diff --git a/core/src/run-document.ts b/core/src/run-document.ts index ace6af4..1dd0776 100644 --- a/core/src/run-document.ts +++ b/core/src/run-document.ts @@ -8,7 +8,7 @@ * See DEC-005 in specs/decisions.md. */ -import { useScope, spawn, createChannel, withResolvers, until } from "effection"; +import { scoped, spawn, createChannel, withResolvers, until } from "effection"; import type { Operation, Stream } from "effection"; import { durableRun, @@ -26,8 +26,6 @@ import type { FunctionComponentDefinition, InputDefinition, ImportResult, - Modifier, - CodeBlockContext, } from "./types.ts"; import { scanSegments } from "./scanner.ts"; import { parseFrontmatter } from "./frontmatter.ts"; @@ -38,7 +36,7 @@ import { validateOutputPlacement, createBlockCounter, } from "./expand.ts"; -import type { ExpansionContext } from "./expand.ts"; +import { Component, importComponent } from "./component-api.ts"; import { renderSegment } from "./render.ts"; import { DocumentOutput } from "./api.ts"; import { @@ -47,12 +45,11 @@ import { createModifierRegistry, useCodeBlock, } from "./modifiers.ts"; -import type { ModifierFactory, ModifierRegistry } from "./modifiers.ts"; +import type { ModifierFactory } from "./modifiers.ts"; import { evalFactory } from "./eval-handler.ts"; import { persistFactory } from "./modifiers/persist.ts"; import { timeoutFactory } from "./modifiers/timeout.ts"; import { daemonFactory } from "./modifiers/daemon.ts"; -import { EvalEnvCtx, EvalScopeCtx } from "./eval-env.ts"; import type { EvalEnv } from "./eval-env.ts"; import { useEvalScope } from "@effectionx/scope-eval"; import { Stdio } from "@effectionx/process"; @@ -252,45 +249,38 @@ const silentFactory: ModifierFactory = (_params) => (_args, next) => // calls inside the expansion engine. See DEC-002. // --------------------------------------------------------------------------- -function* documentWorkflow( - docPath: string, - searchPaths: string[], - modifierRegistry: ModifierRegistry, -): Workflow { - // Import root — same pipeline as any component - const root = yield* durableImportComponent("__root__", docPath, searchPaths); +function* documentWorkflow(): Workflow { + // Import root — same pipeline as any component. The provider middleware + // installed by runDocument maps "__root__" to the document path. The + // ephemeral() wrapper bridges typing only — the import inside remains a + // durable, journaled operation. + const root = yield* ephemeral(importComponent("__root__")); if (root.kind === "function") { throw new Error("Root document must be a markdown file, not a function component"); } - const env: EvalEnv = { values: {} }; - - // Build the expansion context - const ctx: ExpansionContext = { - importComponent: function* (name: string) { - return yield* durableImportComponent(name, undefined, searchPaths); - }, - runModifierChain: function* (modifiers: Modifier[], context: CodeBlockContext) { - const chain = composeModifierChain(modifiers, context, modifierRegistry); - return yield* chain(); - }, - }; + const rootEnv: EvalEnv = { values: {} }; // Per-root-segment emission loop (spec §9). // Mutable counter preserves deterministic blockIds across // per-segment expansion calls (see spec §6.1). const counter = createBlockCounter(); - // EvalScopeCtx is already set on the scope by runDocument. - // EvalEnvCtx is scoped to the document expansion lifetime via - // Context.with() (spec §3.1). Resources spawned by `persist` blocks - // are retained in the eval scope until expansion completes, then - // torn down. - // - // The EvalEnvCtx.with() wraps the entire loop so all segments share - // the same binding environment. - const scopedExpansion: Operation = EvalEnvCtx.with(env, function* () { + // The root binding environment is installed as scope-local middleware + // around the entire loop so all segments share it. Resources spawned by + // `persist` blocks are retained in the eval scope until expansion + // completes, then torn down. + const scopedExpansion: Operation = scoped(function* () { + yield* Component.around( + { + // deno-lint-ignore require-yield + *env(_args, _next) { + return rootEnv; + }, + }, + { at: "min" }, + ); // Structural preflight (spec §6.9): a root with misplaced // executes no body side effects; the aggregate diagnostic renders as a // comment (root policy is "collect"). @@ -312,7 +302,6 @@ function* documentWorkflow( root.meta, {}, new Set(), - ctx, counter, undefined, ); @@ -328,7 +317,7 @@ function* documentWorkflow( const chunks: string[] = []; for (const segment of root.bodySegments) { - const expanded = yield* expandSegments([segment], root.meta, {}, new Set(), ctx, counter); + const expanded = yield* expandSegments([segment], root.meta, {}, new Set(), counter); for (const resolved of expanded) { const text = renderSegment(resolved); @@ -437,7 +426,6 @@ export function* runDocument(options: RunDocumentOptions): Operation(); yield* spawn(function* () { - const scope = yield* useScope(); let emitted = false; // Install platform-appropriate compiler middleware @@ -466,11 +454,35 @@ export function* runDocument(options: RunDocumentOptions): Operation documentWorkflow(docPath, componentDirs, registry), { + const output = yield* durableRun(() => documentWorkflow(), { stream, }); diff --git a/core/tests/component-api.test.ts b/core/tests/component-api.test.ts new file mode 100644 index 0000000..746aa3b --- /dev/null +++ b/core/tests/component-api.test.ts @@ -0,0 +1,214 @@ +/** + * Component Api tests — default operations, missing-provider diagnostics, + * scoped middleware overrides, and nested provider precedence. + */ +import { describe, it } from "@effectionx/bdd/node"; +import { expect } from "@effectionx/bdd/expect"; +import { scoped } from "effection"; +import type { Operation } from "effection"; +import { + Component, + importComponent, + applyModifiers, + raise, + env, + evalScope, + codeBlock, + persistent, + content, +} from "../src/component-api.ts"; +import { persistFactory } from "../src/modifiers/persist.ts"; +import type { ComponentDefinition, ErrorSegment } from "../src/types.ts"; + +function stubComponent(name: string): ComponentDefinition { + return { kind: "markdown", name, path: `${name}.md`, meta: {}, inputs: {}, bodySegments: [] }; +} + +function* messageOf(operation: Operation): Operation { + try { + yield* operation; + return ""; + } catch (error) { + return error instanceof Error ? error.message : String(error); + } +} + +describe("Component Api", () => { + // ----- Defaults ----- + + it("importComponent without a provider throws a missing-provider error", function* () { + const message = yield* messageOf(importComponent("Missing")); + expect(message).toContain('importComponent("Missing")'); + expect(message).toContain("has no provider"); + }); + + it("applyModifiers without a provider throws a missing-provider error", function* () { + const block = { language: "bash", content: "x", blockId: "test:0" }; + const message = yield* messageOf(applyModifiers([{ name: "exec" }], block)); + expect(message).toContain("applyModifiers"); + expect(message).toContain("has no provider"); + }); + + it("codeBlock without a provider throws a missing-provider error", function* () { + const message = yield* messageOf(codeBlock()); + expect(message).toContain("codeBlock"); + expect(message).toContain("has no provider"); + }); + + it("content without a provider throws a missing-provider error", function* () { + const message = yield* messageOf(content()); + expect(message).toContain("content"); + expect(message).toContain("has no provider"); + }); + + it("raise returns the supplied ErrorSegment by default", function* () { + const segment: ErrorSegment = { type: "error", message: "boom", source: "test" }; + const returned = yield* raise(segment); + expect(returned).toBe(segment); + }); + + it("env and evalScope default to undefined; persistent defaults to false", function* () { + expect(yield* env()).toBe(undefined); + expect(yield* evalScope()).toBe(undefined); + expect(yield* persistent()).toBe(false); + }); + + // ----- Scoped overrides ----- + + it("a scoped max middleware wraps importComponent and is removed on scope exit", function* () { + const real = stubComponent("Real"); + const aliased = stubComponent("Aliased"); + yield* Component.around( + { + // deno-lint-ignore require-yield + *importComponent([name], _next) { + if (name === "Real") { + return real; + } + throw new Error(`Component not found: ${name}`); + }, + }, + { at: "min" }, + ); + + const inside = yield* scoped(function* () { + yield* Component.around({ + *importComponent([name], next) { + if (name === "Alias") { + return aliased; + } + return yield* next(name); + }, + }); + return { + alias: yield* importComponent("Alias"), + delegated: yield* importComponent("Real"), + }; + }); + expect(inside.alias).toBe(aliased); + expect(inside.delegated).toBe(real); + + // Outside the scope the alias middleware is gone. + const message = yield* messageOf(importComponent("Alias")); + expect(message).toContain("Component not found: Alias"); + }); + + it("a scoped applyModifiers provider overrides an inherited one and is removed on exit", function* () { + const block = { language: "bash", content: "x", blockId: "test:0" }; + yield* Component.around( + { + // deno-lint-ignore require-yield + *applyModifiers(_args, _next) { + return { output: "outer\n", exitCode: 0, stderr: "" }; + }, + }, + { at: "min" }, + ); + + const inner = yield* scoped(function* () { + yield* Component.around( + { + // deno-lint-ignore require-yield + *applyModifiers(_args, _next) { + return { output: "inner\n", exitCode: 0, stderr: "" }; + }, + }, + { at: "min" }, + ); + return yield* applyModifiers([], block); + }); + expect(inner.output).toBe("inner\n"); + + const outer = yield* applyModifiers([], block); + expect(outer.output).toBe("outer\n"); + }); + + // ----- Nested precedence without sibling leakage ----- + + it("nested env providers override ancestors without leaking into siblings", function* () { + const outerEnv = { values: { tag: "outer" } }; + const innerEnv = { values: { tag: "inner" } }; + yield* Component.around( + { + // deno-lint-ignore require-yield + *env(_args, _next) { + return outerEnv; + }, + }, + { at: "min" }, + ); + + const first = yield* scoped(function* () { + yield* Component.around( + { + // deno-lint-ignore require-yield + *env(_args, _next) { + return innerEnv; + }, + }, + { at: "min" }, + ); + return yield* env(); + }); + expect(first).toBe(innerEnv); + + // A sibling scope sees the ancestor provider, not the first sibling's. + const second = yield* scoped(function* () { + return yield* env(); + }); + expect(second).toBe(outerEnv); + }); + + // ----- Persistent evaluation flag ----- + + it("persistent() is true inside the persist modifier chain and false outside", function* () { + let observed: boolean | undefined = undefined; + const middleware = persistFactory(undefined); + const terminal = function* () { + observed = yield* persistent(); + return { output: "", exitCode: 0, stderr: "" }; + }; + yield* middleware([], terminal) as unknown as Operation; + expect(observed).toBe(true); + expect(yield* persistent()).toBe(false); + }); + + // ----- Content provider dispatch ----- + + it("content(slot) dispatches through the installed provider", function* () { + yield* Component.around( + { + // deno-lint-ignore require-yield + *content([slot], _next) { + if (slot !== undefined) { + return `slot:${slot}`; + } + return "default"; + }, + }, + { at: "min" }, + ); + expect(yield* content()).toBe("default"); + expect(yield* content("header")).toBe("slot:header"); + }); +}); diff --git a/core/tests/daemon.test.ts b/core/tests/daemon.test.ts index 208afef..25f2cc8 100644 --- a/core/tests/daemon.test.ts +++ b/core/tests/daemon.test.ts @@ -6,9 +6,10 @@ */ import { describe, it } from "@effectionx/bdd/node"; import { expect } from "@effectionx/bdd/expect"; +import { scoped } from "effection"; import { daemonFactory } from "../src/modifiers/daemon.ts"; import { combine } from "@effectionx/middleware"; -import { CodeBlockCtx } from "../src/modifiers.ts"; +import { Component } from "../src/component-api.ts"; import type { ModifierFactory, ModifierMiddleware } from "../src/modifiers.ts"; import type { Operation } from "effection"; @@ -63,16 +64,14 @@ describe("Tier Q — Daemon modifier", () => { expect(factory.length).toBe(2); }); - // Q10b: daemon without EvalScopeCtx throws a clear error - // When the daemon middleware runs in a scope where EvalScopeCtx - // is not set, EvalScopeCtx.expect() should throw. - it("Q10b: daemon without EvalScopeCtx throws clear error", function* () { + // Q10b: daemon without an eval scope in scope throws a clear error + it("Q10b: daemon without an eval scope throws clear error", function* () { const middleware = daemonFactory(undefined); const terminal = function* () { return { output: "", exitCode: 0, stderr: "" }; }; - // Provide CodeBlockCtx (required by useCodeBlock) but NOT EvalScopeCtx + // Provide the code block (required by useCodeBlock) but no eval scope const fakeContext = { language: "bash", content: "echo hello", @@ -83,10 +82,16 @@ describe("Tier Q — Daemon modifier", () => { let errorMessage = ""; try { - yield* CodeBlockCtx.with(fakeContext, function* () { - // The daemon middleware generator yields ephemeral operations. - // When it reaches EvalScopeCtx.expect() without the context set, - // it should throw. + yield* scoped(function* () { + yield* Component.around( + { + // deno-lint-ignore require-yield + *codeBlock(_args, _next) { + return fakeContext; + }, + }, + { at: "min" }, + ); yield* middleware([], terminal) as unknown as Operation; }); } catch (e) { @@ -95,7 +100,6 @@ describe("Tier Q — Daemon modifier", () => { } expect(threw).toBe(true); - // The error should mention the missing context - expect(errorMessage.toLowerCase()).toMatch(/evalscope|context/); + expect(errorMessage).toContain("daemon requires a component eval scope"); }); }); diff --git a/core/tests/eval-middleware.test.ts b/core/tests/eval-middleware.test.ts index abc1a67..2d3b01a 100644 --- a/core/tests/eval-middleware.test.ts +++ b/core/tests/eval-middleware.test.ts @@ -53,7 +53,7 @@ describe("Tier T3 — Middleware factory conformance", () => { expect(typeof middleware).toBe("function"); }); - // T28: EvalEnvCtx accessible pattern + // T28: binding environment accessible pattern it("T28: evalFactory creates a generator when called", function* () { const middleware = evalFactory(undefined); // Call the middleware with dummy args and next diff --git a/core/tests/expand.test.ts b/core/tests/expand.test.ts index 7af6920..0da4e82 100644 --- a/core/tests/expand.test.ts +++ b/core/tests/expand.test.ts @@ -1,21 +1,15 @@ import { describe, it } from "@effectionx/bdd/node"; import { expect } from "@effectionx/bdd/expect"; +import { scoped } from "effection"; import { expandSegments } from "../src/expand.ts"; -import type { ExpansionContext } from "../src/expand.ts"; +import { Component } from "../src/component-api.ts"; import { scanSegments } from "../src/scanner.ts"; import { interpolate } from "../src/interpolate.ts"; import { validateProps, PropValidationError } from "../src/validate.ts"; import { renderSegments } from "../src/render.ts"; import type { Operation } from "effection"; -import { EvalEnvCtx } from "../src/eval-env.ts"; -import type { - Segment, - ComponentDefinition, - Json, - CodeBlockResult, - Modifier, - CodeBlockContext, -} from "../src/types.ts"; +import type { EvalEnv } from "../src/eval-env.ts"; +import type { Segment, ComponentDefinition, Json, CodeBlockResult } from "../src/types.ts"; // --------------------------------------------------------------------------- // Helpers @@ -39,56 +33,78 @@ function makeComponent( }; } -function makeCtx( +/** Install test component + modifier providers on the current scope. */ +function* useTestComponents( components: Record, codeResult?: CodeBlockResult, -): ExpansionContext { - return { - importComponent: function* (name: string) { - const comp = components[name]; - if (!comp) throw new Error(`Component not found: ${name}`); - return comp; - }, - runModifierChain: function* (_modifiers: Modifier[], _context: CodeBlockContext) { - return ( - codeResult ?? { - output: "mock output\n", - exitCode: 0, - stderr: "", +): Operation { + yield* Component.around( + { + // deno-lint-ignore require-yield + *importComponent([name], _next) { + const comp = components[name]; + if (!comp) { + throw new Error(`Component not found: ${name}`); } - ); + return comp; + }, + // deno-lint-ignore require-yield + *applyModifiers(_args, _next) { + return ( + codeResult ?? { + output: "mock output\n", + exitCode: 0, + stderr: "", + } + ); + }, }, - }; + { at: "min" }, + ); +} + +/** Install a binding environment on the current scope. */ +function* useTestEnv(testEnv: EvalEnv): Operation { + yield* Component.around( + { + // deno-lint-ignore require-yield + *env(_args, _next) { + return testEnv; + }, + }, + { at: "min" }, + ); } function expand( segments: Segment[], - ctx: ExpansionContext, - meta: Record = {}, - props: Record = {}, + components: Record, + opts: { + meta?: Record; + props?: Record; + codeResult?: CodeBlockResult; + } = {}, ): Operation { - function* op() { - return yield* EvalEnvCtx.with({ values: {} }, function* () { - const expanded = yield* expandSegments(segments, meta, props, new Set(), ctx); - return renderSegments(expanded); - }); - } - return op() as unknown as Operation; + return scoped(function* () { + yield* useTestComponents(components, opts.codeResult); + yield* useTestEnv({ values: {} }); + const expanded = yield* expandSegments(segments, opts.meta ?? {}, opts.props ?? {}, new Set()); + return renderSegments(expanded); + }); } function expandWithEnv( segments: Segment[], - ctx: ExpansionContext, + components: Record, + codeResult?: CodeBlockResult, ): Operation<{ output: string; env: Record }> { - function* op() { - const env = { values: {} as Record }; - const output = yield* EvalEnvCtx.with(env, function* () { - const expanded = yield* expandSegments(segments, {}, {}, new Set(), ctx); - return renderSegments(expanded); - }); - return { output, env: env.values }; - } - return op() as unknown as Operation<{ output: string; env: Record }>; + return scoped(function* () { + const testEnv: EvalEnv = { values: {} }; + yield* useTestComponents(components, codeResult); + yield* useTestEnv(testEnv); + const expanded = yield* expandSegments(segments, {}, {}, new Set()); + return { output: renderSegments(expanded), env: testEnv.values }; + }); } // --------------------------------------------------------------------------- @@ -99,7 +115,7 @@ describe("expansion", () => { // C1: Basic expansion it("C1: basic expansion — component body in output", function* () { const comp = makeComponent("Greeting", "Hello world!"); - const ctx = makeCtx({ Greeting: comp }); + const ctx = { Greeting: comp }; const segments = scanSegments(""); const output = yield* expand(segments, ctx); expect(output).toBe("Hello world!"); @@ -108,7 +124,7 @@ describe("expansion", () => { // C2: Content slot it("C2: content slot — children at position", function* () { const comp = makeComponent("Wrap", "Before After"); - const ctx = makeCtx({ Wrap: comp }); + const ctx = { Wrap: comp }; const segments = scanSegments("middle"); const output = yield* expand(segments, ctx); expect(output).toBe("Before middle After"); @@ -118,7 +134,7 @@ describe("expansion", () => { it("C3: nested expansion — A contains B", function* () { const compB = makeComponent("B", "inner"); const compA = makeComponent("A", "outer end"); - const ctx = makeCtx({ A: compA, B: compB }); + const ctx = { A: compA, B: compB }; const segments = scanSegments(""); const output = yield* expand(segments, ctx); expect(output).toBe("outer inner end"); @@ -129,7 +145,7 @@ describe("expansion", () => { const compC = makeComponent("C", "leaf"); const compB = makeComponent("B", "mid()"); const compA = makeComponent("A", "top()"); - const ctx = makeCtx({ A: compA, B: compB, C: compC }); + const ctx = { A: compA, B: compB, C: compC }; const segments = scanSegments(""); const output = yield* expand(segments, ctx); expect(output).toBe("top(mid(leaf))"); @@ -138,7 +154,7 @@ describe("expansion", () => { // C5: Direct cycle it("C5: direct cycle — A contains A → ErrorSegment", function* () { const compA = makeComponent("A", "start end"); - const ctx = makeCtx({ A: compA }); + const ctx = { A: compA }; const segments = scanSegments(""); const output = yield* expand(segments, ctx); expect(output).toContain("ERROR"); @@ -149,7 +165,7 @@ describe("expansion", () => { it("C6: mutual cycle — A→B→A → ErrorSegment", function* () { const compA = makeComponent("A", "a()"); const compB = makeComponent("B", "b()"); - const ctx = makeCtx({ A: compA, B: compB }); + const ctx = { A: compA, B: compB }; const segments = scanSegments(""); const output = yield* expand(segments, ctx); expect(output).toContain("ERROR"); @@ -161,7 +177,7 @@ describe("expansion", () => { const comp = makeComponent("Page", "Title: {meta.title}", { meta: { title: "My Page" }, }); - const ctx = makeCtx({ Page: comp }); + const ctx = { Page: comp }; const segments = scanSegments(""); const output = yield* expand(segments, ctx); expect(output).toBe("Title: My Page"); @@ -172,7 +188,7 @@ describe("expansion", () => { const comp = makeComponent("Greeting", "Hello, {props.name}!", { inputs: { name: { type: "string", required: true } }, }); - const ctx = makeCtx({ Greeting: comp }); + const ctx = { Greeting: comp }; const segments = scanSegments(''); const output = yield* expand(segments, ctx); expect(output).toBe("Hello, world!"); @@ -181,7 +197,7 @@ describe("expansion", () => { // C10: Missing interpolation key → empty string it("C10: missing interpolation key → empty string", function* () { const comp = makeComponent("Comp", "value: {meta.nonexistent}"); - const ctx = makeCtx({ Comp: comp }); + const ctx = { Comp: comp }; const segments = scanSegments(""); const output = yield* expand(segments, ctx); expect(output).toBe("value: "); @@ -192,7 +208,7 @@ describe("expansion", () => { const comp = makeComponent("Comp", "host: {meta.config.db.host}", { meta: { config: { db: { host: "localhost" } } }, }); - const ctx = makeCtx({ Comp: comp }); + const ctx = { Comp: comp }; const segments = scanSegments(""); const output = yield* expand(segments, ctx); expect(output).toBe("host: localhost"); @@ -201,7 +217,7 @@ describe("expansion", () => { // C12: No Content slot — children silently discarded it("C12: no Content slot — children silently discarded", function* () { const comp = makeComponent("NoSlot", "fixed content"); - const ctx = makeCtx({ NoSlot: comp }); + const ctx = { NoSlot: comp }; const segments = scanSegments("ignored"); const output = yield* expand(segments, ctx); expect(output).toBe("fixed content"); @@ -210,7 +226,7 @@ describe("expansion", () => { // C13: Multiple Content slots it("C13: multiple Content slots — each replaced with same children", function* () { const comp = makeComponent("Multi", "first: second: "); - const ctx = makeCtx({ Multi: comp }); + const ctx = { Multi: comp }; const segments = scanSegments("stuff"); const output = yield* expand(segments, ctx); expect(output).toBe("first: stuff second: stuff"); @@ -221,7 +237,7 @@ describe("expansion", () => { const comp = makeComponent("Greeting", "{props.greeting}, world!", { inputs: { greeting: { type: "string", default: "Hello" } }, }); - const ctx = makeCtx({ Greeting: comp }); + const ctx = { Greeting: comp }; const segments = scanSegments(""); const output = yield* expand(segments, ctx); expect(output).toBe("Hello, world!"); @@ -230,7 +246,7 @@ describe("expansion", () => { // C20: No inputs, no props — valid it("C20: no inputs, no props — valid", function* () { const comp = makeComponent("Badge", "badge"); - const ctx = makeCtx({ Badge: comp }); + const ctx = { Badge: comp }; const segments = scanSegments(""); const output = yield* expand(segments, ctx); expect(output).toBe("badge"); @@ -241,7 +257,7 @@ describe("expansion", () => { const comp = makeComponent("Comp", "val:{props.opt}", { inputs: { opt: { type: "string", required: false } }, }); - const ctx = makeCtx({ Comp: comp }); + const ctx = { Comp: comp }; const segments = scanSegments(""); const output = yield* expand(segments, ctx); expect(output).toBe("val:"); @@ -249,32 +265,47 @@ describe("expansion", () => { // Code block expansion it("code block expansion via modifier chain", function* () { - const ctx = makeCtx({}, { output: "hello\n", exitCode: 0, stderr: "" }); const segments = scanSegments("```bash exec\necho hello\n```\n"); - const output = yield* expand(segments, ctx); + const output = yield* expand( + segments, + {}, + { + codeResult: { output: "hello\n", exitCode: 0, stderr: "" }, + }, + ); expect(output).toBe("hello\n"); }); // Code block with non-zero exit it("code block with non-zero exit → error", function* () { - const ctx = makeCtx({}, { output: "", exitCode: 1, stderr: "not found" }); const segments = scanSegments("```bash exec\nfoo\n```\n"); - const output = yield* expand(segments, ctx); + const output = yield* expand( + segments, + {}, + { + codeResult: { output: "", exitCode: 1, stderr: "not found" }, + }, + ); expect(output).toContain("ERROR"); expect(output).toContain("not found"); }); // Silent code block → no output it("silent code block produces no output", function* () { - const ctx = makeCtx({}, { output: "", exitCode: 0, stderr: "" }); const segments = scanSegments("```bash silent exec\necho hello\n```\n"); - const output = yield* expand(segments, ctx); + const output = yield* expand( + segments, + {}, + { + codeResult: { output: "", exitCode: 0, stderr: "" }, + }, + ); expect(output).toBe(""); }); it("captures component output with as", function* () { const comp = makeComponent("Greeting", "Hello world!"); - const ctx = makeCtx({ Greeting: comp }); + const ctx = { Greeting: comp }; const segments = scanSegments(''); const { output, env } = yield* expandWithEnv(segments, ctx); expect(output).toBe(""); @@ -282,7 +313,7 @@ describe("expansion", () => { }); it("Capture stores children output into env and stays silent", function* () { - const ctx = makeCtx({}); + const ctx = {}; const segments = scanSegments('hello\n'); const { output, env } = yield* expandWithEnv(segments, ctx); expect(output).toBe(""); @@ -290,7 +321,7 @@ describe("expansion", () => { }); it("Capture rejects expression as prop", function* () { - const ctx = makeCtx({}); + const ctx = {}; const segments = scanSegments("text"); const output = yield* expand(segments, ctx); expect(output).toContain("ERROR"); @@ -298,7 +329,7 @@ describe("expansion", () => { }); it("Capture rejects self-closing usage", function* () { - const ctx = makeCtx({}); + const ctx = {}; const segments = scanSegments(''); const output = yield* expand(segments, ctx); expect(output).toContain("ERROR"); @@ -306,7 +337,7 @@ describe("expansion", () => { }); it("Capture rejects extra props", function* () { - const ctx = makeCtx({}); + const ctx = {}; const segments = scanSegments('text'); const output = yield* expand(segments, ctx); expect(output).toContain("ERROR"); @@ -314,7 +345,7 @@ describe("expansion", () => { }); it("Capture with select extracts code block by CSS selector", function* () { - const ctx = makeCtx({}); + const ctx = {}; const segments = scanSegments( 'prose text\n\n```json\n{"key":"val"}\n```\n\nmore prose\n', ); @@ -324,7 +355,7 @@ describe("expansion", () => { }); it("Capture with select falls back to full content when no match", function* () { - const ctx = makeCtx({}); + const ctx = {}; const segments = scanSegments( 'no code here\n', ); @@ -334,7 +365,7 @@ describe("expansion", () => { }); it("Capture with select extracts paragraph text", function* () { - const ctx = makeCtx({}); + const ctx = {}; const segments = scanSegments('Hello world\n'); const { output, env } = yield* expandWithEnv(segments, ctx); expect(output).toBe(""); @@ -342,7 +373,7 @@ describe("expansion", () => { }); it("Capture accepts select alongside as without error", function* () { - const ctx = makeCtx({}); + const ctx = {}; const segments = scanSegments('text\n'); const output = yield* expand(segments, ctx); expect(output).not.toContain("ERROR"); @@ -350,7 +381,7 @@ describe("expansion", () => { it("component as rejects expression prop", function* () { const comp = makeComponent("Greeting", "Hello world!"); - const ctx = makeCtx({ Greeting: comp }); + const ctx = { Greeting: comp }; const segments = scanSegments(""); const output = yield* expand(segments, ctx); expect(output).toContain("ERROR"); @@ -362,24 +393,38 @@ describe("expansion", () => { // Component-declared output — (spec §6.9) // --------------------------------------------------------------------------- -/** ExpansionContext that records executed code-block contents. */ -function recordingCtx( - components: Record, - codeResult?: CodeBlockResult, -): { ctx: ExpansionContext; execCalls: string[] } { +/** + * Install a recording applyModifiers provider and return the captured + * code-block contents. Installed after useTestComponents in the same scope, + * so it wins (later-installed min middleware runs first). + */ +function* useRecordingModifiers(codeResult?: CodeBlockResult): Operation { const execCalls: string[] = []; - const ctx: ExpansionContext = { - importComponent: function* (name: string) { - const comp = components[name]; - if (!comp) throw new Error(`Component not found: ${name}`); - return comp; - }, - runModifierChain: function* (_modifiers: Modifier[], context: CodeBlockContext) { - execCalls.push(context.content); - return codeResult ?? { output: "ran\n", exitCode: 0, stderr: "" }; + yield* Component.around( + { + // deno-lint-ignore require-yield + *applyModifiers([_modifiers, block], _next) { + execCalls.push(block.content); + return codeResult ?? { output: "ran\n", exitCode: 0, stderr: "" }; + }, }, - }; - return { ctx, execCalls }; + { at: "min" }, + ); + return execCalls; +} + +function recordingExpand( + segments: Segment[], + components: Record, + codeResult?: CodeBlockResult, +): Operation<{ output: string; execCalls: string[] }> { + return scoped(function* () { + yield* useTestComponents(components); + const execCalls = yield* useRecordingModifiers(codeResult); + yield* useTestEnv({ values: {} }); + const expanded = yield* expandSegments(segments, {}, {}, new Set()); + return { output: renderSegments(expanded), execCalls }; + }); } describe("component-declared output", () => { @@ -388,7 +433,7 @@ describe("component-declared output", () => { "Warn", "Docs heading.\n\n\nSHOWN\n\n\nMore docs.\n", ); - const ctx = makeCtx({ Warn: comp }); + const ctx = { Warn: comp }; const output = yield* expand(scanSegments(""), ctx); expect(output).toContain("SHOWN"); expect(output).not.toContain("Docs heading"); @@ -397,7 +442,7 @@ describe("component-declared output", () => { it("without renders the complete body", function* () { const comp = makeComponent("Doc", "Alpha then Beta."); - const ctx = makeCtx({ Doc: comp }); + const ctx = { Doc: comp }; const output = yield* expand(scanSegments(""), ctx); expect(output).toContain("Alpha then Beta."); }); @@ -407,7 +452,7 @@ describe("component-declared output", () => { "Multi", "ONE\n\nmiddle docs\n\nTWO\n", ); - const ctx = makeCtx({ Multi: comp }); + const ctx = { Multi: comp }; const output = yield* expand(scanSegments(""), ctx); expect(output).not.toContain("middle docs"); expect(output.indexOf("ONE")).toBeGreaterThanOrEqual(0); @@ -419,7 +464,7 @@ describe("component-declared output", () => { "Adm", "docs\n\n\n> [!WARNING]\n> Careful now.\n\n", ); - const ctx = makeCtx({ Adm: comp }); + const ctx = { Adm: comp }; const output = yield* expand(scanSegments(""), ctx); expect(output).toContain("> [!WARNING]"); expect(output).toContain("> Careful now."); @@ -429,7 +474,7 @@ describe("component-declared output", () => { it("treats and as equivalent empty output", function* () { const selfClosing = makeComponent("A", "before\n\n\n\nafter"); const paired = makeComponent("B", "before\n\n\n\nafter"); - const ctx = makeCtx({ A: selfClosing, B: paired }); + const ctx = { A: selfClosing, B: paired }; const a = yield* expand(scanSegments(""), ctx); const b = yield* expand(scanSegments(""), ctx); expect(a.trim()).toBe(""); @@ -438,7 +483,7 @@ describe("component-declared output", () => { it("rejects props on ", function* () { const comp = makeComponent("Bad", 'x'); - const ctx = makeCtx({ Bad: comp }); + const ctx = { Bad: comp }; const output = yield* expand(scanSegments(""), ctx); expect(output).toContain("ERROR"); expect(output).toContain("accepts no props"); @@ -446,7 +491,7 @@ describe("component-declared output", () => { it("rejects expression props on ", function* () { const comp = makeComponent("Bad", "y"); - const ctx = makeCtx({ Bad: comp }); + const ctx = { Bad: comp }; const output = yield* expand(scanSegments(""), ctx); expect(output).toContain("ERROR"); expect(output).toContain("accepts no props"); @@ -454,7 +499,7 @@ describe("component-declared output", () => { it("projects caller content through inside ", function* () { const comp = makeComponent("Wrap", "docs\n\n\n\n\n"); - const ctx = makeCtx({ Wrap: comp }); + const ctx = { Wrap: comp }; const output = yield* expand(scanSegments("PROJECTED"), ctx); expect(output).toContain("PROJECTED"); expect(output).not.toContain("docs"); @@ -465,15 +510,14 @@ describe("component-declared output", () => { "Dep", 'HELLO\n\nmsg={msg}', ); - const ctx = makeCtx({ Dep: comp }); + const ctx = { Dep: comp }; const output = yield* expand(scanSegments(""), ctx); expect(output).toContain("msg=HELLO"); }); it("executes exec blocks outside but suppresses their output", function* () { const comp = makeComponent("Ex", "```bash exec\nDOCRUN\n```\n\nok\n"); - const { ctx, execCalls } = recordingCtx({ Ex: comp }); - const output = yield* expand(scanSegments(""), ctx); + const { output, execCalls } = yield* recordingExpand(scanSegments(""), { Ex: comp }); expect(execCalls.some((c) => c.includes("DOCRUN"))).toBe(true); expect(output).toContain("ok"); expect(output).not.toContain("ran"); @@ -481,15 +525,14 @@ describe("component-declared output", () => { it("executes documentation after an region", function* () { const comp = makeComponent("Post", "ok\n\n```bash exec\nAFTER\n```\n"); - const { ctx, execCalls } = recordingCtx({ Post: comp }); - const output = yield* expand(scanSegments(""), ctx); + const { output, execCalls } = yield* recordingExpand(scanSegments(""), { Post: comp }); expect(execCalls.some((c) => c.includes("AFTER"))).toBe(true); expect(output).toContain("ok"); }); it("keeps errors inside an region as comments", function* () { const comp = makeComponent("Err", "\n\n"); - const ctx = makeCtx({ Err: comp }); + const ctx = { Err: comp }; const output = yield* expand(scanSegments(""), ctx); expect(output).toContain("