|
1 | 1 | /** |
2 | 2 | * @summary Competent single-main-thread markdown → block-HTML renderer for the benchmark comparator |
3 | | - * (Subject B). |
| 3 | + * (Subject B), with blank-line-incremental parsing. |
4 | 4 | * |
5 | 5 | * The honest comparator does the SAME class of work as Subject A's off-thread Neo parser — block |
6 | 6 | * segmentation, inline marks, and HTML escaping (hostile input rendered inert) — but synchronously on |
7 | | - * the MAIN thread. It returns per-block HTML so the app applies the stream tail-incrementally (append |
8 | | - * new blocks, re-render only the open last block), never an O(n²) full-`innerHTML` rewrite. |
| 7 | + * the MAIN thread. {@link parseMarkdownBlocks} parses a full string; the streaming driver |
| 8 | + * {@link createIncrementalBlocks} MEMOIZES — it settles completed blocks once and re-parses only the |
| 9 | + * open region from the deltas it is fed. |
9 | 10 | * |
10 | | - * Pure (string → string[]), dependency-free, unit-tested. Fairness posture (full rationale + the |
11 | | - * naive-vs-best-practice fork on the benchmark ticket thread): this parser is competent but simpler than |
12 | | - * Neo's full grammar, so it does LESS main-thread work — a CONSERVATIVE bias (a lighter parser makes |
13 | | - * the comparator MORE responsive, shrinking any Neo advantage), which is the honest direction for a |
14 | | - * falsifier. A real library (`marked`) is the documented alternative if reviewers want tighter |
15 | | - * parse-work parity. |
| 11 | + * Why blank-line granularity (not per-block): a blank line is the ONLY block boundary a forward-only |
| 12 | + * token stream can never un-merge — a mid-stream `- a` followed by `-` parses as list + paragraph, yet |
| 13 | + * becomes ONE list once `- b` arrives, so settling per-block would corrupt the output. Blocks before |
| 14 | + * the last blank line are permanently separated; everything after it is the open region, re-parsed each |
| 15 | + * delta. Per-`push` work is therefore bounded by the blank-line cadence of the stream (the benchmark's |
| 16 | + * `LoadProfile` corpus emits `\n\n` separators regularly), NOT by the whole transcript — keeping the |
| 17 | + * comparator's parse cost off the O(n²) full-re-parse path. |
| 18 | + * |
| 19 | + * Why incremental matters for the falsifier (the benchmark's naive-vs-best-practice fork → best practice): a full |
| 20 | + * re-parse of the growing source each tick is O(n²) over a session AND conflates Neo's parser |
| 21 | + * memoization with the worker-topology variable under test. Memoizing here leaves the lag delta |
| 22 | + * measuring ONLY where the work runs (main thread vs worker) — the variable this benchmark isolates. No |
| 23 | + * `innerHTML` O(n²) rewrite on render either (the app applies tail-incrementally). |
| 24 | + * |
| 25 | + * Fairness posture: this parser is competent but simpler than Neo's full grammar, so it does LESS |
| 26 | + * main-thread work per block — a CONSERVATIVE bias (a lighter parser makes the comparator MORE |
| 27 | + * responsive, shrinking any Neo advantage), the honest direction for a falsifier. Residual: a multi-block |
| 28 | + * run with no blank line between (e.g. a heading immediately followed by a paragraph) re-parses together |
| 29 | + * until the next blank line — a bounded, negligible over-count. A real library (`marked`) is the |
| 30 | + * documented alternative if reviewers want tighter parse-work parity. |
| 31 | + * |
| 32 | + * Pure (no DOM / Neo / Playwright), dependency-free, unit-tested. |
16 | 33 | */ |
17 | 34 |
|
18 | 35 | const ESCAPE = {'&': '&', '<': '<', '>': '>', '"': '"', "'": '''}; |
@@ -108,4 +125,63 @@ export function parseMarkdownBlocks(source) { |
108 | 125 | return blocks |
109 | 126 | } |
110 | 127 |
|
| 128 | +/** |
| 129 | + * Index just after the LAST blank line in `source` — the highest offset before which every block is |
| 130 | + * permanently settled (a forward-only append can never merge a block across a blank line). Returns 0 |
| 131 | + * when no blank line is present yet (nothing settled). A blank line is a newline, an optional run of |
| 132 | + * spaces/tabs, then a newline. |
| 133 | + * @param {String} source |
| 134 | + * @returns {Number} |
| 135 | + * @private |
| 136 | + */ |
| 137 | +function lastBlankBoundary(source) { |
| 138 | + const re = /\n[ \t]*\n/g; |
| 139 | + let boundary = 0, match; |
| 140 | + |
| 141 | + while ((match = re.exec(source)) !== null) { |
| 142 | + boundary = match.index + match[0].length; |
| 143 | + re.lastIndex = match.index + 1; // step one char so consecutive blank-line runs keep advancing |
| 144 | + } |
| 145 | + |
| 146 | + return boundary |
| 147 | +} |
| 148 | + |
| 149 | +/** |
| 150 | + * @summary A stateful, delta-fed incremental block parser — the memoizing front-end for streaming. |
| 151 | + * |
| 152 | + * Feed it appended deltas via {@link push}; it keeps only the OPEN source after the last blank line, |
| 153 | + * settles the blocks before that boundary exactly once, and returns the current full block-HTML list |
| 154 | + * each call. Per-`push` work is bounded by the open region (since the last blank line), NOT the whole |
| 155 | + * transcript — the property that keeps the comparator's parse cost off the O(n²) full-re-parse path and |
| 156 | + * isolates the worker-topology variable the benchmark measures. |
| 157 | + * |
| 158 | + * @returns {{push: (function(String): String[])}} |
| 159 | + */ |
| 160 | +export function createIncrementalBlocks() { |
| 161 | + let settledHtml = [], |
| 162 | + openSource = ''; |
| 163 | + |
| 164 | + return { |
| 165 | + /** |
| 166 | + * Append a streamed delta and return the current block-HTML list (settled blocks + the open |
| 167 | + * region's blocks). Pass the DELTA, not the accumulated source. |
| 168 | + * @param {String} [textDelta=''] |
| 169 | + * @returns {String[]} |
| 170 | + */ |
| 171 | + push(textDelta = '') { |
| 172 | + openSource += textDelta; |
| 173 | + |
| 174 | + const boundary = lastBlankBoundary(openSource); |
| 175 | + |
| 176 | + if (boundary > 0) { |
| 177 | + // Everything before the last blank line can never change → settle it once. |
| 178 | + settledHtml.push(...parseMarkdownBlocks(openSource.slice(0, boundary))); |
| 179 | + openSource = openSource.slice(boundary); |
| 180 | + } |
| 181 | + |
| 182 | + return [...settledHtml, ...parseMarkdownBlocks(openSource)] |
| 183 | + } |
| 184 | + }; |
| 185 | +} |
| 186 | + |
111 | 187 | export default parseMarkdownBlocks; |
0 commit comments