Files
mantra-kmp/docs/scripts/markdown-block-fixtures.js
Kgothatso Ngako 7c76359b34 feat(chapters): a chunk is a markdown block, not a blank-line paragraph
Phases 7 and 8 of docs/chapter-uploads.md.

Measured against markdown-it -- the parser mantra.press translates with --
this app's blank-line split disagreed on ten cases in eleven, and on the text
as well as the boundaries. markdown-it's content is a leaf block's inline
source with the BLOCK marker removed, so `# Chapter one` is "Chapter one"
while inline markup is kept. That is the half that matters to a translator:
handed "# Chapter one" they can break the document by mistyping the hash, and
handed "Chapter one" they cannot.

So splitBlocks adopts it, boundaries and stripped text both, with three
deliberate departures. Code blocks yield no chunk -- markdown-it's fence and
code_block carry content, so mantra.press asks a translator to translate
`code();` and the group to sign it as prose. A table is one chunk of its own
source rather than one per cell, because four envelopes to carry one word
each is a poor trade and a table's alignment is structure per-cell
translation cannot preserve. And a link reference definition yields nothing,
which is free: it is markdown-it's own behaviour with the `reference` rule
left enabled, where mantra.press disables it -- reaching for a block rule to
stop links rendering -- and makes a translatable chunk of a URL.

Hand-rolled, not org.jetbrains:markdown, for one reason specific to this
function: its output is hashed into ids a quorum signs, so a version bump
that moved one boundary would silently re-split every chapter proposed
afterwards and nothing would notice. What pins it instead is thirty golden
fixtures generated FROM markdown-it by docs/scripts/markdown-block-fixtures.js,
which also encodes the three departures so the Kotlin test is plain equality.

MarkdownProducer is new and is what makes stripping markers safe: nothing in
the app could put a `#` back before. It substitutes by index, which is
strictly better than the thing it mirrors -- mantra.press matches on text, so
a chapter with two identical paragraphs puts the first one's translation into
both. The scanner segments every line rather than only the chunks, which is
what makes the byte-for-byte round trip exact by construction rather than by
getting every markup rule right.

ProposedEvent.summarize stops re-splitting a chapter to count its chunks and
takes the count from the batch. That number is a count of the batch dressed as
a fact about the text: across a splitter change the figure in the approval
gate would disagree with the events being approved, and it re-scanned every
open proposal's prose on every emission of the room's list.

Phase 2's constant was flagged as provisional pending this, and measured it
did not move: prose separated by blank lines splits the same under both rules,
so the fixture pinning 24 KiB over forty paragraphs is the shape this does not
touch. The narrower claim holds -- a heading- or list-heavy chapter now reaches
both caps sooner, visibly, in the form's own counter.

987 tests, no failures; m3Audit's budgets all met.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-02 19:48:45 +02:00

178 lines
7.1 KiB
JavaScript

