From 1c6fbb2a911cccab05c4075e97fc180755375bdb Mon Sep 17 00:00:00 2001 From: Taras Mankovski <74687+taras@users.noreply.github.com> Date: Sun, 30 Aug 2026 10:44:51 -0400 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20Show=20arbitrary=20text=20as=20a=20?= =?UTF-8?q?fenced=20Markdown=20code=20block=20(#658)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add ``, a core default that places any supplied string inside one fenced Markdown code block and chooses a fence the value cannot close: one backtick longer than the value's longest backtick run, never shorter than three. The value is returned unchanged — nothing trimmed, normalized, escaped or removed — so generated source and diagnostics can be shown as inert text without writing delimiter arithmetic in an eval block. `value` and the optional single-token `language` are ordinary props on a closed schema, so a repository `CodeBlock.md` receives them the way it receives any other prop. The self-closing form is declared to canonical dispatch rather than decided in the body, so a paired spelling is refused before the body runs and its content never expands. --- architecture.md | 1 + .../document-suites/syntax/Syntax.test.md | 1 + packages/core/src/components/CodeBlock.ts | 120 +++++ packages/core/src/components/registry.ts | 9 + .../core/tests/code-block-component.test.ts | 435 ++++++++++++++++++ .../core/tests/component-registration.test.ts | 42 +- packages/core/tests/syntax-catalog.test.ts | 29 ++ specs/executable-mdx-spec.md | 98 +++- 8 files changed, 732 insertions(+), 3 deletions(-) create mode 100644 packages/core/src/components/CodeBlock.ts create mode 100644 packages/core/tests/code-block-component.test.ts diff --git a/architecture.md b/architecture.md index 8bac7499..37e3e4e5 100644 --- a/architecture.md +++ b/architecture.md @@ -3540,6 +3540,7 @@ Status is measured against main. | document validation | validates one supplied root projection and the recursive Markdown source closure normal component selection discovers, returning deterministic version-1 document diagnostics and `valid`, `invalid` or `not-statically-checkable` invocation outcomes without evaluating document code or installing operational host behavior | built on the #654 stack | | `` / `printErrors(fn)` | prints failures | built on main | | `` | binds one name in the current environment from exactly one source: the content it renders, or the exact value `value` names, bound by reference and never through the JSON boundary component props cross — the scanner resolves no JSON for that one prop, and expansion projects none. Which source it has is read from what the author wrote, before either one runs, so a construct naming both expands no child and evaluates no expression. It opens no scope, owns no resource, adds no middleware boundary and writes no journal record — replay reconstructs both sources through ordinary expansion | built on the #527 stack | +| `` | shows any supplied string inside one fenced Markdown code block, choosing a fence one backtick longer than the value's longest backtick run and never shorter than three, so the value cannot close it. An ordinary overridable text default: `value` and the optional single-token `language` are ordinary props on the closed schema, so a repository `CodeBlock.md` receives them the way it receives any other prop. Self-closing only, declared to canonical dispatch rather than decided in the body, so a paired spelling is refused before the body runs and its content never expands; prop validation refuses a missing or non-string `value`, a refused `language` and any unknown prop ahead of both. The value is returned unchanged — nothing trimmed, normalized, escaped or removed — and the framing line feeds belong to the envelope, with no line feed after the closing fence. Exactness is a promise at the function-component return and the `as` capture boundary; the `DocumentOutput` middleware contract governs emitted output after it. It owns no scope, resource, authority or durable effect of its own, so partial and completed replay are the ordinary ones | built on the #658 stack | | `` | renders one supplied value as JSON text where the element was written, from one native two-space `JSON.stringify` call. An ordinary overridable core default whose operand is a capture: the exact evaluation result arrives by reference and is never mutated, cloned, replaced or frozen. It binds nothing, and `as`, content and a missing `value` are all refused before the operand evaluates. A value with no JSON text and a serialization that threw are distinct failures, each positioned at the invocation, emitting no partial output and preserving the original error as its cause. No scope, resource, authority or JSON-specific durable effect: replay reaches it through ordinary expansion, and a surrounding `` or `` keeps its own record of the text it consumed | built on the #452 stack | | root failure settlement | a text root installs the fail-capable `output` mode for its whole body, so an uncaught, undecided failure is the document execution's own outcome, whether or not the root declares `` | built on the #453 stack | | `` region `output` mode | an undecided error fails the document execution | built on main | diff --git a/packages/cli/tests/document-suites/syntax/Syntax.test.md b/packages/cli/tests/document-suites/syntax/Syntax.test.md index 623d766e..febd894e 100644 --- a/packages/cli/tests/document-suites/syntax/Syntax.test.md +++ b/packages/cli/tests/document-suites/syntax/Syntax.test.md @@ -133,4 +133,5 @@ rows read. + diff --git a/packages/core/src/components/CodeBlock.ts b/packages/core/src/components/CodeBlock.ts new file mode 100644 index 00000000..d3d94ea2 --- /dev/null +++ b/packages/core/src/components/CodeBlock.ts @@ -0,0 +1,120 @@ +/** + * `` — place any text inside one fenced Markdown code block + * (specs/executable-mdx-spec.md §6.19). + * + * A document that shows generated source, a diagnostic, or anything else it did + * not write needs the text to arrive as text. The hazard is the fence itself: a + * value that happens to contain three backticks closes a three-backtick block + * early, and the rest of it lands in the document as Markdown — as headings, as + * component invocations, as another fence. Writing that arithmetic in an eval + * block puts the safety of the surrounding document in the hands of every + * author who needs to quote something. + * + * So the fence is chosen here, from the value, and it is the only thing this + * component decides. It scans for the longest run of backticks and opens with + * one more than that, never fewer than three, which is a fence the value cannot + * close no matter what it holds. + * + * ## The value is not read for anything else + * + * Nothing is trimmed, normalized, escaped, re-encoded or removed. Text that + * looks like a fence, an element, an interpolation, HTML or an executable code + * block stays exactly as it arrived, because a function component's return is + * rendered rather than rescanned as document source (§6.8). The two line feeds + * that frame the value belong to the envelope, not to the value, and there is + * no line feed after the closing fence: a document that wants one writes it. + * + * ## `value` is an ordinary prop + * + * Unlike `` (§6.12), whose operand must arrive by reference or lose the + * very failures it exists to report, a string crosses the component JSON + * boundary as itself. Keeping `value` on the ordinary boundary is what lets a + * repository `CodeBlock.md` receive it the way it receives every other prop, + * instead of inheriting a capture from core's registration that it never + * declared. + * + * `language` is closed to one token — the info string a Markdown reader expects + * — so that a value cannot reach the opening fence line, where it would be read + * as syntax rather than shown as text. + */ + +import type { Operation } from "effection"; +import { printErrors } from "../component-failures.ts"; +import type { FormDeclaration, InvocationForm } from "../invocation-identity.ts"; +import type { Json } from "../types.ts"; + +export const props = { + type: "object", + properties: { + value: { type: "string" }, + language: { + type: "string", + pattern: "^[A-Za-z0-9][A-Za-z0-9._+#-]*$", + }, + }, + required: ["value"], + additionalProperties: false, +}; + +/** An invocation `` cannot render. */ +export class CodeBlockError extends Error { + constructor(message: string, options?: ErrorOptions) { + super(message, options); + this.name = "CodeBlockError"; + } +} + +const PAIRED = + " shows the text it is given, not content: write instead."; + +const UNESTABLISHED = + " was called without the invocation the engine issued, so which " + + "form it was written as cannot be established."; + +/** + * The fence this value cannot close. + * + * Scanned rather than matched: a regular expression over an arbitrary value is + * one more thing between the text and the count, and the count is the whole + * contract. Every character other than U+0060 ends a run and is otherwise not + * looked at. + */ +function fenceFor(value: string): string { + let longest = 0; + let run = 0; + for (let index = 0; index < value.length; index += 1) { + if (value[index] === "`") { + run += 1; + if (run > longest) { + longest = run; + } + } else { + run = 0; + } + } + return "`".repeat(Math.max(3, longest + 1)); +} + +const show = printErrors( + // deno-lint-ignore require-yield + function* CodeBlock(props: Record): Operation { + const value = String(props.value); + const language = props.language === undefined ? "" : String(props.language); + const fence = fenceFor(value); + return `${fence}${language}\n${value}\n${fence}`; + }, +); + +/** + * The one form this component runs, and what it says about the other. + * + * Declared rather than decided in the body: canonical dispatch reads the shape + * the author wrote (§5.6), so what runs, what the catalog advertises and what a + * refusal says all come from this one value. + */ +export const form: FormDeclaration = { + forms: "self-closing", + fn: show, + refuse: (_props: Record, written: InvocationForm | undefined) => + new CodeBlockError(written === "paired" ? PAIRED : UNESTABLISHED), +}; diff --git a/packages/core/src/components/registry.ts b/packages/core/src/components/registry.ts index 74df0411..aa7f3cc9 100644 --- a/packages/core/src/components/registry.ts +++ b/packages/core/src/components/registry.ts @@ -16,6 +16,7 @@ */ import type { ComponentRegistry, RegistryEntry } from "../types.ts"; +import { form as codeBlockForm, props as codeBlockProps } from "./CodeBlock.ts"; import Elicit, { props as elicitProps, returns as elicitReturns } from "./Elicit.ts"; import TempDir, { props as tempDirProps } from "./TempDir.ts"; import Fetch, { props as fetchProps } from "./Fetch.ts"; @@ -169,6 +170,14 @@ export const CORE_REGISTRY: ComponentRegistry = new Map([ }, { returns: parseJsonObject(globReturns) }, ), + core("CodeBlock", codeBlockForm, parseJsonObject(codeBlockProps), { + description: + "Show arbitrary text as a fenced Markdown code block. " + + '`` chooses a fence the value cannot ' + + "close.", + as: "Optional. Captures the exact fenced Markdown instead of emitting it.", + context: null, + }), core( "Json", Json, diff --git a/packages/core/tests/code-block-component.test.ts b/packages/core/tests/code-block-component.test.ts new file mode 100644 index 00000000..637b414f --- /dev/null +++ b/packages/core/tests/code-block-component.test.ts @@ -0,0 +1,435 @@ +/** + * Tier CB — `` (spec §6.19). + * + * The contract is bytes, so the assertions are bytes. Every exactness case + * compares a complete string taken at one of two boundaries — what the + * component rendered at the invocation site, or what `as` bound — because both + * sit before the `DocumentOutput` middleware, and normalization downstream of + * them could make a wrong fence or a rewritten value read as correct. Nothing + * here installs that middleware. + * + * The cases drive core's registered component through `execute()`, because the + * form this component accepts is decided by canonical dispatch against the + * invocation the engine issued (§5.6): expanding segments directly would refuse + * every invocation for want of that authority, and prove nothing about the + * component. + * + * Three fixtures travel with each document. `` puts an arbitrary string + * in scope without an eval block — a document cannot write a lone carriage + * return or a seven-backtick run as a prop literal. `` reads a binding + * back out at the ordinary prop boundary, which is the boundary a repository + * override receives. `` is written inside paired content, so a + * refused form that expanded its children anyway is counted rather than + * inferred. + */ + +import { describe, it } from "@executablemd/test-support/bdd"; +import { expect } from "@executablemd/test-support/expect"; +import { scoped } from "effection"; +import type { Operation } from "effection"; +import { forEach } from "@effectionx/stream-helpers"; +import { useStubFs } from "@executablemd/runtime/test"; +import { InMemoryStream } from "@executablemd/durable-streams"; +import { collect } from "../src/collect.ts"; +import { execute } from "../src/execute.ts"; +import { registerComponents } from "../src/components/registration.ts"; +import type { Json } from "../src/types.ts"; + +interface Run { + /** Everything the document emitted, before any output middleware. */ + output: string; + /** The run's own outcome: a failure nothing printed settles here. */ + failure: string; + /** Every `value` a `` was handed, in invocation order. */ + probed: string[]; + /** How many times the paired-content tripwire expanded. */ + tripped: number; +} + +/** Everything a run reported, printed or settled. */ +function reported(result: Run): string { + return `${result.output}\n${result.failure}`; +} + +/** Run `source` as the document, with `values` reachable through ``. */ +function run(source: string, values: Record = {}): Operation { + return scoped(function* () { + const probed: string[] = []; + let tripped = 0; + + yield* useStubFs({ "doc.md": source }); + yield* registerComponents([ + { + name: "Value", + origin: "tier-cb", + props: { + type: "object", + properties: { key: { type: "string" } }, + required: ["key"], + additionalProperties: false, + }, + // deno-lint-ignore require-yield + *fn(props: Record): Operation { + return values[String(props.key)]; + }, + }, + { + name: "Probe", + origin: "tier-cb", + props: { + type: "object", + properties: { value: { type: "string" } }, + required: ["value"], + additionalProperties: false, + }, + // deno-lint-ignore require-yield + *fn(props: Record): Operation { + probed.push(String(props.value)); + return ""; + }, + }, + { + name: "Tripwire", + origin: "tier-cb", + props: { type: "object", properties: {}, additionalProperties: false }, + // deno-lint-ignore require-yield + *fn(): Operation { + tripped += 1; + return "TRIPPED"; + }, + }, + ]); + + const execution = yield* execute({ path: "doc.md", stream: new InMemoryStream() }); + const output = yield* forEach(function* () {}, execution.output); + const outcome = yield* execution; + return { + output, + failure: outcome.ok ? "" : outcome.error.message, + probed, + tripped, + }; + }); +} + +/** The one line a `` invocation renders, with nothing around it. */ +function* directly(value: string, attributes = ""): Operation { + return yield* run(``, { v: value }); +} + +/** The exact string `as` bound, at the capture boundary. */ +function* captured(value: string, attributes = ""): Operation { + const result = yield* run( + ``, + { v: value }, + ); + expect(reported(result)).not.toContain("error"); + expect(result.probed.length).toBe(1); + return result.probed[0] ?? ""; +} + +describe("Tier CB — the envelope", () => { + it("CB1: an empty value is three backticks, two line feeds and nothing else", function* () { + const result = yield* directly(""); + + expect(result.failure).toBe(""); + expect(result.output).toBe("```\n\n```"); + expect(yield* captured("")).toBe("```\n\n```"); + }); + + it("CB1: ordinary Markdown keeps its own lines, and gains no final line feed", function* () { + const value = "# Title\n\n- one\n- two"; + const result = yield* directly(value); + + expect(result.output).toBe("```\n# Title\n\n- one\n- two\n```"); + expect(yield* captured(value)).toBe("```\n# Title\n\n- one\n- two\n```"); + }); + + it("CB1: the block lands where the element was written", function* () { + const result = yield* run( + 'before\n\n\n\nafter\n', + { v: "shown" }, + ); + + expect(result.failure).toBe(""); + expect(result.output).toContain("before"); + expect(result.output).toContain("```\nshown\n```"); + expect(result.output).toContain("after"); + }); +}); + +describe("Tier CB — the fence the value cannot close", () => { + it("CB2: the fence is the longest backtick run plus one, never fewer than three", function* () { + const cases: ReadonlyArray = [ + ["no backticks at all", 3], + ["one ` here", 3], + ["two `` here", 3], + ["three ``` here", 4], + ["`` then ` then ``` then ``", 4], + ["```` four ````", 5], + ["a run of ``````` seven", 8], + ["```\nnested\n```", 4], + ["`", 3], + ["``````````", 11], + ]; + + for (const [value, length] of cases) { + const block = yield* captured(value); + const fence = "`".repeat(length); + + expect(block).toBe(`${fence}\n${value}\n${fence}`); + // Stated twice on purpose: the complete string pins the layout, and this + // pins the count the layout was built from. + expect(block.split("\n")[0]?.length).toBe(length); + } + }); + + it("CB2: a run broken by any other character is two runs, not one", function* () { + // Counting backticks without resetting would read five here and open a + // six-backtick fence. + expect(yield* captured("```\n``")).toBe("````\n```\n``\n````"); + expect(yield* captured("``x``x``")).toBe("```\n``x``x``\n```"); + }); +}); + +describe("Tier CB — the value is not read for anything else", () => { + it("CB3: removing the envelope recovers the source code units exactly", function* () { + const value = [ + " leading spaces", + "\ttab indented", + "", + "", + "carriage\r\nreturn line", + "`` two `````` six", + "trailing spaces ", + ].join("\n"); + const block = yield* captured(value); + + // The value's longest run is six backticks, so the fence is seven. + const fence = "`".repeat(7); + expect(block.startsWith(`${fence}\n`)).toBe(true); + expect(block.endsWith(`\n${fence}`)).toBe(true); + + const recovered = block.slice(fence.length + 1, block.length - fence.length - 1); + expect(recovered.length).toBe(value.length); + for (let index = 0; index < value.length; index += 1) { + expect(recovered.charCodeAt(index)).toBe(value.charCodeAt(index)); + } + }); +}); + +describe("Tier CB — the language token", () => { + it("CB4: an omitted language leaves the opening fence bare", function* () { + expect((yield* captured("plain")).split("\n")[0]).toBe("```"); + }); + + it("CB4: every accepted token renders on the opening fence, unchanged", function* () { + const accepted = ["markdown", "md", "json", "shell-session", "c++", "c#", "F", "x1._+#-"]; + + for (const language of accepted) { + const result = yield* directly("body", ` language="${language}"`); + + expect(result.failure).toBe(""); + expect(result.output).toBe("```" + language + "\nbody\n```"); + } + }); + + it("CB4: a refused token fails validation and renders no fence", function* () { + const refused = [ + "", + " ", + "\t", + "shell session", + "md ", + " md", + "a`b", + "`", + "-md", + ".md", + "+md", + "#md", + "js/ts", + "js:ts", + "js,ts", + "js;ts", + "js\\ts", + "js$ts", + "js%ts", + "js*ts", + "js\nts", + ]; + + for (const language of refused) { + const result = yield* run( + '', + { v: "body", lang: language }, + ); + + expect(reported(result)).toContain("Prop validation failed for "); + expect(reported(result)).toContain("language"); + expect(result.output).not.toContain("```"); + expect(result.output).not.toContain("body"); + } + }); +}); + +describe("Tier CB — the invocation is refused before the body", () => { + it("CB5: a missing value is the engine's own required-prop refusal", function* () { + const result = yield* run(""); + + expect(reported(result)).toContain("Prop validation failed for "); + expect(reported(result)).toContain("value"); + expect(result.output).not.toContain("```"); + }); + + it("CB5: a non-string value is refused rather than coerced", function* () { + for (const value of ["42", "true", "null", "{ text: 'x' }", "['x']"]) { + const result = yield* run(``); + + expect(reported(result)).toContain("Prop validation failed for "); + expect(result.output).not.toContain("```"); + } + }); + + it("CB5: an unknown prop is refused, and nothing renders", function* () { + const result = yield* directly("body", ' fence="~~~"'); + + expect(reported(result)).toContain("Prop validation failed for "); + expect(reported(result)).toContain("fence"); + expect(result.output).not.toContain("body"); + }); + + it("CB5: paired content is refused by form, and the content never expands", function* () { + const result = yield* run( + '', + { v: "body" }, + ); + + expect(reported(result)).toContain(""); + expect(result.tripped).toBe(0); + expect(result.output).not.toContain("TRIPPED"); + expect(result.output).not.toContain("```"); + }); + + it("CB5: empty paired content is still the paired form", function* () { + const result = yield* run('', { + v: "body", + }); + + expect(reported(result)).toContain(""); + expect(result.output).not.toContain("```"); + }); +}); + +/** Everything a document must not act on, in one string. */ +const HOSTILE = [ + "```md", + "", + "{v}", + "", + "", + "```bash", + "echo executed", + "```", + "", + "~~~", + "", +].join("\n"); + +describe("Tier CB — inertness and capture", () => { + it("CB6: candidate-like content is shown, not run, and not escaped", function* () { + const result = yield* directly(HOSTILE); + + expect(result.failure).toBe(""); + expect(result.tripped).toBe(0); + // The value's longest run is three, so the fence is four. + expect(result.output).toBe("````\n" + HOSTILE + "\n````"); + // Present verbatim: nothing was escaped, rewritten or removed. + expect(result.output).toContain(""); + expect(result.output).toContain("{v}"); + expect(result.output).toContain("echo executed"); + }); + + it("CB6: `as` binds that exact string and emits nothing where it was written", function* () { + const direct = yield* directly(HOSTILE); + const result = yield* run( + 'beforeafter', + { v: HOSTILE }, + ); + + expect(result.failure).toBe(""); + expect(result.probed).toEqual([direct.output]); + // Captured instead of emitted: the invocation site contributed nothing. + expect(result.output).toBe("beforeafter"); + expect(result.tripped).toBe(0); + }); +}); + +describe("Tier CB — durability", () => { + /** A registered source for the string `` is handed. */ + function source(text: string) { + return { + name: "Source", + origin: "tier-cb", + props: { type: "object", properties: {}, additionalProperties: false }, + // deno-lint-ignore require-yield + *fn(): Operation { + return text; + }, + }; + } + + function runOn(stream: InMemoryStream, text: string): Operation { + return scoped(function* () { + yield* useStubFs({ + "doc.md": '\n\n\n', + }); + yield* registerComponents([source(text)]); + return yield* collect(yield* execute({ path: "doc.md", stream })); + }); + } + + function* descriptions(stream: InMemoryStream): Operation { + const events = yield* stream.readAll(); + const types: string[] = []; + for (const event of events) { + if (event.type === "yield") { + types.push(String(event.description.type)); + } + } + return types; + } + + /** The journal without the root's close, so the next run continues live. */ + function* partial(stream: InMemoryStream): Operation { + const events = yield* stream.readAll(); + return new InMemoryStream( + events.filter((event) => !(event.type === "close" && event.coroutineId === "root")), + ); + } + + it("CB7: no code-block effect is journaled, partial replay re-renders, completed replay does not", function* () { + const live = new InMemoryStream(); + const first = String(yield* runOn(live, "FIRST")); + expect(first).toContain("```\nFIRST\n```"); + + // Every durable record belongs to something that already existed: + // resolving the component is the ordinary import every resolution produces. + const types = yield* descriptions(live); + expect(types).toContain("import_component"); + expect(types.filter((type) => /code.?block|fence/i.test(type))).toEqual([]); + + // A partial journal reaches the component again and fences the string this + // execution reconstructed, which is the changed one. + const resumed = yield* partial(live); + const second = String(yield* runOn(resumed, "SECOND")); + expect(second).toContain("```\nSECOND\n```"); + expect(second).not.toContain("FIRST"); + expect(yield* descriptions(resumed)).toEqual(types); + + // A completed root is reused whole: the live source is never consulted. + const again = String(yield* runOn(live, "THIRD")); + expect(again).toBe(first); + expect(again).not.toContain("THIRD"); + }); +}); diff --git a/packages/core/tests/component-registration.test.ts b/packages/core/tests/component-registration.test.ts index 440aa1c9..cbc9a1c2 100644 --- a/packages/core/tests/component-registration.test.ts +++ b/packages/core/tests/component-registration.test.ts @@ -368,7 +368,16 @@ describe("Tier CR — resolution order", () => { it("CR16b: a repository file overrides every component core supplies", function* () { const dir = yield* useFixture(); - for (const name of ["Fetch", "File", "Glob", "Json", "Parse", "SafeParse", "TempDir"]) { + for (const name of [ + "CodeBlock", + "Fetch", + "File", + "Glob", + "Json", + "Parse", + "SafeParse", + "TempDir", + ]) { yield* writeTextFile(join(dir, `${name}.md`), "mine\n"); const selection = yield* select(name, [dir]); expect(selection.kind).toBe("repository"); @@ -377,7 +386,16 @@ describe("Tier CR — resolution order", () => { it("CR17: core's components resolve when nothing is on disk", function* () { const dir = yield* useFixture(); - for (const name of ["Fetch", "File", "Glob", "Json", "Parse", "SafeParse", "TempDir"]) { + for (const name of [ + "CodeBlock", + "Fetch", + "File", + "Glob", + "Json", + "Parse", + "SafeParse", + "TempDir", + ]) { const selection = yield* select(name, [dir]); expect(selection.kind).toBe("registered"); expect(originOf(selection)).toBe(CORE_ORIGIN); @@ -533,6 +551,26 @@ describe("Tier CR — what a document gets", () => { expect(rendered).not.toContain("override saw the projection"); }); + it("CR22d: a repository CodeBlock.md replaces core's fence, end to end", function* () { + const dir = yield* useFixture(); + yield* writeTextFile(join(dir, "doc.md"), '\n'); + // `value` is an ordinary prop on both sides, so the override receives the + // same string core's registration would have been handed. Nothing about + // core's registration travels with the name. + yield* writeTextFile( + join(dir, "CodeBlock.md"), + ["---", "props:", " value: { type: string }", "---", "", "OVERRIDE:{props.value}"].join( + "\n", + ), + ); + + // Core's own would have wrapped the string in a fence; the override writes + // it bare, so the output says which one ran and what it was given. + const rendered = String(yield* run(dir)); + expect(rendered).toContain("OVERRIDE:fenced"); + expect(rendered).not.toContain("```"); + }); + it("CR23: a broken local component fails instead of falling back to core's", function* () { const dir = yield* useFixture(); // Core's renders its content, so a fall-through would succeed diff --git a/packages/core/tests/syntax-catalog.test.ts b/packages/core/tests/syntax-catalog.test.ts index f029e03c..02f9877a 100644 --- a/packages/core/tests/syntax-catalog.test.ts +++ b/packages/core/tests/syntax-catalog.test.ts @@ -880,6 +880,35 @@ describe("Tier SY: complete component contracts", () => { expect(find(entries, "Glob").returns).toEqual({ type: "array", items: { type: "string" } }); }); + it("SY24b: reports 's whole contract, without running it", function* () { + const catalog = yield* catalogFor({}, []); + const entry = find(builtIn(catalog), "CodeBlock"); + + expect(entry.origin).toEqual({ + kind: "registered", + origin: "@executablemd/core", + reserved: false, + }); + expect(entry.sourceKind).toBe("registered"); + expect(entry.forms).toEqual(["self-closing"]); + expect(entry.props).toEqual({ + type: "object", + properties: { + value: { type: "string" }, + language: { type: "string", pattern: "^[A-Za-z0-9][A-Za-z0-9._+#-]*$" }, + }, + required: ["value"], + additionalProperties: false, + }); + // `value` crosses the ordinary prop boundary, so it is in the schema above + // rather than in this list — which is what a repository override receives. + expect(entry.captures).toEqual([]); + expect(entry.returnMode).toBe("text"); + expect(entry.returns).toEqual({ type: "string" }); + expect(entry.as).toContain("Optional"); + expect(entry.context).toBe(undefined); + }); + it("SY25: preserves the declaration order of captures and forms", function* () { const catalog = yield* scoped(function* () { yield* useTree({}); diff --git a/specs/executable-mdx-spec.md b/specs/executable-mdx-spec.md index 5acd0208..b3fc20d6 100644 --- a/specs/executable-mdx-spec.md +++ b/specs/executable-mdx-spec.md @@ -2725,7 +2725,7 @@ nothing. A non-string is not an error — it is a value with no destination. Some components are core's own: `` (§6.11), ``, `` and `` (§6.12), `` (§6.13), `` (§6.13.1), `` -(§6.14), and `` (§6.18). Each is already +(§6.14), `` (§6.18), and `` (§6.19). Each is already in the module graph, so it ships in the compiled binary and every published package without a search path or a bundling step, and a document invokes it with no `--include`. @@ -7928,6 +7928,102 @@ component grants that admission, and a repository component that takes the name `Fetch` is not the pinned identity. +### 6.19 Showing text as a code block: `` + +A document that renders text it did not write — generated source, a validator's +diagnostics, a command's transcript — needs that text to arrive as text. +`` is core's own component (§5.3) for exactly that: + +```md + +``` + +The hazard it removes is the fence. A value holding three backticks closes a +three-backtick block early, and everything after it lands in the document as +Markdown — as headings, as element invocations, as another fence. Writing that +arithmetic in an eval block puts the safety of the surrounding document in the +hands of every author who has something to quote. + +#### Props and form + +`value` is required and must be a string. `language` is optional and is one +token matching `^[A-Za-z0-9][A-Za-z0-9._+#-]*$` — `markdown`, `json`, +`shell-session`, `c++` and `c#` are all accepted, and an empty token, a token +holding whitespace or a backtick, and a token opening with a punctuation +character are all refused. The schema is closed: no other prop exists, and there +is no way to choose the fence character, the fence length, an indentation, an +escaping rule or a trailing newline. + +The component is **self-closing only**, and that is declared to canonical +dispatch rather than decided in its body (§5.6), so what runs, what the syntax +catalog advertises and what a refusal says all come from one value. Any paired +spelling — including one whose content is empty — is refused as a printed +component failure naming the `` spelling, and the content +written between the tags never expands. + +Props are validated before the body, so a missing or non-string `value`, a +refused `language`, and any unknown prop are the engine's ordinary structured +prop-validation failure, with no fence emitted and no body entered. + +Unlike `` (§6.12), whose operand must arrive by reference or lose the very +failures it exists to report, `value` is an **ordinary prop**: a string crosses +the component JSON boundary as itself. That is what lets a repository +`CodeBlock.md` receive it the way it receives every other prop, instead of +inheriting a capture from core's registration that it never declared. + +#### The fence, and the exact result + +A **backtick run** is a contiguous sequence of U+0060 characters. The whole +value is scanned for the longest one, and the fence is + +```text +max(3, longest run + 1) +``` + +backticks — a fence the value cannot close, whatever it holds. The component +returns exactly + +```text +fence + (language ?? "") + "\n" + value + "\n" + fence +``` + +Both framing characters are U+000A and belong to the envelope rather than to +`value`. There is no line feed after the closing fence: a document that wants +one writes it. + +Nothing else about the value is read. It is not trimmed, line-ending-normalized, +escaped, re-encoded, or searched for an apparent closing fence. Text that +resembles a fence, an element invocation, an interpolation, HTML or an +executable code block stays exactly as it arrived, because a function +component's returned text is rendered rather than rescanned as document source +(§6.8). + +#### `as`, and where exactness ends + +Without `as`, the returned string renders at the invocation site. With +`as="name"`, the ordinary component-capture boundary binds that exact string and +the invocation emits nothing. + +Exactness is a promise about the function-component return and that capture +boundary. The `DocumentOutput` contract (§9) still governs what happens to +emitted output afterwards: whitespace normalization and terminal formatting may +transform it, and `xmd run --raw` installs neither. + +#### Resolution and replay + +The registration is an ordinary overridable default, not a reserved name: a +repository `CodeBlock.md` is chosen ahead of it and receives ordinary props, +and a repository definition that is broken fails rather than falling through to +core. Inspection reports the self-closing form, the props schema above, no +captures, and text return mode with the effective `{ type: "string" }` schema. + +`` opens no scope, acquires no resource, holds no authority and has +no durable effect of its own — resolving it is the ordinary `import_component` +every component resolution produces. A live run and a partial replay both reach +it through normal expansion and fence the string that execution reconstructed; +completed-root terminal reuse is unchanged. + + ## 7. Entry point ### 8.1 `execute`