Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 28 additions & 4 deletions packages/core/src/expand.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2796,13 +2796,16 @@ function makeProjectFn(callerEnv: EvalEnv | undefined): ProjectFn {
return segments;
}
return segments.map((seg) => {
if (seg.type === "component") {
if (seg.type === "component" && seg.name !== "Content") {
return {
...seg,
projectedEnv: callerEnv,
children: project(seg.children),
};
}
// A `<Content />` passing through is a projection the invocation that
// wrote it already resolved: its content carries that invocation's env,
// and it is recognized by identity, which a copy would lose.
return seg;
});
};
Expand All @@ -2815,6 +2818,11 @@ function makeProjectFn(callerEnv: EvalEnv | undefined): ProjectFn {
* expansion frame is installed, so a current binding named `props` is used.
* Slot validation errors are emitted once, at the first projection point,
* tracked via the shared `state`.
*
* A projection point is wherever the body writes one, so this descends through
* every authored element on the way. Only the body is walked: what a projection
* resolves to belongs to the caller, and the caller's own body already
* substituted it.
*/
function substituteSegmentList(
segments: Segment[],
Expand All @@ -2824,7 +2832,10 @@ function substituteSegmentList(
claim: ClaimFn,
): Segment[] {
return segments.flatMap((segment): Segment[] => {
if (segment.type === "component" && segment.name === "Content") {
if (segment.type !== "component") {
return [segment];
}
if (segment.name === "Content") {
const targetSlot = segment.props.slot;
const pendingErrors = !state.errorsEmitted ? slots.errors : [];
if (pendingErrors.length > 0) {
Expand All @@ -2839,10 +2850,23 @@ function substituteSegmentList(
targetSlot !== undefined
? project((slots.named.get(String(targetSlot)) ?? []).map(stripSlotProp))
: project(slots.default);
const element: ComponentElement = { ...segment, children: projected, selfClosing: false };
// `slot` names which of this invocation's slots to read, and reading
// them consumes it (Β§6.3.5). A resolved projection nested inside another
// invocation is ordinary content there, so it partitions by position.
const { slot: _, ...props } = segment.props;
const element: ComponentElement = {
...segment,
props,
children: projected,
selfClosing: false,
};
return [...pendingErrors, claim(element)];
}
return [segment];
const children = substituteSegmentList(segment.children, slots, project, state, claim);
const untouched =
children.length === segment.children.length &&
children.every((child, index) => child === segment.children[index]);
return untouched ? [segment] : [{ ...segment, children }];
});
}

Expand Down
20 changes: 20 additions & 0 deletions packages/core/tests/expansion-identity.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -443,6 +443,26 @@ describe("Tier XP β€” expansion identity", () => {
expect(twoProbes[0]).not.toBe(twoProbes[1]);
});

// A projection written inside an iterating construct is one authored
// `<Content />` expanded once per item, so its identity has to come from the
// path it expands under rather than from the element. `<Each>` adds its item
// frame before the projection point is reached; dropping that frame, or
// deriving the projection from the element that carries it, is what this
// kills β€” both would report one identifier twice.
it("XP26: a projection nested in <Each> differs per iteration and reproduces itself", function* () {
const source = "<EachHost>\n<Probe />\n</EachHost>\n";
const definitions = {
EachHost: markdown("EachHost", '<Each in={[1, 2]} let="n">\n<Content />\n</Each>\n'),
};

const first = yield* identifiers(source, definitions);
const second = yield* identifiers(source, definitions);

expect(first).toHaveLength(2);
expect(first[0]).not.toBe(first[1]);
expect(second).toEqual(first);
});

// The projection ordinal is the one discriminator that is not read off the
// source, so it has to follow the component's own program order rather than
// the order projections finish in. Two runs of one component, with the
Expand Down
16 changes: 16 additions & 0 deletions packages/core/tests/invocation-scope.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,22 @@ describe("Tier O β€” Eval scope hierarchy", () => {
expect(timeline).toEqual(["start:projected", "start:own", "stop:projected", "stop:own"]);
});

// O41: the same contract when the projection point sits inside another
// invocation. The wrapper completes before the provider retains its own
// resource, so a projection that had moved into the wrapper's scope would
// stop before `start:own` rather than after it.
it("O41: a nested <Content /> keeps the invocation's content scope", function* () {
const timeline: string[] = [];
const definitions = {
Provider: markdown("Provider", `<Wrapper><Content /></Wrapper>\n\n${WATCH_BLOCK_OWN}`),
Wrapper: markdown("Wrapper", "<Content />"),
};

yield* expandAll(`<Provider>\n${WATCH_BLOCK}\n</Provider>`, definitions, timeline);

expect(timeline).toEqual(["start:projected", "start:own", "stop:projected", "stop:own"]);
});

// O11/O12: the same teardown order on a propagated body error, for both
// component forms. The body throws after projecting, so the projected
// resource is still alive when the invocation starts unwinding.
Expand Down
180 changes: 149 additions & 31 deletions packages/core/tests/named-slots.test.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
/**
* Named slots tests β€” Tiers NS-A through NS-G.
* Named slots tests β€” Tiers NS-A through NS-H.
*
* Covers slot partitioning, content substitution, expansion integration,
* slot prop reservation, renderChildren interaction, edge cases, and
* boundary scanner confirmation.
* slot prop reservation, renderChildren interaction, edge cases,
* boundary scanner confirmation, and nested projection points.
*/

import { describe, it, beforeAll } from "@executablemd/test-support/bdd";
Expand Down Expand Up @@ -162,10 +162,6 @@ function cleanup(dir: string): void {
fs.rmSync(dir, { recursive: true, force: true });
}

// ═══════════════════════════════════════════════════════════════════════════
// Tier NS-A β€” Slot partitioning (unit)
// ═══════════════════════════════════════════════════════════════════════════

describe("Tier NS-A β€” Slot partitioning", () => {
// deno-lint-ignore require-yield
it("NS-A1: no slot props β€” all in default", function* () {
Expand Down Expand Up @@ -268,10 +264,6 @@ describe("Tier NS-A β€” Slot partitioning", () => {
});
});

// ═══════════════════════════════════════════════════════════════════════════
// Tier NS-B β€” Content substitution (unit)
// ═══════════════════════════════════════════════════════════════════════════

describe("Tier NS-B β€” Content substitution", () => {
it("NS-B1: backward compat β€” no slots anywhere", function* () {
const layout = makeComponent("Layout", "before\n<Content />\nafter");
Expand Down Expand Up @@ -410,10 +402,6 @@ describe("Tier NS-B β€” Content substitution", () => {
});
});

// ═══════════════════════════════════════════════════════════════════════════
// Tier NS-C β€” Expansion integration
// ═══════════════════════════════════════════════════════════════════════════

describe("Tier NS-C β€” Expansion integration", () => {
it("NS-C1: basic named slot expansion", function* () {
const report = makeComponent(
Expand Down Expand Up @@ -617,10 +605,6 @@ describe("Tier NS-C β€” Expansion integration", () => {
});
});

// ═══════════════════════════════════════════════════════════════════════════
// Tier NS-D β€” slot prop reservation
// ═══════════════════════════════════════════════════════════════════════════

describe("Tier NS-D β€” slot prop reservation", () => {
it("NS-D1: slot in props frontmatter β†’ error", function* () {
const { props } = parseFrontmatter({
Expand Down Expand Up @@ -691,10 +675,6 @@ describe("Tier NS-D β€” slot prop reservation", () => {
});
});

// ═══════════════════════════════════════════════════════════════════════════
// Tier NS-E β€” renderChildren() interaction
// ═══════════════════════════════════════════════════════════════════════════

describe("Tier NS-E β€” renderChildren interaction", () => {
beforeAll(() => useTempFileCompiler());
it("NS-E1: renderChildren includes all slots", function* () {
Expand Down Expand Up @@ -814,10 +794,6 @@ describe("Tier NS-E β€” renderChildren interaction", () => {
});
});

// ═══════════════════════════════════════════════════════════════════════════
// Tier NS-F β€” Edge cases
// ═══════════════════════════════════════════════════════════════════════════

describe("Tier NS-F β€” Edge cases", () => {
it("NS-F1: slot on self-closing component", function* () {
const layout = makeComponent("Layout", '<Content slot="icon" />\n<Content />');
Expand Down Expand Up @@ -984,10 +960,6 @@ describe("Tier NS-F β€” Edge cases", () => {
});
});

// ═══════════════════════════════════════════════════════════════════════════
// Tier NS-G β€” Boundary scanner (confirms no changes needed)
// ═══════════════════════════════════════════════════════════════════════════

describe("Tier NS-G β€” Boundary scanner", () => {
// deno-lint-ignore require-yield
it("NS-G1: slot parsed as string prop", function* () {
Expand Down Expand Up @@ -1030,3 +1002,149 @@ describe("Tier NS-G β€” Boundary scanner", () => {
expect(segments[0]!.type).toBe("text");
});
});

describe("Tier NS-H β€” Nested projection points", () => {
it("NS-H1: inside another component invocation", function* () {
const echo = makeComponent("Echo", "ECHO(<Content />)");
const host = makeComponent("Host", "<Echo>prefix <Content /> suffix</Echo>");
const segments = scanSegments("<Host>MATERIAL</Host>");
const expanded = yield* expandAll(segments, { Echo: echo, Host: host });
expect(renderSegments(expanded)).toContain("ECHO(prefix MATERIAL suffix)");
});

it("NS-H2: several levels deep", function* () {
const inner = makeComponent("Inner", "IN(<Content />)");
const outer = makeComponent("Outer", "OUT{<Content />}");
const host = makeComponent("Host", "<Outer><Inner>deep <Content /></Inner></Outer>");
const segments = scanSegments("<Host>MATERIAL</Host>");
const expanded = yield* expandAll(segments, { Inner: inner, Outer: outer, Host: host });
expect(renderSegments(expanded)).toContain("OUT{IN(deep MATERIAL)}");
});

it("NS-H3: named slot in a nested position", function* () {
const echo = makeComponent("Echo", "ECHO(<Content />)");
const host = makeComponent("Host", '<Echo><Content slot="header" /></Echo>\n\n<Content />');
const headerComp = makeComponent("Header", "HEADER-TEXT");
const segments = scanSegments('<Host>\n<Header slot="header" />\ndefault text\n</Host>');
const expanded = yield* expandAll(segments, { Echo: echo, Host: host, Header: headerComp });
const output = renderSegments(expanded);
expect(output).toContain("ECHO(HEADER-TEXT)");
expect(output).toContain("default text");
expect(output.indexOf("ECHO(HEADER-TEXT)")).toBeLessThan(output.indexOf("default text"));
});

it("NS-H4: two projections at different depths receive the same content", function* () {
const echo = makeComponent("Echo", "ECHO(<Content />)");
const host = makeComponent("Host", "top: <Content />\n\n<Echo><Content /></Echo>");
const segments = scanSegments("<Host>MATERIAL</Host>");
const expanded = yield* expandAll(segments, { Echo: echo, Host: host });
const output = renderSegments(expanded);
expect(output).toContain("top: MATERIAL");
expect(output).toContain("ECHO(MATERIAL)");
});

it("NS-H5: nested inside a top-level <Output> region", function* () {
const echo = makeComponent("Echo", "ECHO(<Content />)");
const host = makeComponent("Host", "<Output><Echo><Content /></Echo></Output>");
const segments = scanSegments("<Host>MATERIAL</Host>");
const expanded = yield* expandAll(segments, { Echo: echo, Host: host });
expect(renderSegments(expanded)).toContain("ECHO(MATERIAL)");
});

it("NS-H6: slot validation errors are still emitted once", function* () {
const echo = makeComponent("Echo", "ECHO(<Content />)");
const host = makeComponent("Host", "<Echo><Content /></Echo>\nsecond: <Content />");
const bad = makeComponent("Bad", "BAD");
const segments = scanSegments('<Host>\n<Bad slot="123invalid" />\n</Host>');
const expanded = yield* expandAll(segments, { Echo: echo, Host: host, Bad: bad });
const errors = expanded.filter((segment) => segment.type === "error");
expect(errors).toHaveLength(1);
});

it("NS-H7: nested inside a structural construct", function* () {
const tmpDir = makeTempDir();
try {
writeFiles(tmpDir, {
"components/HostCap.md": [
"---",
"props: { type: object, properties: {}, additionalProperties: false }",
"---",
'<Capture as="c">captured: <Content /></Capture>',
"<Output>host got: {c}</Output>",
].join("\n"),
"doc.md": "<HostCap>MATERIAL</HostCap>",
});
const output = yield* collect(
yield* execute({
path: path.join(tmpDir, "doc.md"),
stream: new InMemoryStream(),
componentDirs: [path.join(tmpDir, "components"), tmpDir],
}),
);
expect(output).toContain("host got: captured: MATERIAL");
} finally {
cleanup(tmpDir);
}
});

it("NS-H8: nested inside an iterating construct", function* () {
const tmpDir = makeTempDir();
try {
writeFiles(tmpDir, {
"components/EachHost.md": [
"---",
"props: { type: object, properties: {}, additionalProperties: false }",
"---",
'<Each in={[1, 2]} let="n">',
"item {n}: <Content />",
"</Each>",
].join("\n"),
"doc.md": "<EachHost>MATERIAL</EachHost>",
});
const output = yield* collect(
yield* execute({
path: path.join(tmpDir, "doc.md"),
stream: new InMemoryStream(),
componentDirs: [path.join(tmpDir, "components"), tmpDir],
}),
);
expect(output).toContain("item 1: MATERIAL");
expect(output).toContain("item 2: MATERIAL");
} finally {
cleanup(tmpDir);
}
});

it("NS-H9: an unclaimed <Content /> stays reserved through a nested wrapper", function* () {
const echo = makeComponent("Echo", "ECHO(<Content />)");
const host = makeComponent("Host", "<Echo><Content /></Echo>");
// Written in the document, where nothing projects: no invocation resolved
// it, so passing it through two nested bodies must not make it mean one.
const segments = scanSegments("<Host><Content /></Host>");
const expanded = yield* expandAll(segments, { Echo: echo, Host: host });
const errors = expanded.filter((segment) => segment.type === "error");
expect(errors).toHaveLength(1);
expect(errors[0]!.message).toContain("<Content> is reserved");
});

it("NS-H10: a wrapped projection reaches a nested invocation's named slot", function* () {
const layout = makeComponent("Layout", 'LAYOUT(<Content slot="header" />)');
const section = makeComponent("Section", "<Content />");
const host = makeComponent(
"Host",
'<Layout><Section slot="header"><Content slot="header" /></Section></Layout>',
);
const headerComp = makeComponent("Header", "HEADER-TEXT");
const segments = scanSegments('<Host>\n<Header slot="header" />\ndefault text\n</Host>');
const expanded = yield* expandAll(segments, {
Layout: layout,
Section: section,
Host: host,
Header: headerComp,
});
const output = renderSegments(expanded);
expect(output).toContain("LAYOUT(HEADER-TEXT)");
// Host's default slot is not projected, so nothing else rides along.
expect(output).not.toContain("default text");
});
});
Loading
Loading