#!/usr/bin/env node
//
// Generates the golden fixtures for `Markdown.splitBlocks`, from markdown-it.
//
// `Markdown.splitBlocks` is hand-rolled, and its output is hashed into chunk
// event ids that a quorum signs -- so it is pinned by measurement against the
// reference implementation rather than by anybody's reading of CommonMark. This
// script is that measurement. It runs markdown-it over the corpus below and
// writes the expected chunks as a Kotlin source file in commonTest.
//
// node docs/scripts/markdown-block-fixtures.js \
// --markdown-it <path to a markdown-it install> \
// [--out composeApp/src/commonTest/kotlin/.../MarkdownBlockFixtures.kt]
//
// mantra.press has one: its node_modules/markdown-it. Pass that path.
//
// THE THREE DEPARTURES are applied here, in the oracle, so the Kotlin test is a
// plain equality check and the departures are visible in one place:
//
// 1. A fenced or indented code block yields no chunk. markdown-it's `fence`
// and `code_block` tokens carry content, so mantra.press turns `code();`
// into a chunk -- with a trailing newline in its text -- and asks a
// translator to translate it and the group to sign it as prose.
// 2. A table is one chunk of its own source, not one per cell. Four chunks
// for a 2x2 table is markdown-it's honest reading, but each costs an event
// envelope to carry one word and a table's alignment is structure that
// per-cell translation cannot preserve.
// 3. A link reference definition yields no chunk. This one is free: it is
// markdown-it's own behaviour with the `reference` block rule left
// enabled. mantra.press disables it -- reaching for the wrong rule name to
// stop links rendering -- which drops `[ref]: https://...` through to
// `paragraph` and makes a translatable chunk of a URL.
//
// `html: false` is markdown-it's default and is kept: an HTML block comes back
// as a paragraph, which is what the Kotlin subset does too.
const fs = require('fs')
const path = require('path')
function arg(name, fallback) {
const i = process.argv.indexOf(name)
return i >= 0 && process.argv[i + 1] ? process.argv[i + 1] : fallback
}
const mdPath = arg('--markdown-it', null)
if (!mdPath) {
console.error('--markdown-it <path to a markdown-it install> is required')
process.exit(2)
}
const outPath = arg(
'--out',
'composeApp/src/commonTest/kotlin/press/mantra/compose/text/MarkdownBlockFixtures.kt'
)
const md = require(path.resolve(mdPath))()
/**
* The corpus. Each entry is a shape that either appears in real chapters or has
* already caught something. The converter shapes come from mantra.press's own
* scripts/convert-btc-tex-to-markdown.js, which is what the files being
* uploaded are made by.
*/
const CORPUS = {
'one paragraph': 'A single paragraph.\n',
'two paragraphs': 'First paragraph.\n\nSecond paragraph.\n',
'lazy continuation': 'First line.\nSecond line of the same paragraph.\n',
'atx heading apart': '# Chapter one\n\nThe first paragraph.\n',
'atx heading glued': '# Chapter one\nThe first paragraph.\n',
'deep atx heading': '#### A fourth-level heading\n\nBody.\n',
'converter shape': '# Chapter one\nFirst paragraph here.\n\nSecond paragraph here.\n',
'setext h1': 'Title\n=====\n\nBody.\n',
'setext h2': 'Subtitle\n--------\n\nBody.\n',
'tight bullet list': 'Shopping:\n\n- milk\n- bread\n- eggs\n',
'ordered list': '1. first\n2. second\n',
'nested list': '- outer\n - inner\n',
'blockquote two lines': '> line one\n> line two\n',
'two blockquotes': '> first\n\n> second\n',
'thematic break': 'One.\n\n---\n\nTwo.\n',
'fenced code': 'Text.\n\n```\ncode();\n```\n\nMore.\n',
'fenced code with info': 'Text.\n\n```kotlin\nval x = 1\n```\n',
'indented code': 'Text.\n\n indented code\n\nMore.\n',
'table': '| a | b |\n|---|---|\n| 1 | 2 |\n',
'table with prose': 'Before.\n\n| a | b |\n|---|---|\n| 1 | 2 |\n\nAfter.\n',
'link reference definition': 'See [it][ref].\n\n[ref]: https://example.com\n',
'inline markup survives': 'A *word* and a [link](https://example.com) and `code`.\n',
'trailing hard break': 'Line one \nLine two\n',
'no trailing newline': 'No newline at the end.',
'leading blank lines': '\n\nA paragraph after blank lines.\n',
'crlf already normalised': 'One.\n\nTwo.\n',
'html block falls back to a paragraph': '<div>html block</div>\n',
'heading then list then quote':
'# Title\n\n- one\n- two\n\n> quoted\n\nEnd.\n',
'multi paragraph blockquote': '> first para\n>\n> second para\n',
'unicode': 'Ke a leboga.\n\nሰላም ለዓለም።\n',
}
/** The expected chunks for one input, with the three departures applied. */
function expected(source) {
const lines = source.split('\n')
const tokens = md.parse(source, {})
const out = []
let tableDepth = 0
for (const token of tokens) {
if (token.type === 'table_open') {
// Departure 2: one chunk of the table's own source.
const [start, end] = token.map
out.push(lines.slice(start, end).join('\n'))
tableDepth++
continue
}
if (token.type === 'table_close') {
tableDepth--
continue
}
if (tableDepth > 0) continue
// Departure 1: code carries content and is not prose.
if (token.type === 'fence' || token.type === 'code_block') continue
// Departure 3 needs no code: with the `reference` rule enabled, a link
// reference definition produces no token at all.
if (token.type === 'inline' && token.content) out.push(token.content)
}
return out
}
function kotlinString(value) {
return '"' + value
.replace(/\\/g, '\\\\')
.replace(/"/g, '\\"')
.replace(/\$/g, '\\$')
.replace(/\n/g, '\\n')
.replace(/\r/g, '\\r')
.replace(/\t/g, '\\t') + '"'
}
const entries = Object.entries(CORPUS).map(([name, source]) => {
const chunks = expected(source)
return ` Fixture(
name = ${kotlinString(name)},
markdown = ${kotlinString(source)},
chunks = listOf(${chunks.length === 0 ? '' : '\n' + chunks.map(c => ' ' + kotlinString(c)).join(',\n') + ',\n '}),
),`
}).join('\n')
const file = `package press.mantra.compose.text
// GENERATED by docs/scripts/markdown-block-fixtures.js -- do not edit by hand.
//
// The expected output of [Markdown.splitBlocks] for ${Object.keys(CORPUS).length} inputs, taken from
// markdown-it with the three departures recorded in docs/chapter-uploads.md
// applied by the generator. Regenerate with:
//
// node docs/scripts/markdown-block-fixtures.js \\
// --markdown-it <path>/node_modules/markdown-it
//
// A change here is a change to what a chunk is, and so to the ids a quorum
// signs. It should arrive with a reason.
data class Fixture(
val name: String,
val markdown: String,
val chunks: List<String>,
)
val MARKDOWN_BLOCK_FIXTURES: List<Fixture> = listOf(
${entries}
)
`
fs.mkdirSync(path.dirname(outPath), { recursive: true })
fs.writeFileSync(outPath, file)
console.log(`wrote ${Object.keys(CORPUS).length} fixtures to ${outPath}`)