MarkdownOutput.kt
src/jvmMain/kotlin/afield/ui/output/MarkdownOutput.kt · 603 lines
package afield.ui.output
import afield.app.diagnostics.named
import afield.app.protocol.put
import afield.data.table.cell
import afield.data.table.div
import afield.data.table.get
import afield.data.table.minus
import afield.data.table.plus
import afield.data.table.rem
import afield.data.table.times
import afield.document.graph.box
import afield.runtime.execution.plus
import afield.runtime.lifecycle.early
import afield.runtime.lifecycle.generator
import afield.runtime.lifecycle.plus
import afield.runtime.lifecycle.rem
import afield.runtime.lifecycle.then
import afield.tools.docs.screenshot
import afield.ui.notation.string
import afield.ui.notation.with
import afield.ui.visualization.Table
import afield.ui.visualization.chart
import afield.ui.visualization.h
import afield.ui.visualization.table
import afield.audio.synth.*
import afield.document.graph.Box
import afield.graphics.vulkan.*
import afield.media.stream.*
import afield.media.video.*
import afield.runtime.lifecycle.end
import afield.runtime.lifecycle.map
import afield.ui.output.templates.filter
import org.intellij.markdown.flavours.gfm.GFMFlavourDescriptor
import org.intellij.markdown.html.HtmlGenerator
import org.intellij.markdown.parser.MarkdownParser
Markdown output for box output areas: markdown renders GitHub-flavoured markdown, with raw HTML,
side-by-side :::row columns and {role:text} spans, into a styled card using Field's design
tokens. FieldMarkdown holds the renderer and its full syntax; md and mdEscape escape a value
for interpolation.
object FieldMarkdown
Markdown for output areas — the compact way to put a figure in a box's output pane.
The alternative, and what this replaces, is building the HTML by hand: a card <div> with a
hand-picked font stack and hex colours, captions as <div style='color:#c586c0'>, key/value
tables assembled with joinToString into <tr><td style=…>, a <pre> guarded by a locally
defined fun esc(s: String). In the docs that grew to be longer than the code it illustrated.
Markdown says all of it — headings, tables, fenced code (which escapes for free) — and picks up
Field's own --field-* design tokens instead of re-inventing them badly, one figure at a time.
markdown("keys", """
### 1 — capture()
${captured.h}
### 2 — the live structures, read back
| binding | value |
|------------------|-------|
| glow.radius | ${live(glow, "radius")} |
| glow.intensity | ${live(glow, "intensity")} |
""", width = 820)
GitHub-flavoured markdown, so tables and strikethrough work. Beyond it, two additions:
Raw HTML passes through, inline and block. This is the point — ${'$'}{key.h},
${'$'}{a.diffHtml(b)}, ${'$'}{fn.graphSvg()} and friends already return HTML, and they land
verbatim, including inside a table cell. Only text you wrote as markdown gets escaped.
:::row … :::end puts columns side by side, split on a --- line — the before/after shape
markdown has no spelling for:
:::row
#### before
${'$'}{base.graphSvg()}
---
#### after
${'$'}{changed.graphSvg()}
:::end
Close it with :::end, not a bare :::. Both work here, but the docs pipeline eats the bare
form before your code runs — see BLOCK_CLOSE, which is worth reading before you debug a figure
whose columns appeared and then swallowed the rest of the card.
{role:text} marks a span without reaching for a hex literal. The roles are
ROLES: {accent:…} {muted:…} {dim:…} {ok:…} {warn:…} {bad:…}. They nest, and a
literal brace is \{.
Neither addition applies inside fenced or inline code, so a :::row or a {warn:…} shown as
code stays as written.
Three things worth knowing.
The text is dedented for you, and more forgivingly than String.trimIndent — see dedent. You
do not need to call trimIndent(), and interpolating a multi-line value will not wreck the
figure the way it wrecks trimIndent.
Interpolate a multi-line value into a code fence at column zero, hard against the left margin, not at the indentation of the surrounding block:
markdown("persist", """
#### version 0 on disk, as written
~~~json
${'$'}{Files.readString(path)}
~~~
""")
Inside a fence whitespace is the content, and splicing at the margin indents only the value's first line — which nothing downstream can tell apart from content written that way. At column zero the value arrives byte for byte. Anywhere outside a fence, indentation does not matter and you can interpolate wherever it reads best.
Finally, width is not decoration: a docs capture="box" screenshot clips to the output card,
so an unpinned card sized by its widest unbreakable line loses everything past the clip.
object FieldMarkdown {
val ROLES = listOf("accent", "muted", "dim", "ok", "warn", "bad")
Inline span roles: {accent:text} renders as <span class="field-md-accent">text</span>.
fun render(text: String): String
Renders text to an HTML fragment — no card wrapper, so no styling. Usually you want card.
fun render(text: String): String = buildString {
parseBlocks(dedent(text)).forEach { block ->
when (block) {
is Block.Prose -> append(renderMarkdown(block.text))
is Block.Row -> {
val columns = block.columns.filter { it.isNotBlank() }
if (columns.isEmpty()) return@forEach
append("""<div class="field-md-row">""")
columns.forEach { append("""<div class="field-md-col">""").append(renderMarkdown(it)).append("</div>") }
append("</div>")
}
}
}
}
fun row(parts: List<String>): String
parts as one :::row block — the source a Box.showRow card is built from, separate so a
caller composing a larger figure can splice a row into the middle of it.
Each part is dedented on its own. It has to be: the block's own first line is :::row at
column zero, so dedent over the finished card would find no margin to strip, and a
multi-line part written inside indented Kotlin would reach markdown still carrying its
indentation — which markdown reads as a code block and shows verbatim, roles and headings and
all. Every caller writing a """ part hits that, so it is handled here rather than being a
rule to remember.
fun row(parts: List<String>): String =
parts.joinToString("\n---\n", ":::row\n", "\n:::end") { dedent(it) }
fun card(text: String, width: Int? = null): String
Renders text wrapped in the field-md card that carries the stylesheet (see field.css).
width, in CSS pixels, pins the card. Leave it null and the card sizes to its content, which
is right in a live box and wrong for a capture="box" docs screenshot — see FieldMarkdown.
fun card(text: String, width: Int? = null): String {
val style = width?.let { """ style="width:${it}px;max-width:${it}px"""" } ?: ""
return """<div class="field-md"$style>${render(text)}</div>"""
}
--------------------------------------------------------------- indenting
Strips the author's indentation, anchored on the first non-blank line and removed only
from lines that actually carry it — where String.trimIndent instead takes the minimum across
every non-blank line and re-indents the rest.
The difference is the whole reason this exists, and it is about interpolation. A figure builds
its table rows with joinToString("\n") and splices them into an indented """ block; every
row after the first arrives at column 0. That drags trimIndent's minimum to zero, so nothing
is stripped, markdown reads the author's four spaces as an indented code block, and the entire
figure renders as one grey slab. Anchoring on the first line makes the interpolated block's own
indentation irrelevant.
Two refinements sit on top, both there to keep interpolated content intact: fenced bodies are
judged whole rather than line by line (marginIsUniform), and a still-indented line that
opens a tag is flattened (unindentHtmlOpener).
private fun dedent(text: String): String {
val lines = text.lines()
val first = lines.firstOrNull { it.isNotBlank() } ?: return ""
val margin = first.takeWhile { it == ' ' }
fun unmargin(line: String) = if (line.startsWith(margin)) line.drop(margin.length) else line
// Fenced regions are found first, on margin-stripped lines so a fence sitting at the margin
// is still recognised as one, and each is judged as a whole — see [marginIsUniform].
val fenced = BooleanArray(lines.size)
val unmarginFence = BooleanArray(lines.size)
var open: Fence? = null
var start = -1
fun closeRegion(end: Int) {
val content = (start + 1) until end
val uniform = marginIsUniform(lines, content, margin)
content.forEach { fenced[it] = true; unmarginFence[it] = uniform }
}
lines.forEachIndexed { index, raw ->
val marker = fenceAt(unmargin(raw))
val current = open
when {
current == null -> if (marker != null) { open = marker.fence; start = index }
marker != null && marker.closes(current) -> { closeRegion(index); open = null }
}
}
if (open != null) closeRegion(lines.size) // unterminated fence: still treat the rest as content
return lines
.mapIndexed { index, raw ->
if (fenced[index]) (if (unmarginFence[index]) unmargin(raw) else raw)
else unindentHtmlOpener(unmargin(raw))
}
.joinToString("\n")
.trim('\n')
}
Whether every non-blank line of a fence's body carries the author's margin — which decides
whether the body is dedented as a whole or left exactly as written.
All-or-nothing, because inside a fence whitespace is the content and a per-line rule
corrupts it. Hand-written fence bodies all sit at the margin and dedent cleanly. An
interpolated one — a JSON file read off disk, updateCode(), a diff — has its own
indentation and lines at column zero, so nothing is stripped and it survives byte for byte.
The corollary is a rule for authors, and the KDoc on FieldMarkdown states it: interpolate a multi-line value into a fence at column zero, not at the margin. Splicing it at the margin indents only its first line, which no dedent rule can distinguish from content that was written that way.
private fun marginIsUniform(lines: List<String>, range: IntRange, margin: String): Boolean =
range.all { lines[it].isBlank() || lines[it].startsWith(margin) }
Flattens a still-indented line that opens an HTML tag, which markdown would otherwise read as an indented code block and show rather than render.
This is the other half of surviving interpolation, and it exists because HTML-producing APIs
leak indentation. keyframeKeyHtml is the worked example: it wraps its output in a """
literal and calls trimIndent(), but its own interpolated ${'$'}rows puts lines at column
zero, so the minimum indent is zero, nothing is stripped, and the returned HTML carries a
four-space margin. Invisible to every existing caller — leading whitespace means nothing in
HTML — and fatal here, where four spaces means "code block". Rather than chase that bug
through every generator, absorb it.
Only the opening line needs this. Once an HTML block starts, CommonMark takes everything up to the next blank line verbatim, whatever its indentation. Fenced code is skipped by the caller, so HTML shown deliberately as an example stays shown.
private fun unindentHtmlOpener(line: String): String {
val indent = line.takeWhile { it == ' ' }.length
return if (indent >= 4 && line.getOrNull(indent) == '<') line.drop(indent) else line
}
---------------------------------------------------------------- blocks
private sealed interface Block {
data class Prose(val text: String) : Block
data class Row(val columns: List<String>) : Block
}
private const val ROW_OPEN = ":::row"
What closes a :::row.
:::end is the spelling to use, and the reason is the docs pipeline: public/docs_hooks.py
treats a line whose stripped form is exactly ::: as one of its own fence-section markers
(alongside :::static and :::fold) and deletes it from the code it runs. A figure that
closed its row with a bare ::: therefore lost the terminator before the box ever executed —
the row stayed open and swallowed everything after it, silently, and only in a docs build.
:::row survives that filter because it is not a bare :::, which is what made the failure
so confusing: the columns appeared, the closing did not.
A bare ::: is still accepted, so the dialect behaves the way anyone would guess in a live
box, a test, or anywhere else the docs pipeline is not involved.
private val BLOCK_CLOSE = setOf(":::end", ":::")
Splits src into prose runs and :::row blocks. Code fences are tracked so a :::row or a
--- shown as example code is left alone. An unterminated row is closed at the end rather
than dropped — a missing ::: should cost you a layout, not the whole figure.
source — 50 lines, from line 298
private fun parseBlocks(src: String): List<Block> {
val blocks = mutableListOf<Block>()
val prose = StringBuilder()
var columns: MutableList<StringBuilder>? = null
var fence: Fence? = null
fun sink(): StringBuilder = columns?.last() ?: prose
fun flushProse() {
if (prose.isNotBlank()) blocks += Block.Prose(prose.toString())
prose.setLength(0)
}
for (line in src.lines()) {
val marker = fenceAt(line)
val open = fence
if (open == null) {
if (marker != null) {
fence = marker.fence
sink().appendLine(line)
continue
}
} else {
if (marker != null && marker.closes(open)) fence = null
sink().appendLine(line)
continue
}
val trimmed = line.trim()
when {
columns == null && trimmed == ROW_OPEN -> {
flushProse()
columns = mutableListOf(StringBuilder())
}
columns != null && trimmed in BLOCK_CLOSE -> {
blocks += Block.Row(columns.map { it.toString() })
columns = null
}
columns != null && isColumnBreak(trimmed) -> columns.add(StringBuilder())
else -> sink().appendLine(line)
}
}
columns?.let { blocks += Block.Row(it.map { column -> column.toString() }) }
flushProse()
return blocks
}
Inside a :::row, a rule line starts the next column.
private fun isColumnBreak(trimmed: String): Boolean = trimmed.length >= 3 && trimmed.all { it == '-' }
----------------------------------------------------------------- roles
Rewrites {role:text} spans outside fenced code.
Fences are found line by line, because that is how they are delimited, and each run of
non-fenced lines is then rewritten as one string so a role may wrap across lines. That
matters: a muted annotation under a chart is usually a paragraph, and requiring it to sit on
one physical line would mean a {muted: opened at the top and closed three lines down failed
silently, leaving the braces on screen. Indented (four-space) code blocks are not detected, so
show example code in a fence rather than by indenting it.
source — 78 lines, from line 364
private fun applyRoles(md: String): String {
if (!md.contains('{')) return md
val out = StringBuilder(md.length + 64)
val prose = StringBuilder()
var fence: Fence? = null
fun flushProse() {
if (prose.isNotEmpty()) {
out.append(applyRolesIn(prose.toString()))
prose.setLength(0)
}
}
md.lines().forEachIndexed { index, line ->
val marker = fenceAt(line)
val open = fence
val isCode = when {
open == null && marker != null -> { fence = marker.fence; true }
open == null -> false
else -> { if (marker != null && marker.closes(open)) fence = null; true }
}
val piece = if (index == 0) line else "\n$line"
if (isCode) {
flushProse()
out.append(piece)
} else {
prose.append(piece)
}
}
flushProse()
return out.toString()
}
private fun applyRolesIn(line: String): String {
if (!line.contains('{')) return line
val out = StringBuilder(line.length + 32)
var i = 0
while (i < line.length) {
when {
// An inline code span is copied verbatim, braces and all. It cannot cross a line
// break, so the search for its closing run stops at the end of this one.
line[i] == '`' -> {
val ticks = line.drop(i).takeWhile { it == '`' }.length
val lineEnd = line.indexOf('\n', i).let { if (it < 0) line.length else it }
val close = line.indexOf("`".repeat(ticks), i + ticks).takeIf { it in 0 until lineEnd } ?: -1
val end = if (close < 0) minOf(i + ticks, lineEnd) else close + ticks
out.append(line, i, end)
i = end
}
line[i] == '\\' && line.getOrNull(i + 1) == '{' -> {
out.append('{')
i += 2
}
line[i] == '{' -> {
val role = ROLES.firstOrNull { line.startsWith("$it:", i + 1) }
val end = if (role == null) -1 else matchingBrace(line, i)
if (role == null || end < 0) {
out.append(line[i])
i++
} else {
val body = line.substring(i + role.length + 2, end)
out.append("""<span class="field-md-$role">""")
.append(applyRolesIn(body))
.append("</span>")
i = end + 1
}
}
else -> {
out.append(line[i])
i++
}
}
}
return out.toString()
}
Index of the } closing the { at open, honouring nesting, or -1 when unbalanced.
private fun matchingBrace(line: String, open: Int): Int {
var depth = 0
for (i in open until line.length) {
when (line[i]) {
'{' -> depth++
'}' -> if (--depth == 0) return i
}
}
return -1
}
---------------------------------------------------------------- fences
private data class Fence(val char: Char, val length: Int)
private data class FenceMarker(val fence: Fence, val info: String) {
/** A fence closes an open one only when it matches and carries no info string. */
fun closes(open: Fence): Boolean =
info.isEmpty() && fence.char == open.char && fence.length >= open.length
}
private fun fenceAt(line: String): FenceMarker? {
val indent = line.takeWhile { it == ' ' }.length
if (indent > 3) return null
val rest = line.substring(indent)
val char = rest.firstOrNull() ?: return null
if (char != '`' && char != '~') return null
val length = rest.takeWhile { it == char }.length
if (length < 3) return null
val info = rest.substring(length).trim()
// A backtick in the info string means this is an inline code span, not a fence.
if (char == '`' && info.contains('`')) return null
return FenceMarker(Fence(char, length), info)
}
--------------------------------------------------------------- parsing
Parser and flavour are built per call rather than shared. Boxes run concurrently and
GFMFlavourDescriptor initialises its parser manager lazily; the allocation is noise next to
the parse, and a shared instance would be a race.
private fun renderMarkdown(md: String): String {
val src = applyRoles(md)
val flavour = GFMFlavourDescriptor()
val tree = MarkdownParser(flavour).buildMarkdownTreeFromString(src)
return HtmlGenerator(src, tree, flavour).generateHtml()
.removePrefix("<body>")
.removeSuffix("</body>")
}
}
fun Box.markdown(channel: String, text: String, width: Int? = null, open: Boolean = true)
Shows text, rendered as markdown, in this box's output area on channel.
The compact way to put a figure in an output pane — see FieldMarkdown for the dialect, which is
GitHub-flavoured markdown plus raw-HTML passthrough, :::row columns and {role:text} spans.
The text is trimmed for you, so a """ block indented inside a with(…) { } renders as intended.
markdown("keys", """
### version history after three saves
| # | file | note |
|---|------|------|
${versions.joinToString("\n") { "| ${'$'}{it.index} | ${'$'}{it.file} | ${'$'}{it.note ?: "—"} |" }}
dropped in silence: {bad:${'$'}{dropped.joinToString(", ")}}
""", width = 820)
@param channel output disclosure that receives the card, and its title
@param text markdown; ${'$'}-interpolated HTML from other Field APIs passes through verbatim
@param width pins the card to this many CSS pixels — set it for a capture="box" docs screenshot,
which clips to the output card and would otherwise cut off anything past the clip
@param open reveal the disclosure, so the figure is visible without a click
fun Box.markdown(channel: String, text: String, width: Int? = null, open: Boolean = true) {
MarkdownAppendHistory.replace(this, channel, text) {
display(channel, FieldMarkdown.card(text, width))
if (open) displayOpen(channel)
}
}
fun Box.showRow(channel: String, parts: List<String>, width: Int? = null)
Puts parts side by side in one card — the :::row block without the string plumbing.
Each part is markdown, so raw HTML passes through exactly as it does anywhere else in a figure:
an SVG from histogramToSvg, a base64 img, a Table.h. This is the shape a
before/after or a strip of small figures wants, and the alternative people reach for is a
hand-rolled <div style='display:flex;gap:12px'> — which is a second layout vocabulary, and one
that does not wrap or theme with the rest of the card.
showRow("histograms", samples.map { histogramToSvg(it) })
@param channel output disclosure that receives the card, and its title
@param parts one column each, in order; blank parts are dropped, as :::row itself drops them
@param width pins the card, as in Box.markdown
fun Box.showRow(channel: String, parts: List<String>, width: Int? = null) {
markdown(channel, FieldMarkdown.row(parts), width)
}
fun Box.showRow(channel: String, vararg parts: String, width: Int? = null)
showRow with the columns written inline rather than collected into a list.
fun Box.showRow(channel: String, vararg parts: String, width: Int? = null) =
showRow(channel, parts.asList(), width)
fun Box.markdown(text: String, width: Int? = null, open: Boolean = true)
Shows text as markdown on the default output channel. See Box.markdown.
fun Box.markdown(text: String, width: Int? = null, open: Boolean = true) {
markdown("output", text, width, open)
}
private const val MARKDOWN_PUNCTUATION = "\\`*_{}[]()#+-.!|<>&~"
fun mdEscape(value: Any?): String
value as literal text inside markdown — the safe way to interpolate a computed value into a
Box.markdown figure, and effectively required inside a table cell.
Runtime values are not markdown. A | splits the table row it lands in, a pair of * or _
turns into emphasis, something shaped like <b> is read as raw HTML and disappears rather than
being shown, and a newline ends the row early. Field values hit all of these in practice — a
StackSL program reads saw 110 *, a path or a toString() can hold anything. This
backslash-escapes markdown punctuation and folds newlines to spaces, so what you computed is what
the figure shows. null renders as null, matching plain ${'$'} interpolation.
Two places not to use it. HTML from another Field API — key.h, diffHtml(), graphSvg() — is
meant to pass through, so interpolate that directly. And a fenced code block is already
literal, so a multi-line dump belongs in a fence rather than here.
fun mdEscape(value: Any?): String {
val text = value.toString().replace("\r\n", "\n")
return buildString(text.length + 16) {
text.forEach { c ->
when {
c == '\n' || c == '\r' -> append(' ')
c in MARKDOWN_PUNCTUATION -> append('\\').append(c)
else -> append(c)
}
}
}
}
val Any?.md: String
mdEscape as a property, so a value escapes itself where it is interpolated —
"| ${'$'}{worker.state.md} |" rather than "| ${'$'}{mdEscape(worker.state)} |". The pair
matches val Any?.h, which does the same for the HTML visualizer.
A ${'$'}{...} template around a call reads as machinery; a suffix on the value reads as the
value, and a table row built out of six of them stays a table row. Same escaping, same null,
and the same two places not to use it — see mdEscape.
One thing to know, shared with .h: this is an extension on Any?, so a bare ${'$'}md inside
a string template resolves to some receiver's .md rather than failing to compile, when no
local named md is in scope. Name a local markdown and interpolate ${'$'}{it.md} explicitly.
val Any?.md: String
get() = mdEscape(this)