Files
mantra-kmp/docs/scripts/markdown-block-fixtures.js
T

177 lines
7.1 KiB
JavaScript
Raw Normal View History

#!/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}`)