Rendering
The SDK owns the output shapes: what a whole composition looks like as text, markdown, Slack blocks, and HTML. Dispatch gets a single node to its renderer; this layer wraps the body in whatever the format calls an envelope.
Three of the four are small, and each takes a narrow dispatcher interface rather than the whole thing — a text envelope needs renderText and nothing else.
renderTextEnvelope(composition, dispatcher, { heading });
renderMarkdownEnvelope(composition, dispatcher, { heading }); //
renderSlackEnvelope(composition, dispatcher, { heading, text, collectAssets, assetPrefix });
- title uppercased, subtitle, nodes, blank-line joined
- title, subtitle, nodes
heading: false leaves out the title and subtitle, and nothing else; the Slack envelope also leaves them out of its default fallback text. It defaults to true.
The Slack envelope does the most. It emits a header block for the title and a context block for the subtitle unless heading is false, then each node's blocks; enforces Slack's 50-block message budget; clamps the fallback text to 4,000 characters; and returns only the asset requests whose placeholder block survived the budget — uploading files for elided blocks would be orphaned work.
SLACK_LIMITS publishes the numbers a renderer has to respect: 50 blocks per message, 3,000 characters in a section, 10 fields per section, 10 elements per context block, 25 buttons in an actions block, 150 characters in a header, 75 in an option label.
renderHTMLWithDispatcher(composition, { dispatcher, validate, options, styleAdapter, enhancementDefinitions, defaultAriaLabel }) returns:
interface HTMLRenderResult {
html: string;
css: string;
js: string;
body: string;
measurement: PayloadMeasurement;
validationErrors: ValidationError[];
}
html is the wrapper element plus content; body is the content alone; css is what the adapter emitted; js is the enhancement script as a function body over root (see Enhancements); measurement is the byte length of the markup, the stylesheet, the enhancement script as delivered, and their total, for a host that budgets payload size. Validation runs inside, per the caller's onValidationError mode, and the findings come back on the result as validationErrors ({ path, message, nodeType? } each) rather than being thrown by default.
validate defaults to createCompositionValidator(dispatcher.definitions); pass one when validation needs options or a wider inventory.
React content is shared with the React surface through one helper, so the two cannot diverge: an h2 and a p.sub for the composition's title and subtitle when heading is not false, then the body nodes, inside a dispatcher context provider.
The wrapper differs in one place. html renders the body to a string first and hosts it in a div, so the document is section.isomer > div > body, with the optional style and script elements beside the div; the React surface's wrapCompositionContent places the content directly inside the section. Style .isomer descendants (.isomer h2, .isomer .card) rather than .isomer > children, and the same rules serve both surfaces.
CSS is not the SDK's. A pack supplies an HTMLStyleAdapter, and the SDK calls it at fixed points in the pass:
| Hook | When |
|---|---|
resolveOptions? |
First — settles composition-dependent options, before anything reads them |
createCollector |
Once per render |
collectWrapperStyles? |
The .isomer / .isomer.framed / .isomer.fluid rules around the wrapper |
collectViewStyles? |
Per composition, with the dispatcher and the enhancement scope |
createRenderContext |
Builds the context every react renderer is handed |
collectAfterRender? |
After the tree is rendered |
renderStyles |
Emits the stylesheet |
getScriptText? |
Emits the adapter's own script, a function body over root |
createRenderContext must return a complete context, because TContext is the pack's own type. The HTML surface never writes to it and hands renderers a view of it rather than a copy, so a frozen or class-instance context keeps its methods and private state. Whether renderers emit node anchors during an HTML render is the surface's decision, whatever the context's anchors says. The HTML adapter's default TContext is StyledRenderContext (resolveClassName, cssVarRef). PrimitiveRenderContext itself is enhancements, anchors, and onEvent.
A pack that authors its CSS with Distillate, Elastic's typed CSS engine with render-driven style collection, does not write those hooks by hand. createDistillateHtmlStyleAdapter(distillery) is the adapter: record handles during render, emit their stylesheet. Put it on definePrimitivePack({ styleAdapter }) so a host gets it without asking. The SDK does not depend on Distillate; the helper is duck-typed against artifactCollector, renderStyles, and registry.
ownsHandle is what lets a runtime combine several packs' adapters instead of making the host choose one: each handle goes to the adapter that owns it, so no pack's CSS is emitted twice or rendered against another pack's theme. Declare it on any adapter meant to coexist with others. The Distillate helper answers it from the distillery's own registry.
A primitive contributes CSS through collectStyles(node, { styles, context }). The adapter's collectViewStyles still walks the body via the dispatcher's positional collectStyles(node, styles, context).
The wrapper hook is named for the wrapper rather than the SVG frame. The document an image is drawn inside is a Frame.
A progressive enhancement is an id, a content gate, and usually a script. resolveEnhancements(body, requested, walk, definitions) intersects what the host asked for with what the composition actually contains, so a composition with no table never ships the sort script. The host opts in by id: enhancements: ['tableSort']. The HTML render resolves the request once for every pack: the set reaches every renderer as context.enhancements, in place of anything the adapter's context holds, and each resolved enhancement's script is emitted once. A set rather than a field per feature, so adding one costs no plumbing:
context.enhancements?.has('tableSort');
The baseline — empty or absent — has to answer the question on its own. An enhancement improves an answer that already works without it.
Every script, whether an enhancement's, the adapter's getScriptText, or the caller's scriptText, is a function body with root, the render's .isomer section, in scope. The scripts option decides who binds root and runs it:
scripts |
html carries |
The host |
|---|---|---|
'embedded' (default) |
A <script> that binds root to its parent section when the page parses it |
Does nothing |
'host' |
No <script> |
Calls runEnhancementScript(result.js, section) after inserting html |
'embedded' only works where the browser parses the HTML with the page. It never runs inside a shadow root or anywhere the host inserts html itself: innerHTML and React never execute a <script>, and one that a loader did execute would find document.currentScript null in a shadow tree. Those hosts use 'host'. result.js is the same body in both modes and runs only through runEnhancementScript, never as a <script> of its own.
The mismatches that can be detected are reported. An embedded script that runs without a root warns. runEnhancementScript throws ENHANCEMENT_ROOT_MISSING when it is given no section, and warns when the section also carries an embedded script, which in light DOM would run every enhancement twice. An embedded script inserted with innerHTML never runs at all, so nothing can report it.
runEnhancementScript compiles with new Function, so a strict Content-Security-Policy must allow 'unsafe-eval'. A host that cannot should render with 'embedded' into light DOM.
An enhancement the host drives itself, rather than one that ships behavior in the page, has no script.
Runtime code that acts on a rendered node, such as a host stepping through a slide's parts, finds its element through a node anchor. A react renderer spreads nodeAnchor(context, node) on its root element, which sets data-isomer-node to the node's type, escaped so HTML parsing leaves it unchanged (anchorValue(type); a plain identifier is unchanged):
react: (node, { context }) => <ol {...nodeAnchor(context, node)}>…</ol>,
Anchors render only when something needs them, so a render nobody acts on carries none. During an HTML render the surface decides, for the whole synchronous render and without touching the render context: anchors are on when a resolved enhancement declares anchors: true, or when a test passes the anchors: true render option, and off otherwise, whatever the context's anchors says. anchors: false cannot turn off anchors an enhancement needs. Outside an HTML render, on the React and svg surfaces, the context's own anchors decides: a React host turns them on with anchors: true on the context it passes.
findNodeElements(root, composition.body, walk) pairs each react-visible node with its element: the k-th node of a type, walked pre-order, is the k-th element anchored with that type in document order. root holds one render. A type whose counts disagree, for instance because one of its nodes rendered nothing, is left out, so the caller falls back to its baseline. For the pairing to hold, a container draws its children in the order its definition returns them, and anything a renderer draws that is not one of its children renders with withoutAnchors(context). It returns a view of the context, not a copy, so methods, getters, private state, and instanceof keep working; anchors are off for that subtree, and the mark survives contexts derived from it by spreading.
renderSlackEnvelope always returns a postable payload, fitting the rendered blocks to Slack's limits (SLACK_LIMITS) in a fixed order:
- Table character budget. Slack counts table cell characters across the whole message, not per block, so tables that individually fit can still push the message over. Tables are kept in document order until
tableCellCharsPerMessageruns out; the rest degrade to one mrkdwn section per row, keyed by the header row. - Section rhythm. A divider goes in front of every header and the actions block; a spacer follows each content block except a table about to be followed by a divider. Consecutive field-only sections are not spaced, since they read as one grid.
- Block budget. If the block count still exceeds
blocksPerMessage, spacers are dropped first, from the end backward, since they cost nothing but rhythm. If that alone is not enough, the remainder is truncated and replaced with a trailing context block noting how many were elided.
assets is filtered to the requests whose placeholder block survived step 3 — a block enforceBlockBudget elides is never posted, so uploading its file would be wasted, orphaned work.
./markdown publishes boldLabelPrefix and boldSectionLabel, and ./slack the escaping, clamping, and Block Kit constructors. formatCompactNumber and the structured-value formatters live on the root entry, since every surface needs them. ./text publishes no formatters: line width, trend glyphs, and threshold copy are editorial choices a pack makes, not contract.