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, baseUrl });
- 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; fits each block to Slack's text, element-count, and image URL limits; enforces Slack's 50-block message budget; escapes the default fallback text as mrkdwn and clamps it 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. baseUrl is what relative links resolve against; without it they print their labels (Relative links on Slack).
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 or button.
renderHTMLWithDispatcher(composition, { dispatcher, validate, options, styleAdapter, enhancementDefinitions, defaultAriaLabel }) returns:
interface HTMLRenderResult {
html: string;
css: string;
js: string;
body: string;
measurement: { html: number; css: number; js: number; total: number };
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?, code? } each) rather than being thrown by default; input refused by the budget throws in either mode.
validate defaults to createCompositionValidator(dispatcher.definitions); pass one when validation needs options or a wider inventory. The render draws the composition its result carries, the copy it checked, and never reads its input again.
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 puts its adapter on definePrimitivePack({ styleAdapter }) so a host gets it without asking. The SDK ships no adapter for any CSS engine; the slides pack's src/theme/style_adapter.ts is a Distillate one to copy.
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 frame. The document a snapshot is drawn inside is a Frame.
On react and html a browser draws the pack's stylesheet, so color that varies by scheme is written once as light-dark(…). It follows the wrapper's color-scheme for an explicit light or dark theme, and the page's under auto, including when the page changes scheme after the render. The snapshot surface resolves the same stylesheet to the frame's mode, and also hands each renderer the frame's palette for that mode as env.theme. Containers recurse with scope.renderReact(child, context): the scope preserves that palette and snapshot visibility, including through deferred React components and fresh child contexts.
A renderer is not told the scheme on react or html. Under auto the page alone knows it, at paint time, so a value decided while rendering would be a guess that goes stale when the page flips. Color that lives outside the stylesheet still follows the scheme, by leaving the browser's copy to CSS:
SVG paint. Give the shape a presentation attribute and a class. Any class rule beats a presentation attribute, so a browser draws the rule's
light-dark(…). An image backend lifts an inline<svg>out of the document, so the stylesheet does not reach it and only the attribute is drawn. Take the attribute's literal fromenv.themeso the image matches the frame's mode, and fall back to the light literal whereenv.themeis absent:react: (node, { theme }) => ( <path d={node.path} className="series-1" fill={(theme ?? LIGHT).series[0]} /> ),Do not write
var(--x, #fallback)in the attribute. SVG parsers that do not implement custom properties drop the whole value, so the shape paints black.Markup a library draws. A chart library that renders SVG on the server writes
fillandstrokefrom the palette the pack passed it, with no classes. The pack knows every literal it passed, so it can add a class per palette slot to the markup afterwards, or generate rules from the palette that match the attribute, such as[fill='#5470c6'] { fill: light-dark(#5470c6, #8aa2e8) }. Prefer classes that identify palette slots and scope the rules to the chart. Matching by attribute requires distinct light literals, unchanged attribute serialization, and no unrelated marks sharing those values; colors in inline styles need the inline-style recipe instead.Inline styles. An ordinary class rule cannot override an inline style. Write the literal from
env.themewhen it is present, which is onsnapshot, andlight-dark(…)otherwise.Canvas or other output drawn in the browser. Read the scheme as CSS resolved it, not from
[data-theme]orprefers-color-scheme: a host can setcolor-schemeon any ancestor. Give an element inside the wrappercolor: light-dark(#000, #fff), or a pack token holdinglight-dark(…), and draw from its computedcolor. Read it again whenprefers-color-schemechanges or the host switches its scheme.
A progressive enhancement is an id, optional name and description for host UI, a content gate, and usually a script. A render keeps the first definition of each requested id whose gate finds content in the composition, the same way on the html and React surfaces, 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 or the adapter's getScriptText, 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.
Inline text cannot close its element. CSS escapes </style while preserving string values and HTML-style wrappers. JavaScript embedding parses scripts containing </script or <!--, preserves strings and regular expressions, converts HTML-style comments to line comments, and reconstructs affected tagged-template objects with their original raw and cooked values, frozen arrays, and per-site identity. Embedded scripts need no eval. css: 'separate' and scripts: 'host' return the text unchanged, and measurement counts inline text as emitted.
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, from ./react, 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 (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 snapshot surfaces, the context's own anchors decides: a React host turns them on with withNodeAnchors(context) or anchors: true on the context it passes, or by passing the runtime's React surface an enhancement that declares anchors: true; the runtime's snapshot surface takes an anchors: true render option.
findNodeElementPairs(root, composition.body, walk), from ./react, lists each react-visible node, pre-order, with its element, so a node object used twice pairs twice. 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.
checkLayout(layout, composition.body, walk, surface) reports where nodes run past the room their container gives them or land on each other, from a measured render with anchors on. layout is a LayoutBox tree: canvas x, y, width, and height, an optional scale, text runs, attributes, and children. It is declared by shape, so the takumi backend's measure produces one, and measureDom(root), from ./react, produces one from a browser's DOM: each element's getBoundingClientRect measured from root's top left, its text runs' client rects, and its attributes other than class, id, and style. An HTML element's scale is its drawn size over its layout size, so a rotated one reads as scaled and nothing inside it is checked. An SVG element has no layout size, so it is never scaled and the inside of a transformed SVG subtree is still checked. measureDom needs a real layout engine; under jsdom every box is empty. Boxes pair with nodes by the same rule as findNodeElementPairs. surface is the one the layout was rendered on, snapshot for an image or react for the DOM, which decides the top-level nodes it drew; nested nodes follow react either way.
Each LayoutFinding is { kind, path, type, with?, by }, with path in validation's form. The rules know nothing about any pack:
- A node's room is the box of its nearest anchored ancestor, or of an element inside it that spreads
layoutRoom(context), such as a frame's body, or the whole canvas at the top level.layoutRoomsetsdata-isomer-room(LAYOUT_ROOM_ATTRIBUTE) whenevernodeAnchorwould render; it bounds the nodes nested in it, not its own node's content. An anchored box whose type is left out still counts as a room and bounds its parent's content.overflowis its element boxes past that room; its text runs are left out, because glyphs overhang a tight line height. overlapis text or childless boxes shared by two siblings under the same room,bythe shortest distance along one axis that would clear them, which for one inside the other is more than their shared width. A node is never compared with the nodes nested in it.- A scaled box is one opaque box, and nothing inside it is checked. One pixel of spill is tolerated.
Findings are advice, not validation errors, and no surface runs the check: an overlap may be intended, and the agent or author reading them decides. overflow: hidden is not visible to it, and a container's inner region is only a room when the renderer marks it with layoutRoom. A pack whose renderers spread no anchors gets no findings, and a type whose counts disagree is not reported, as with findNodeElementPairs. Findings belong to the engine that measured the layout: the same composition can fit in a browser and overflow in takumi.
renderSlackEnvelope always returns a postable payload, fitting the rendered blocks to Slack's limits (SLACK_LIMITS) in a fixed order:
- Block fitting. Every block a renderer returns is fitted on its own:
- An image whose
image_urlis pastimageUrlCharsleaves its alt text: animageblock becomes acontextblock holding it, a context image element amrkdwnelement, and a section's image accessory is dropped, its alt text following in acontextblock. - Each Slack-limited text is cut to its
SLACK_LIMITSentry, keeping the leading text, cut at a grapheme boundary, and ending in…: header, section text and fields, context elements, image and video titles, video description andauthor_name, image alt text, button text, select placeholders, and option and option-group labels. Amrkdwntext is never cut inside a<…>link or mention or an entity; formatting marks are left as they fall, and Slack prints an unclosed one as written. An option'svalueis cut tooptionValueChars, a button's tobuttonValueChars, and anaction_idtoactionIdChars, with no…, since a host reads them back; a button's or overflow option'surlpasturlCharsis dropped rather than cut. - A menu keeps its first
optionsPerSelectoptions,optionsPerChoicefor radio buttons and checkboxes, oroptionsPerOverflowfor an overflow menu; a grouped select keeps its firstoptionGroupsPerSelectgroups and the firstoptionsPerSelectoptions of each. An initial option becomes the emitted option of its (cut) value, or is dropped when none is emitted, asinitial_optionsis when none remain. - In a
rich_textblock, a section, quote, or preformatted element pastsectionTextCharssplits into adjacent elements of its type with nothing added between them, so its text is kept whole. A link or tag within that length stays whole; anything longer splits at grapheme boundaries (a grapheme past the length at code points) into inlines of its own type and style, so a long link becomes adjacent links to the same URL whose labels, or the URL where it has none, run on, and a long tag adjacent tags of its color. A list is left as is, and table cell text is not clamped.
- An image whose
- Table limits. Slack counts table cell characters across the whole message, not per block, so tables that individually fit can still push the message over. In document order, a table is kept if it has between one and
tableRowsrows, each with between one andtableColumnscells, and its cells fit what the tables kept before it left oftableCellCharsPerMessage; any other is replaced by onerich_textblock, and no cell is clamped. Each row prints its columns, to the wider of the header and the row, asheading: cell, the heading bold with its own styles, and a blank section parts the rows. A cell keeps its inlines, styles, sections, lists, quotes, and preformatted blocks as separate elements, and a list keeps its items as the cell gave them; a blank heading leaves its cell alone, a heading that is not one section prints its blocks, bold, above the cell. A table whose rows past the header have no cells prints its headings, and one with nothing to print, such as a table with no rows, is dropped. An empty row, or an empty element in a cell, prints nothing. A section ends its line before the next one unless its rendered text already ends in a line terminator. A section, quote, or preformatted element pastsectionTextCharssplits into adjacent elements of its type with nothing added between them. A link or tag within that length stays whole; anything longer splits at grapheme boundaries (a grapheme past the length at code points) into inlines of its own type and style, so a long link becomes adjacent links to the same URL whose labels, or the URL where it has none, run on, and a long tag adjacent tags of its color. - Section rhythm. A divider goes in front of every header after the first block and every actions block. A spacer goes between two consecutive content blocks, and between a content block other than a table and such a divider. Consecutive field-only sections are not spaced, since they read as one grid. Then a
contextblock pastcontextElements, anactionsblock pastbuttonsPerActions, and a section pastfieldsPerSectionfields split into consecutive blocks of the same type, with nothing between them; only the first keeps theblock_id, and a section's text and accessory stay on it. - 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 4 — a block enforceBlockBudget elides is never posted, so uploading its file would be wasted, orphaned work.
A block from unmodeledSlackBlock, or of a type Isomer does not model, skips steps 1 to 3 and counts as one block in step 4. Slack counts the same way: blocksPerMessage limits top-level blocks, and blocks nested inside one, such as a container's child_blocks, do not count toward it.
A container goes through every step with its children:
- Step 1 cuts its
titletocontainerTitleCharsand itssubtitletocontainerSubtitleChars, splits arich_text_titleelement pastsectionTextCharsas in anyrich_textblock, drops aniconwhose URL is pastimageUrlChars, cuts the icon's alt text, and fits each child as a block of its own. - Step 2 counts the cells of its tables toward the message in document order, with the tables around it.
- Step 3 coalesces field sections and splits blocks within its children, never across its edge. It puts no spacer inside a container, nor beside one, since a container sets itself apart.
- Step 4 counts it as one block, however many children it holds.
A container nested in another is unwrapped into its parent, since Slack rejects one. A container any step leaves with no children, or more than containerChildBlocks, is unwrapped too: its title becomes a header (or its rich_text_title a rich_text block), its subtitle a context block, followed by its children.
slackLimitProblems({ text, blocks }) lists where a payload breaks SLACK_LIMITS, one message per problem naming the block path, and is empty when Slack would accept it. It also reports a block wrapped in unmodeledSlackBlock whose type Isomer models. It cannot check a block of a type Isomer does not model; unmodeledSlackBlockTypes(blocks) names those types, a container's children included. The SDK's envelope tests hold every payload they render to it, and isomer-studio check runs it on each example's Slack output.
A primitive that groups its body under a title, such as an "Impact" or "Hypotheses" section, renders on Slack through slackContainer, which builds a Block Kit container:
import { slackContainer } from '@elastic/isomer-sdk/slack';
slack: (node, { collector, scope }) =>
slackContainer(
node.title,
node.children.flatMap((child) => scope.renderSlack(child, collector)),
{ is_collapsible: node.collapsible, fallbackLevel: 3 }
),
The options are the container's own optional fields, subtitle, icon, width, is_collapsible, default_collapsed, has_header_divider, and block_id, plus fallbackLevel. The title is collapsed to one line and cut to containerTitleChars. When the body renders to no blocks, or to more than containerChildBlocks, the section falls back to a header at fallbackLevel followed by the blocks. A container among the children, such as a nested section's, is unwrapped into this one, since Slack does not accept a container inside another.
Slack adds Block Kit blocks faster than Isomer releases. SlackBlock covers the blocks Isomer models. A slack renderer returns any other block through unmodeledSlackBlock, a bridge until Isomer models it:
import { unmodeledSlackBlock } from '@elastic/isomer-sdk/slack';
// elastic/isomer#123
interface SlackCarouselBlock {
type: 'carousel';
elements: SlackCardBlock[];
}
slack: (node) =>
unmodeledSlackBlock<SlackCarouselBlock>({
type: 'carousel',
elements: node.items.map(card),
}),
The type argument is optional. With it, the block's fields are checked against an interface the pack writes from Slack's reference page for the block.
What Isomer does with the block:
- The envelope passes it through exactly as built and counts it as one block toward
blocksPerMessage. It fits nothing inside it, so a field past a Slack limit makes Slack reject the whole message. Keep its content within the limits on Slack's reference page. - The notification
textcomes from the text render, so it carries the node even on a client that cannot draw the block. - Studio's Slack preview warns that the block's limits are not checked and that the preview may not draw it. Check the message in Block Kit Builder.
When Isomer models the type, the pack hears about it before Slack does:
- A call with a literal
typestops compiling, and the error says to return the block withoutunmodeledSlackBlock. Unwrapped, it is Isomer's own block type and the envelope fits it. - When
typeis not a literal, or the pack is not type-checked, the conformance casewraps only Slack blocks Isomer does not modelfails, andisomer-studio checkreports the block. - A wrapped block is never fitted, even one whose type Isomer models, so a stale wrapper costs fitting and nothing else.
isModeledSlackBlockType(type) says whether Isomer models a type, and isUnmodeledSlackBlock(block) whether a block came from unmodeledSlackBlock.
Every unmodeledSlackBlock call has an issue asking Isomer to model its block, so the bridge has an end. Before adding one:
- Check that Isomer does not model the type already:
isModeledSlackBlockType('carousel'), or theSlackBlockunion in the version the pack depends on. If a newer version models it, upgrade instead. - Look for an open issue:
gh issue list -R elastic/isomer --state open --search "carousel in:title". - If there is none, open one from the Model a Slack block template, with
gh issue create -R elastic/isomer --label enhancement --title "feat(sdk): model the Slack carousel block"and a body that follows the template: the block's Slack reference page, the fields the pack emits, the limits Slack documents for it, and the primitive that needs it. - Put the issue's reference in a comment beside the pack's interface for the block, as in the example above, so whoever removes the wrapper can find it.
- Add the block's interface to
define/slack_blocks.tsand to theSlackBlockunion. The type list inrender/slack/unmodeled.tsdoes not compile until the type is added there too. - Add a
SLACK_LIMITSentry for each limit Slack documents, fit it in the envelope, and check it inslackLimitProblems(render/slack/limit_problems.ts). - Export the new types from the
./slackbarrels, and say in the release notes that packs wrapping the type drop the wrapper.
./markdown publishes the md builder and serializeMarkdown, both printed through one GFM serializer that escapes each value where it lands; ./slack the mrkdwn escaping and clamping. 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.
A host that lets a model embed its own elements in Markdown, such as <render_attachment id="…" />, replaces each with content of its own. Cutting the raw string at each tag breaks the Markdown around it: a list restarts, emphasis is left open, a table splits, and a tag inside a code sample is taken for a real one. splitAuthoredMarkdown(source, { elements }) parses the source once, within the parse budget md.authored uses, and returns it in order as markdown segments (content, sanitized as md.authored sanitizes, and the source it was built from) and element segments (name, and attributes as written, keyed by lower-cased name).
splitAuthoredMarkdown('Here is the note: <render_attachment id="a1" /> Anything else?', {
elements: ['render_attachment'],
});
// markdown 'Here is the note:', element { id: 'a1' }, markdown 'Anything else?'
Each tag is found by the grammar of a CommonMark raw HTML open tag on one line, with _ allowed in the name, and swapped for a placeholder before the parse, so the parser's own grammar decides what the tag is. The placeholder keeps the tag's < and > as its edges, so emphasis around a tag reads as it did, and tags written identically share one placeholder, so a reference label holding a tag still matches a definition holding the same. A placeholder in text is a tag. One anywhere else is not: in a code span or block, a raw HTML block or comment, a link destination or title, an image's alt text, or a footnote definition, or escaped with \ or written with entities, the tag stays text.
- A tag directly in a top-level paragraph splits the paragraph in place, so a tag alone on its line splits the text around it.
- A tag inside a list, table, quote, heading, link label, or emphasis is removed from that block, and its element follows the whole top-level block, so a list keeps its numbering and a table its rows.
- A block that held a tag is printed from its parse by the GFM serializer, so it means what the source meant but is written in the serializer's style (
_emphasis,-bullets, fenced code). Emphasis, a link, a heading, or a paragraph the tag leaves empty is dropped; a list item left empty is kept, so the items after it keep their numbers, in Markdown and in the Slack renderer alike. Split text that would open another block is escaped. - Each
markdownsegment is a document of its own. A link or image reference whose definition is in another segment has that definition copied ahead of it, the first definition of the label winning as it did in the source. Footnote definitions stay where they were written, so a footnote cited from another segment prints as its label. - Names match case-insensitively and whole, and the element carries the name as listed. Attribute names are lower-cased, the first of a repeated name wins, a bare attribute is
'', and values are as written: entities are not decoded, and an unquoted value keeps a trailing/(path=/api/>is/api/), as in HTML. A quoted value stays on one line and holds no backtick, so a code span opened before the tag still closes where CommonMark closes it. - Whitespace-only
markdownsegments are dropped. With no tag in the source, the result is one segment built bymd.authoredfrom the source as written. The private-use characters U+E000 and U+E001 are reserved for the placeholders: in the source, literal or as a character reference, they become U+FFFD. - Past the parse budget (measured on the source as written, tags included), at the parser's recursion limit, or with blocks nested more than 64 levels deep, the source is cut at each tag and every piece degrades to inert text, as
md.authoreddegrades; with no parse to say otherwise, a tag inside code is cut there too.
md.authored(segment.source) rebuilds a parsed segment's content, so a host whose composition nodes hold strings can store source instead.