brookmd 0.25.1 → 0.26.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,271 @@ Notable changes to brookmd (formerly `flux-md`). Format based on
4
4
  [Keep a Changelog](https://keepachangelog.com/); this project aims to follow
5
5
  [Semantic Versioning](https://semver.org/).
6
6
 
7
+ ## 0.26.0 — 2026-07-30
8
+
9
+ **Rendered HTML bytes change in this release.** Everything new below is opt-in
10
+ and default-off — with the flags off those paths are byte-identical to 0.25.2 —
11
+ but the output-fidelity work is unconditional: container framing newlines,
12
+ paragraph whitespace, table line breaks, fenced-code indentation, and the
13
+ task-list checkbox form now match the CommonMark and GFM reference renderers
14
+ exactly. No API is removed, and the wire envelope, ids, and commit semantics are
15
+ untouched — but HTML snapshots taken against 0.25.x will need regenerating.
16
+ That, not the new features, is why this is a minor bump.
17
+
18
+ What it buys: **652/652 CommonMark 0.31 and 24/24 GFM, byte-exact** against the
19
+ reference renderers, where before the suites passed only after structural
20
+ normalization. Byte-exactness is now the harnesses' default floor and is pinned
21
+ in CI, so one regressed byte fails the build.
22
+
23
+ Requires `brookmd-core` 0.25.0.
24
+
25
+ ### Added
26
+
27
+ - **`softBreaks` — a soft line break renders as `<br>`.** Strict CommonMark
28
+ treats a bare `\n` inside a paragraph as whitespace, so a model that writes one
29
+ thought per line gets one reflowed blob. This is the `remark-breaks` /
30
+ chat-comment convention where one Enter is one visual line, and it is what most
31
+ chat UIs actually want. Off by default; it only ever *adds* breaks (a hard break
32
+ is `<br>` either way), so no existing output loses a line.
33
+ - **`allowSchemes` — un-block a URL scheme brookmd blocks by default.** Bare
34
+ scheme names, no colon (`allowSchemes: ["file"]`), matched case-insensitively.
35
+ It reaches exactly one tier: the *overridable*-blocked schemes, today `file:`.
36
+ The script-executing tier (`javascript:`, `data:text/html`, …) is
37
+ non-overridable — listing one is a silent no-op, not an escape hatch — and the
38
+ encoded-evasion neutralization (percent- and entity-encoded scheme prefixes) is
39
+ unchanged and runs before the check. This exists for privileged embedders —
40
+ Electron shells, extensions, editor preview panes — that intercept link clicks
41
+ instead of navigating.
42
+ - **`lenientLists` — rescue an over-indented list item from becoming a code
43
+ block.** CommonMark §5.2 says a marker followed by 5+ columns of whitespace
44
+ starts an indented code block, so a model emitting `- const value = 1;`
45
+ renders a `<pre><code>` instead of a list item. With the flag on, a marker
46
+ followed by **6 or more columns of literal spaces** absorbs the padding into the
47
+ content column and the text parses as the item's own markdown. Deliberately
48
+ narrow: exactly 5 spaces still opens code (that column is the spec boundary
49
+ itself), a fence on the marker line stays a fence, an indent on a *later* line
50
+ stays code, and **tab** padding stays code — tabs are an authoring choice, model
51
+ over-indentation is always literal spaces. Excluding tabs is what holds the
52
+ divergence to a single spec example (274) and only while the flag is on; the
53
+ conformance suites run strict and are unaffected.
54
+ - **`blockHtml` — block-level raw HTML through the safe sanitizer.** A
55
+ `<details><summary>…</summary>…</details>` block rendered as an escaped code
56
+ block before; now it renders as real elements, sanitized. Only CommonMark HTML
57
+ block **types 6 and 7** qualify — types 1–5 (`<script>`/`<pre>`/`<style>`/
58
+ `<textarea>`, comments, processing instructions, declarations, CDATA) stay
59
+ escaped by design, so the constructs that can execute or swallow the rest of
60
+ the document never take effect.
61
+ It shares its token core with the inline raw-HTML path (one policy, not two),
62
+ tracks an open-tag stack with speculative closers so a half-streamed
63
+ `<details` never leaks a broken element, carries a void-element table so `<hr>`
64
+ and friends are not pushed onto that stack, and caps nesting depth at 100.
65
+ Half-streamed tags are suppressed while open and settle at finalize. Takes
66
+ effect only when the sanitizer is engaged (`htmlAllowlist` or `dropHtmlTags`);
67
+ on its own it does nothing.
68
+ - **`CodeBlockData.meta` — the fence info string's remainder.**
69
+ ```` ```ts title="src/main.ts" ```` now yields `lang: "ts"` **and**
70
+ `meta: 'title="src/main.ts"'` on the data channel and as a `meta` prop on
71
+ `components.CodeBlock`. Always-on like `lang` (no `blockData` needed) and
72
+ omitted entirely when the fence carried none, so a fence without meta is
73
+ byte-identical to before. It is deliberately **not** in the rendered HTML —
74
+ there is no `data-meta` attribute — because a filename header is a component's
75
+ job, not the parser's. While streaming it appears only once it can no longer
76
+ change (the opening fence line terminated by a newline, or at finalize), so a
77
+ header never flickers through a half-typed `title="src/ma`.
78
+ - **`ListItemData.start` — a source byte offset per list item.** Under
79
+ `blockData`, each top-level `items[]` entry carries the document-absolute offset
80
+ of the byte where its marker begins, same origin as `Block.start` and stable as
81
+ the document grows. That is enough to build task-checkbox writeback: locate the
82
+ `[ ]` from the item's offset and flip it in the original markdown, no HTML
83
+ round-trip. **Nested** items carry no offset rather than a wrong one — a nested
84
+ list is not a separate block, it renders against a synthesized de-indented
85
+ string with no document offset — and that limitation is documented on the type.
86
+ - Together `meta` and `start` bump the **wire contract to 1.3.0** (WIRE.md §9):
87
+ purely additive optional `data` keys, byte-identical when unexercised.
88
+ - All four flags are plumbed through every surface: client/worker config, the
89
+ server renderer, the `<brook-markdown>` element (`soft-breaks`,
90
+ `lenient-lists`, `block-html`, `allow-schemes`), React Native, Kotlin, Swift,
91
+ and the Flutter hand-written config.
92
+ - **Byte-exact conformance mode in the spec harnesses**, with default ratchet
93
+ floors `CMARK_MIN_EXACT=652` / `GFM_MIN_EXACT=24` — pinned explicitly in both
94
+ the CI and publish workflows alongside the older normalized floors. The four
95
+ deliberate differences from the reference (`target`/`rel` on links, `data-lang`
96
+ on code blocks, HTML5 void `<br>`, `style="text-align:…"` instead of GFM's
97
+ deprecated `align`) are folded by a documented `canonicalize` step applied to
98
+ **both** sides — the only transform on the byte-exact path, so it can erase our
99
+ intentional extras but never hide a structural divergence.
100
+ - **An unconditional `bindings` CI job** — FFI and C-ABI crate tests plus
101
+ regeneration-freshness checks for the React Native, Kotlin, and Swift bindings.
102
+ The existing binding workflows only ran on `pull_request` behind `paths:`
103
+ filters, so a push to main or a JS-only PR gated on nothing; that is how stale
104
+ binding goldens survived.
105
+
106
+ ### Changed
107
+
108
+ - **Container inner newlines now match the reference exactly** (cmark's `cr()`
109
+ rule): `<li>` / `<blockquote>` / alert framing newlines land where the
110
+ reference puts them, an empty blockquote gets its newline, and a `<li>` opening
111
+ with a tight paragraph no longer emits a spurious `\n`.
112
+ - **Paragraph whitespace follows the spec line by line.** Leading indentation is
113
+ stripped on *every* line, not just the first; trailing spaces and tabs are
114
+ dropped before a soft break; a hard-break line sheds the *next* line's indent.
115
+ Lazy continuation lines in blockquotes and lists keep their `\n` instead of
116
+ being glued with a space.
117
+ - **GFM tables emit one element per line**, matching the reference's framing, and
118
+ task-list checkboxes emit the reference byte-form
119
+ (`checked="" disabled="" type="checkbox"`).
120
+ - **Fenced code**: the body is de-indented by the opening fence's own indent (so
121
+ a fence at columns 1–3 no longer carries that indent into every line);
122
+ significant trailing spaces inside the last line are preserved; interior blank
123
+ lines before the closer are preserved. Tab-column arithmetic is fixed after
124
+ blockquote markers, across item-content boundaries, and after list markers.
125
+ - **Document assembly is now defined and documented** ([WIRE.md
126
+ §12](../../crates/brookmd-core/WIRE.md)): a block's `html` carries **no
127
+ trailing newline** — the terminator after a top-level block belongs to the
128
+ document, not the block — and concatenating blocks is a `cr()`-join.
129
+ `renderToString` follows that rule, so a server-rendered document and a
130
+ reference render agree byte-for-byte.
131
+ - **Native `BrookConfig` gained four fields** (`soft_breaks`, `allow_schemes`,
132
+ `lenient_lists`, `block_html`), appended in a newly documented **append-only
133
+ zone**: uniffi serializes a record's fields *positionally*, so inserting a field
134
+ anywhere above shifts every later read. The wire was verified to be
135
+ exact-consumption — a binding built against the old record fails loudly on a
136
+ version mismatch rather than silently mis-decoding — and the RN, Kotlin, and
137
+ Swift bindings are regenerated, with the Flutter hand-written config synced
138
+ (it also gained the previously-missing `wire_delta`). Stale wire goldens across
139
+ all four languages were re-synced. `brookmd-ffi` and `brookmd-cabi` go to
140
+ **0.3.0**: the native library and its generated bindings must ship in lockstep.
141
+
142
+ ### Performance
143
+
144
+ Every item below is pinned by a **wall-time** regression guard in
145
+ `crates/brookmd-core/tests/scaling.rs`, each paired with a flush-control twin
146
+ that isolates the shape from its chunking. That pairing is the point: work
147
+ counters alone provably miss the allocation-class quadratics below — the parser
148
+ was doing O(n) *counted* work while re-allocating O(n) bytes per append, so only
149
+ the clock could see it.
150
+
151
+ | Shape | Measured as | Before | After |
152
+ | --- | --- | --- | --- |
153
+ | growing fence opener line | growth over an 8× input span | 50.5× | **9.1×** |
154
+ | indented continuation lines | vs the unindented control | 304.6× | **1.1×** |
155
+ | ragged chunks parked on a blank partial line | vs control | 110.2× | **1.1×** |
156
+ | the same, inside a list item | work counters | 212× | **12.8×** |
157
+ | block-HTML sanitize | vs the escaped control | 247× | **1.3×** |
158
+
159
+ - **Fence opener-line growth** re-sliced the whole opener on every append —
160
+ quadratic in the info string's length. Now linear-ish: 9.1× over an 8× span.
161
+ - **Indented continuation lines** made `is_boundary` produce *zero* cut
162
+ candidates, so every append re-rendered the entire paragraph from its start. A
163
+ new indent-led boundary rule (with a hard-break-straddle exclusion) restores
164
+ cutting; the shape now costs 1.1× its unindented control.
165
+ - **Ragged chunking that parks on a whitespace-only partial line** dropped the
166
+ paragraph cache outright, forcing a full rebuild per append. The cache now
167
+ **suspends instead of dropping**: the closed view stays byte-identical and the
168
+ parse resumes when the line completes. List items inherit the fix through the
169
+ nested parser.
170
+ - **Block-level raw HTML** would otherwise re-sanitize its whole body per append
171
+ (measured 247×); the sanitize cache folds at token boundaries, shipping at 1.3×
172
+ the cost of the escaped control. Container, table-cell, and heading caches were
173
+ proven structurally immune to the same class and are pinned so they stay that
174
+ way.
175
+
176
+ ### Security
177
+
178
+ - **The raw-HTML attribute policy is now an explicit dropped-attribute table**
179
+ rather than scheme-checking alone: `srcdoc`, `is`, `autofocus`,
180
+ `contenteditable`, the DOM-clobbering `id` / `name`, the shadow-piercing
181
+ `slot` / `part` / `exportparts`, the `form*` family, the `xmlns:` / `xlink:`
182
+ prefixes, and `ping`. `formaction` and `ping` are **dropped outright** — for a
183
+ URL carrier that re-targets a form or fires a background beacon, dropping is
184
+ strictly stronger than validating its scheme. Component-tag props stay
185
+ permissive by design (they are consumer-mediated — your component decides what
186
+ to do with them, and they never become DOM attributes); that asymmetry is now
187
+ documented rather than incidental.
188
+ - **The web component's `gfm-tagfilter` attribute was never observed.** It was in
189
+ the element's config map but missing from `observedAttributes`, so it applied at
190
+ mount and then silently ignored every later change — a security-relevant flag
191
+ that could not be turned on after the fact. Fixed, and the list now carries a
192
+ comment tying it to the map so the next flag cannot repeat it.
193
+
194
+ ### Fixed
195
+
196
+ - **A mid-stream committed-view divergence**: a cut taken after a *single*
197
+ inter-word space could straddle the hard-break lookbehind, so a paragraph
198
+ containing entity-produced spaces rendered differently mid-stream than it did
199
+ one-shot. The streamed view now matches the one-shot render at every chunk
200
+ boundary again.
201
+
202
+ ## brookmd-react-native 0.1.7 — 2026-07-30
203
+
204
+ ### Added
205
+
206
+ - `softBreaks`, `allowSchemes`, `lenientLists`, and `blockHtml` on the native
207
+ `BrookConfig`, reaching the on-device parser through the same JSI path as the
208
+ existing flags. The generated TS/C++ bindings are regenerated from
209
+ `brookmd-ffi` 0.3.0 and are now freshness-checked on every push by the new CI
210
+ `bindings` job.
211
+
212
+ ### Changed
213
+
214
+ - `brookmd` dependency range `^0.25.0` → `^0.26.0`, and the vendored native
215
+ binaries are built against `brookmd-core` 0.25.0 — so the on-device renderer
216
+ produces the same reference-exact bytes as the browser. Native and JS must move
217
+ together here: uniffi records are positional, and the four new `BrookConfig`
218
+ fields land in the record's append-only zone.
219
+
220
+ ## 0.25.2 — 2026-07-27
221
+
222
+ Performance only. Rendered output is unchanged — byte-identical mid-stream, not
223
+ just at finalize.
224
+
225
+ ### Fixed
226
+
227
+ - **An open component block no longer re-scans its whole body on every blank
228
+ line — O(n²) → O(n).** `try_incremental_component` bailed whenever the fed
229
+ buffer ended on a blank line, and because the cache had already been taken the
230
+ bail dropped it, forcing a full tail re-scan, a full re-render, and a fresh
231
+ nested parser over the entire body. Blank lines are legal component-body
232
+ content, so that fired once per body paragraph.
233
+
234
+ The bail existed for a real reason: when the buffer ends blank the full rescan
235
+ renders the component and all its sub-blocks with `open_tail = false`, which
236
+ the nested parser's `force_open_tail = true` commits can never match. Rather
237
+ than work around it with a trigger-byte heuristic (measured: still 3.9×/doubling
238
+ on any body containing a backtick or bracket — i.e. all real ones), the cache
239
+ now carries a **settled twin**: a second nested parser with `force_open_tail`
240
+ off, fed lazily only on the appends that read it, so each catch-up spans just
241
+ the bytes since the previous blank line. A body with no blank lines never
242
+ allocates it. The cache now arms once per stream instead of once per paragraph.
243
+
244
+ Streaming a 64 KB `<Thinking>` body, scan work and wall time:
245
+
246
+ | body | before | after |
247
+ | --- | --- | --- |
248
+ | plain paragraphs | 117,333,095 B / 11,954 ms | 229,837 B / **269 ms** |
249
+ | with inline code | 101,046,887 B / 9,316 ms | 224,977 B / **226 ms** |
250
+ | with links | 86,362,310 B / 6,837 ms | 220,835 B / **180 ms** |
251
+ | with `$` and `<` | 117,333,095 B / 7,333 ms | 229,837 B / **388 ms** |
252
+
253
+ Scan and render work are now 2.00×/doubling (dead linear); the residual
254
+ 3.3–3.6×/doubling in wall time is a separate, pre-existing cliff —
255
+ `assemble_wrapped_body` re-materializes an open block's HTML every append —
256
+ which the already-registered `open-block-html-reemit` shape sits in too.
257
+
258
+ **Trade-off:** the twin holds a second copy of an open component's body bytes
259
+ plus one rendered-HTML set, freed when the block closes. Roughly 2× transient
260
+ memory for open component blocks *that contain blank lines*; blank-free bodies
261
+ are unaffected.
262
+
263
+ ### Added
264
+
265
+ - `tests/scaling.rs` gains `component_multi_para` and `component_multi_para_rich`
266
+ (backticks, brackets, `$`, `<`), both gated `Linear`. The existing
267
+ `component_block_open` shape generates no blank lines and so was structurally
268
+ blind to this: its `scanned` is a flat 249 B from 8 KB to 64 KB. The new shapes
269
+ measure 252.4× on the pre-fix parser (gate fails) and 16.0× over a 16× span
270
+ after.
271
+
7
272
  ## 0.25.1 — 2026-07-27
8
273
 
9
274
  A correction release. 0.25.0 shipped one user-visible regression and a dev-gate
@@ -49,6 +314,15 @@ memory leak on the opt-in `childMemo` path. Upgrade from 0.25.0.
49
314
  id, kind, override keys, HTML excerpt, the error) and moves only the
50
315
  explanatory prose behind the dev gate.
51
316
 
317
+ ## brookmd-react-native 0.1.6 — 2026-07-27
318
+
319
+ ### Fixed
320
+
321
+ - Vendored native binaries rebuilt against `brookmd-core` 0.24.2, which makes an
322
+ open component block's streaming cost linear in its body. This matters more
323
+ on-device than in the browser: the pre-fix path spent ~24 s of native CPU on a
324
+ 64 KB token-streamed `<Thinking>` body. No JS changes.
325
+
52
326
  ## brookmd-react-native 0.1.5 — 2026-07-27
53
327
 
54
328
  ### Fixed
package/README.md CHANGED
@@ -461,6 +461,17 @@ export default function Doc({ md }: { md: string }) {
461
461
  - **`renderToString(md, { config })`** — synchronous HTML string, **zero React
462
462
  dependency** (imports cleanly with no `react` installed).
463
463
  - **`parseToBlocks(md, { config })`** — the block array, for custom rendering.
464
+
465
+ **Document assembly.** A `Block.html` never ends with a newline — the terminator
466
+ that follows a top-level block belongs to the *document*, not the block.
467
+ `renderToString` therefore joins blocks with cmark's `cr()` rule: insert `\n`
468
+ before a block only when the output doesn't already end with one, and end the
469
+ document with one `\n`. An unconditional `"\n".join(...)` would double the
470
+ newline a raw HTML block serializes for itself. **If you assemble
471
+ `parseToBlocks` output into a document string yourself, use the same rule** —
472
+ it's what makes the output byte-identical to a reference CommonMark/GFM renderer
473
+ (652/652 CommonMark 0.31 and 24/24 GFM extension examples, byte-for-byte). See
474
+ `WIRE.md` §12.
464
475
  - **`<BrookMarkdownStatic content config components />`** (from
465
476
  `brookmd/server/react`) — synchronous React tree for **render-once** contexts;
466
477
  render it with your framework's server renderer
@@ -614,12 +625,16 @@ const client = new BrookClient({
614
625
  gfmFootnotes: true, // [^1] + [^1]: → footnote section (default false)
615
626
  gfmMath: true, // $…$ / \(…\) inline + $$…$$ / \[…\] display math (default false)
616
627
  dirAuto: true, // per-block dir="auto" for RTL/bidi text (default false)
628
+ softBreaks: true, // a single \n renders as <br> (remark-breaks / chat convention; default false)
629
+ lenientLists: true, // marker + 6+ SPACES → item text, not indented code (default false)
617
630
  a11y: true, // task-list <label> + <th scope="col"> a11y markup (default false)
618
631
  unsafeHtml: false, // pass raw HTML through (default false — keep it false for untrusted input)
619
632
  componentTags: ["Thinking", "Callout"], // BLOCK custom tags w/ markdown inside (default none)
620
633
  inlineComponentTags: ["tik", "cite"], // INLINE custom tags (chips/citations) w/ markdown inside (default none)
621
634
  htmlAllowlist: ["br", "sub", "sup"], // safe raw-HTML sanitizer: [] = allow all but dangerous; list = only those (default off)
622
635
  dropHtmlTags: [], // tags removed entirely (comments always dropped when sanitizing; default off)
636
+ blockHtml: true, // extend the sanitizer to BLOCK raw HTML (<details>…); needs a list above (default false)
637
+ allowSchemes: ["file"], // un-block a default-blocked URL scheme (default none — see "Security")
623
638
  blockData: true, // opt-in structured kind.data per block (default false — see "Structured block data")
624
639
  },
625
640
  });
@@ -640,6 +655,32 @@ When to enable each flag:
640
655
  definitions. Off by default; see the footnote streaming caveat above.
641
656
  - `dirAuto: true` — when content can be RTL / mixed-direction. Emits per-block
642
657
  `dir="auto"` so the browser detects direction independently per block.
658
+ - `lenientLists: true` — when your LLM over-indents after a list marker. Strict
659
+ CommonMark (§5.2) says a marker followed by **5 or more** columns of
660
+ whitespace starts an indented code block, so a model writing
661
+ `- const value = 1;` renders as a `<pre><code>` block instead of a list
662
+ item. This flag raises that cutoff to **6 columns of literal spaces**: at 6+
663
+ the padding is absorbed into the item's content column and the text renders
664
+ as the item's own markdown (inline formatting, links, and nested lists all
665
+ parse normally). Off by default, so strict-CommonMark output is unchanged.
666
+
667
+ Four cases stay strictly conformant **by design** — the flag is deliberately
668
+ narrow, not a general "fix my indentation" pass:
669
+
670
+ | Input | Stays | Why |
671
+ | --- | --- | --- |
672
+ | `-` + exactly 5 spaces | code block | 5 columns is the §5.2 boundary itself; relaxing it would swallow genuine one-space-past-the-minimum code |
673
+ | `` - ```js `` (fence on the marker line) | fenced code | the marker line opens a real fence — there is no over-indentation to undo |
674
+ | `-` then code indented on a **later** line | code block | the decision reads only the marker's own line; a later-line indent is unambiguous authored code |
675
+ | `-\t\tfoo` (tab padding) | code block | tab padding is a deliberate authoring choice, unlike model over-indentation which is always literal spaces |
676
+
677
+ Excluding tabs is what keeps the divergence from CommonMark down to a single
678
+ spec example (274, `1.` + 6 spaces). Everything else in the 652-example suite
679
+ renders identically with the flag on or off; the conformance suites
680
+ themselves run in strict mode and are unaffected.
681
+
682
+ The rule is a pure per-line comparison made when the marker is first scanned,
683
+ so it costs no lookahead and no re-parse while streaming.
643
684
  - `a11y: true` — opt-in accessibility markup that deviates from strict GFM
644
685
  byte-output: wraps task-list checkboxes in a `<label>` (screen-reader
645
686
  association) and adds `scope="col"` to table headers. Off by default so
@@ -664,6 +705,15 @@ When to enable each flag:
664
705
  - `htmlAllowlist` / `dropHtmlTags` — render a **safe subset of raw HTML** (e.g.
665
706
  `<br>`, `<sub>`, `<sup>`) natively without `unsafeHtml`, drop specific tags, and
666
707
  drop HTML comments. See [Safe raw HTML](#safe-raw-html).
708
+ - `blockHtml: true` — extend that sanitizer to **block-level** raw HTML, so a
709
+ `<details><summary>…</summary>…</details>` block renders as real elements
710
+ instead of an escaped code block. Needs one of the two lists above to be set;
711
+ `<script>`/`<pre>`/`<style>`/`<textarea>` blocks stay escaped. See
712
+ [Block-level raw HTML](#block-level-raw-html-blockhtml).
713
+ - `allowSchemes: ["file"]` — un-block a URL scheme brookmd blocks by default,
714
+ for privileged hosts (Electron, extensions) that intercept link clicks instead
715
+ of navigating. Script-executing schemes can never be re-enabled. See
716
+ [Un-blocking a scheme](#un-blocking-a-scheme--allowschemes).
667
717
 
668
718
  **Footnotes** (`gfmFootnotes`) work in streaming with one honest caveat: a
669
719
  `[^1]` reference renders speculatively the moment it's seen (committed blocks
@@ -763,8 +813,9 @@ attributes (with `class`→`className` and `style` as an object) plus `children`
763
813
  **Block-kind** keys (`CodeBlock`, `Mermaid`, `MathBlock`, `Alert`, `Paragraph`,
764
814
  `Heading`, `List`, `Blockquote`, `Table`, `Rule`, `Html`) replace the entire
765
815
  block. The component receives [`BlockComponentProps`](#types): `{ block, html,
766
- open, speculative }`, plus `text`/`language` for code/math blocks (the alert
767
- type is at `block.kind.data.kind`).
816
+ open, speculative }`, plus `text`/`language` for code/math blocks and `meta`,
817
+ the rest of a fence's info string (```` ```ts title="src/main.ts" ````), for a
818
+ filename header (the alert type is at `block.kind.data.kind`).
768
819
 
769
820
  > **One map, two prop contracts — the single biggest footgun.** The keys above
770
821
  > are looked up by TWO dispatchers. The block-kind dispatcher passes
@@ -866,7 +917,7 @@ byte-identical, so non-users pay nothing.
866
917
  |------|-------------------|------|-----|
867
918
  | `Table` | `{ headers, rows, aligns }`, cells `{ text, html }` | `props.table` | sort / filter / transpose / CSV / chart |
868
919
  | `Heading` | `{ level, text, id }` | `props.heading` | table of contents with anchors |
869
- | `CodeBlock` | `{ lang, code }` | `props.code` | decoded source (copy / run) |
920
+ | `CodeBlock` | `{ lang, meta?, code }` | `props.code` | decoded source (copy / run) |
870
921
  | `MathBlock` | `{ latex }` | `props.math` | LaTeX source (re-render) |
871
922
  | `List` | `{ ordered, start }` | `props.list` | ordered-list numbering |
872
923
 
@@ -1004,11 +1055,77 @@ new BrookClient({ config: { htmlAllowlist: [] } });
1004
1055
  text stays as inert text).
1005
1056
  - Every rendered tag's **attributes are sanitized**: `on*` handlers and `style`
1006
1057
  (a CSS beacon / clickjacking vector) are dropped, and dangerous URL schemes
1007
- (`javascript:`, …, including multi-encoded) become `#`.
1008
- - **Scope:** *inline* raw HTML. Block-level raw HTML stays escaped for now (use
1009
- `unsafeHtml` **without** the sanitizer to render block HTML when the sanitizer
1010
- is engaged, block HTML stays escaped even if `unsafeHtml` is also on). Tag
1011
- matching is case-insensitive.
1058
+ (`javascript:`, …, including multi-encoded) become `#` — in `href`, `src`,
1059
+ `srcset`, `poster`, `cite`, `action`, `data`, `longdesc` and `background`.
1060
+ - A further set of **DOM-hazard attributes is dropped outright** (case-insensitive)
1061
+ they neither execute nor carry a URL, but each lets authored markup reach
1062
+ past the text it should be:
1063
+ `srcdoc` (inline document injection), `is` (customized-built-in upgrade),
1064
+ `autofocus` / `contenteditable` (focus-steal, UI spoof), `id` / `name` (DOM
1065
+ clobbering — an element shadowing `document.getElementById` or a global),
1066
+ `slot` / `part` / `exportparts` (shadow-DOM injection), `form`, `formaction`,
1067
+ `formenctype`, `formmethod`, `formnovalidate`, `formtarget` (form hijack),
1068
+ `xmlns` / `xlink:*` (namespace escape hatch), and `ping` (tracking beacon).
1069
+ Some are inert *today* only because the tag that gives them meaning is already
1070
+ in the dangerous set — they are dropped anyway so the policy never depends on
1071
+ that coincidence. `class`, `title`, `alt`, `target`, `rel`, `data-*` and
1072
+ `aria-*` are unaffected.
1073
+ - **This applies to raw HTML only.** [Component tags](#component-tags) stay
1074
+ permissive: their attributes become framework *props* on `components[tag]`, so
1075
+ `<Tab id="x">` keeps `id` — the consumer's component decides whether it ever
1076
+ reaches the DOM. `on*`, `style` and dangerous URL schemes are filtered there too.
1077
+ - **Scope:** *inline* raw HTML by default. Block-level raw HTML stays escaped
1078
+ unless you also set `blockHtml` (below). Tag matching is case-insensitive.
1079
+
1080
+ #### Block-level raw HTML (`blockHtml`)
1081
+
1082
+ A model that emits a disclosure widget on its own lines —
1083
+
1084
+ ```html
1085
+ <details>
1086
+ <summary>Sources</summary>
1087
+ Three filings and a transcript.
1088
+ </details>
1089
+ ```
1090
+
1091
+ — produces an *HTML block*, not inline HTML, so the sanitizer above leaves it
1092
+ escaped. Opt in with `blockHtml` and it renders as real elements:
1093
+
1094
+ ```ts
1095
+ new BrookClient({ config: { htmlAllowlist: [], blockHtml: true } });
1096
+ // or restrict it:
1097
+ new BrookClient({ config: { htmlAllowlist: ["details", "summary"], blockHtml: true } });
1098
+ ```
1099
+
1100
+ - **Only meaningful with the sanitizer engaged.** `blockHtml` on its own does
1101
+ nothing; it extends `htmlAllowlist` / `dropHtmlTags` to block level. Default
1102
+ `false`, so existing sanitizer users keep escaped block HTML until they opt in.
1103
+ - **Same policy, no exceptions.** Tags go through the same allow / drop /
1104
+ non-overridable-dangerous decision and the same hardened attribute policy as
1105
+ inline raw HTML — a block-level `<div onclick=… id=… srcdoc=…>` renders as a
1106
+ bare `<div>`.
1107
+ - **Scope: CommonMark HTML block types 6 and 7** — a known block-level tag
1108
+ (`<details>`, `<div>`, `<table>`, `<section>`, …) or any other complete tag
1109
+ alone on its line. **Types 1–5 stay escaped/dropped**, as with the flag off:
1110
+ type 1 is the raw-text family (`<script>`, `<pre>`, `<style>`, `<textarea>`),
1111
+ where a browser reads everything after the opening tag as unparsed text — so a
1112
+ *speculative* close mid-stream is an mXSS vector — and types 2–5 (comments,
1113
+ processing instructions, CDATA, declarations) carry no renderable element at
1114
+ all. A block `<script>` is escaped in every configuration, including with
1115
+ `script` explicitly allowlisted and `unsafeHtml` also on.
1116
+ - **Streaming: speculative closers.** While the block is still arriving, every
1117
+ still-open element gets a closer appended, so the HTML you have received *so
1118
+ far* is always a complete tree — `<div>\n<b>bol` renders as
1119
+ `<div><b>bol</b></div>`, and the closers simply stop being speculative when the
1120
+ author's own `</b></div>` lands (the emitted bytes don't change). A half-arrived
1121
+ tag (`<spa`, `<a href="htt`) renders as **nothing** until it completes, the same
1122
+ pending-invisible contract as a streaming markdown link's URL; if the stream
1123
+ ends on one, it settles as escaped text. Mis-nesting is repaired rather than
1124
+ propagated: `<b><i></b>` emits `<b><i></i></b>`, and a close tag matching
1125
+ nothing open is dropped. A type-6/7 block ends at a blank line even with tags
1126
+ open — the closers land there.
1127
+ - **Markdown inside the HTML is not parsed** (the body is text + tags). That —
1128
+ full `rehype-raw` semantics — is a later stage.
1012
1129
 
1013
1130
  ### Types
1014
1131
 
@@ -1033,7 +1150,8 @@ interface BlockComponentProps {
1033
1150
  open: boolean;
1034
1151
  speculative: boolean;
1035
1152
  text?: string; // decoded source — CodeBlock / MathBlock
1036
- language?: string; // info string — CodeBlock
1153
+ language?: string; // info string, first word — CodeBlock
1154
+ meta?: string; // info string, the rest (`title="src/main.ts"`) — CodeBlock
1037
1155
  }
1038
1156
  ```
1039
1157
 
@@ -1051,14 +1169,35 @@ const html = highlight("const x = 1;", "ts");
1051
1169
 
1052
1170
  ## Coverage
1053
1171
 
1054
- **CommonMark 0.31: 100% (652/652 spec examples)** — every section, including
1055
- the hard ones (nested/loose lists, link reference definitions, link precedence,
1056
- lazy blockquote continuation). Plus GFM extensions: tables, strikethrough, task
1057
- lists, extended autolinks, GitHub alerts (`> [!NOTE]` → styled callouts),
1058
- footnotes (`[^1]` + `[^1]:`), and math (`$…$`, `$$…$$`, `\(…\)`, `\[…\]`).
1059
- Autolinks and alerts are on by default; footnotes and math are opt-in per stream
1060
- (see [Per-stream config](#per-stream-config)). See
1061
- `crates/brookmd-core/tests/{cmark_spec,gfm_spec,footnotes,math}.rs` for runners and floors.
1172
+ **CommonMark 0.31: 100% (652/652 spec examples), byte-exact** — every section,
1173
+ including the hard ones (nested/loose lists, link reference definitions, link
1174
+ precedence, lazy blockquote continuation). Plus GFM extensions, also **24/24
1175
+ byte-exact**: tables, strikethrough, task lists, extended autolinks, GitHub
1176
+ alerts (`> [!NOTE]` → styled callouts), footnotes (`[^1]` + `[^1]:`), and math
1177
+ (`$…$`, `$$…$$`, `\(…\)`, `\[…\]`). Autolinks and alerts are on by default;
1178
+ footnotes and math are opt-in per stream (see
1179
+ [Per-stream config](#per-stream-config)).
1180
+
1181
+ *Byte-exact* means the full output string matches the reference renderer's
1182
+ byte-for-byte — not a structurally normalized or whitespace-forgiving compare.
1183
+ Both counts are the harnesses' **default** floors (`CMARK_MIN_EXACT=652`,
1184
+ `GFM_MIN_EXACT=24`, pinned explicitly in the CI workflows), so a single byte of
1185
+ regression fails the build even when the normalized tally stays green.
1186
+
1187
+ The only deviations from the reference output are deliberate brookmd choices,
1188
+ folded by a documented `canonicalize` step applied to **both** sides before
1189
+ comparison — the only transform on the byte-exact path, so it can erase our
1190
+ intentional extras but never hide a structural divergence:
1191
+
1192
+ | Deliberate difference | Reference emits |
1193
+ |---|---|
1194
+ | `target="_blank" rel="noopener noreferrer nofollow"` on links (security-only) | no such attrs |
1195
+ | `data-lang="…"` on code blocks (alongside `class="language-…"`) | `class` only |
1196
+ | HTML5 void elements (`<br>`) | XHTML self-closing (`<br />`) |
1197
+ | `style="text-align:…"` on table cells | GFM's deprecated `align="…"` |
1198
+
1199
+ See `crates/brookmd-core/tests/{cmark_spec,gfm_spec,footnotes,math}.rs` for the
1200
+ runners, the `canonicalize` source, and the floors.
1062
1201
 
1063
1202
  GitHub alerts render to GitHub-compatible markup
1064
1203
  (`<div class="markdown-alert markdown-alert-note">…`), so existing markdown CSS
@@ -1125,17 +1264,61 @@ brookmd is XSS-safe by default — its HTML output is meant to be injected via
1125
1264
  - **Raw HTML is escaped** (the `unsafeHtml: true` config flag disables this;
1126
1265
  **never enable it for untrusted input without a `sanitize` hook**).
1127
1266
  - **Dangerous URL schemes are neutralized** in `<a href>` and `<img src>` —
1128
- `javascript:`, `vbscript:`, `data:text/html`, `data:text/javascript` become
1129
- `#`. The check runs on the *decoded* URL and strips characters browsers
1130
- ignore in the scheme, so obfuscations like `javascript&#58;…`,
1131
- `javascript\:…`, `&#106;avascript:…`, and embedded tabs/newlines are caught,
1132
- not just the literal form. (See `crates/brookmd-core/tests/security.rs`.)
1267
+ `javascript:`, `vbscript:`, `data:text/html`, `data:text/javascript` (and
1268
+ `file:`, see below) become `#`. The check runs on the *decoded* URL and strips
1269
+ characters browsers ignore in the scheme, so obfuscations like
1270
+ `javascript&#58;…`, `javascript\:…`, `&#106;avascript:…`, and embedded
1271
+ tabs/newlines are caught, not just the literal form. (See
1272
+ `crates/brookmd-core/tests/security.rs`.)
1273
+ - **The opt-in raw-HTML sanitizer removes DOM-hazard attributes.** Beyond `on*`,
1274
+ `style` and URL schemes, [Safe raw HTML](#safe-raw-html) drops `srcdoc`, `is`,
1275
+ `autofocus`, `contenteditable`, `id`/`name` (DOM clobbering), `slot`/`part`/
1276
+ `exportparts`, the `form*` family and `ping` from raw tags — a denylist by
1277
+ *policy*, not one that happens to be safe because some other tag is blocked.
1278
+ brookmd's own generated ids (footnote `fn-N`/`fnref-N`, heading slugs) are
1279
+ emitted by the renderer and never pass through the sanitizer, so they are
1280
+ unaffected. Component-tag props are exempt (see that section).
1133
1281
  - **`htmlToReact` defends in depth**: it drops inline `on*` event-handler
1134
1282
  attributes and runs URL attributes through the same scheme filter. It's
1135
1283
  intended for brookmd's own (already-sanitized) HTML; if you hand it arbitrary
1136
1284
  third-party HTML, these guards are your only line of defense — prefer a
1137
1285
  dedicated HTML sanitizer for genuinely hostile input.
1138
1286
 
1287
+ ### Un-blocking a scheme — `allowSchemes`
1288
+
1289
+ `file:` is blocked by default. It can't execute script, but it has no legitimate
1290
+ use in untrusted/LLM markdown, and in a **privileged** host — Electron, a browser
1291
+ extension, a page on a `file://` origin — a live `file:` href is a
1292
+ local-resource-disclosure / phishing vector. In an ordinary browser tab blocking
1293
+ it costs nothing anyway: a page simply **refuses to navigate** to a `file:` URL,
1294
+ so the href is inert there either way.
1295
+
1296
+ Some hosts genuinely need it. A coding-agent UI whose model links to local paths
1297
+ wants the href present so it can **intercept the click** and open the file in an
1298
+ editor or preview pane. `allowSchemes` is the opt-in for exactly that:
1299
+
1300
+ ```ts
1301
+ const client = new BrookClient({ config: { allowSchemes: ["file"] } });
1302
+ ```
1303
+
1304
+ - Takes **bare scheme names, no colon** (`["file"]`), matched
1305
+ case-insensitively.
1306
+ - It **never restricts** anything — this is not a general allowlist. Schemes
1307
+ outside the built-in blocklist (`vscode:`, `ftp:`, `mailto:`, …) already render
1308
+ today and are unaffected by this setting.
1309
+ - The **script-executing tier is non-overridable**: `javascript:`, `vbscript:`,
1310
+ `data:text/html`, `data:text/javascript`, and the scriptable `data:` media
1311
+ types (`data:image/svg`, `data:application/xhtml`, `data:text/xml`, …) stay
1312
+ blocked no matter what you list. Naming one is a silent no-op — the same rule
1313
+ as `htmlAllowlist`, where allowlisting `<script>` still cannot re-enable it.
1314
+ - Applies uniformly to links, URI autolinks, images, and sanitized URL
1315
+ attributes.
1316
+
1317
+ Turning it on moves the risk to you: **intercept link clicks rather than letting
1318
+ navigation happen**, and treat the path as untrusted input at the point you act
1319
+ on it. In a privileged host, whether a model-authored `file:///…` is allowed to
1320
+ reach a real file is the embedder's decision, not brookmd's.
1321
+
1139
1322
  ### Rendering untrusted / LLM HTML safely
1140
1323
 
1141
1324
  If you enable `unsafeHtml` to render HTML from an untrusted source (e.g. an LLM
@@ -38,8 +38,9 @@ function blockProps(block) {
38
38
  if (block.kind.type === "CodeBlock") {
39
39
  props.text = data?.code ?? decodeCodeText(block.html);
40
40
  props.language = data?.lang ?? "";
41
+ if (typeof data?.meta === "string") props.meta = data.meta;
41
42
  if (typeof data?.code === "string") {
42
- props.code = { lang: data.lang ?? null, code: data.code };
43
+ props.code = { lang: data.lang ?? null, meta: data.meta, code: data.code };
43
44
  }
44
45
  } else if (block.kind.type === "MathBlock") {
45
46
  props.text = data?.latex ?? decodeMathText(block.html);
package/dist/element.js CHANGED
@@ -9,18 +9,25 @@ function parseTriBool(value) {
9
9
  const CONFIG_ATTRS = [
10
10
  "gfm-autolinks",
11
11
  "gfm-alerts",
12
+ // Must mirror the `set(...)` map below: this list feeds `observedAttributes`,
13
+ // so an attribute missing here is read once at mount and then never reacts to
14
+ // a change. `gfm-tagfilter` was in the map but not in this list.
15
+ "gfm-tagfilter",
12
16
  "gfm-footnotes",
13
17
  "gfm-math",
14
18
  "dir-auto",
19
+ "lenient-lists",
20
+ "soft-breaks",
15
21
  "a11y",
16
- "unsafe-html"
22
+ "unsafe-html",
23
+ "block-html"
17
24
  ];
18
25
  function defineBrookMarkdown(tag = "brook-markdown") {
19
26
  if (typeof customElements === "undefined") return;
20
27
  if (customElements.get(tag)) return;
21
28
  class BrookMarkdownElement extends HTMLElement {
22
29
  static get observedAttributes() {
23
- return ["markdown", "src", "component-tags", ...CONFIG_ATTRS];
30
+ return ["markdown", "src", "component-tags", "allow-schemes", ...CONFIG_ATTRS];
24
31
  }
25
32
  #client = null;
26
33
  #ownsClient = false;
@@ -150,8 +157,11 @@ function defineBrookMarkdown(tag = "brook-markdown") {
150
157
  set("gfm-footnotes", "gfmFootnotes");
151
158
  set("gfm-math", "gfmMath");
152
159
  set("dir-auto", "dirAuto");
160
+ set("lenient-lists", "lenientLists");
161
+ set("soft-breaks", "softBreaks");
153
162
  set("a11y", "a11y");
154
163
  set("unsafe-html", "unsafeHtml");
164
+ set("block-html", "blockHtml");
155
165
  const tags = this.getAttribute("component-tags");
156
166
  if (tags !== null) {
157
167
  const list = tags.split(/[\s,]+/).filter(Boolean);
@@ -160,6 +170,14 @@ function defineBrookMarkdown(tag = "brook-markdown") {
160
170
  any = true;
161
171
  }
162
172
  }
173
+ const schemes = this.getAttribute("allow-schemes");
174
+ if (schemes !== null) {
175
+ const list = schemes.split(/[\s,]+/).filter(Boolean);
176
+ if (list.length > 0) {
177
+ cfg.allowSchemes = list;
178
+ any = true;
179
+ }
180
+ }
163
181
  return any ? cfg : void 0;
164
182
  }
165
183
  // Lazily create the internal client from config attributes (self-owned).
package/dist/react.js CHANGED
@@ -218,8 +218,9 @@ function blockKindProps(block, components) {
218
218
  if (block.kind.type === "CodeBlock") {
219
219
  props.text = data?.code ?? decodeCodeText(block.html);
220
220
  props.language = data?.lang ?? "";
221
+ if (typeof data?.meta === "string") props.meta = data.meta;
221
222
  if (typeof data?.code === "string") {
222
- props.code = { lang: data.lang ?? null, code: data.code };
223
+ props.code = { lang: data.lang ?? null, meta: data.meta, code: data.code };
223
224
  }
224
225
  } else if (block.kind.type === "MathBlock") {
225
226
  props.text = data?.latex ?? decodeMathText(block.html);
package/dist/server.d.ts CHANGED
@@ -21,10 +21,21 @@ export declare function parseToBlocks(markdown: string, opts?: {
21
21
  }): Block[];
22
22
  /**
23
23
  * Render a complete markdown string to an HTML string synchronously — no worker,
24
- * no React. The concatenated per-block HTML (XSS-safe with `unsafeHtml` off).
25
- * For component dispatch / a `<BrookMarkdown>`-matching React tree, use
26
- * `BrookMarkdownStatic` from `brookmd/server/react` with your framework's server
27
- * renderer instead.
24
+ * no React. The per-block HTML joined into one document (XSS-safe with
25
+ * `unsafeHtml` off). For component dispatch / a `<BrookMarkdown>`-matching React
26
+ * tree, use `BrookMarkdownStatic` from `brookmd/server/react` with your
27
+ * framework's server renderer instead.
28
+ *
29
+ * **Document assembly** (WIRE.md §12): a block's `html` never ends with a
30
+ * newline — that terminator belongs to the document, not the block — so joining
31
+ * blocks inserts one `\n` between them, `cr()`-style: only when the previous
32
+ * block does not ALREADY end with one. A raw HTML block serializes its own
33
+ * trailing newline, so an unconditional join would double it. The document ends
34
+ * with a final newline. This is exactly what a reference CommonMark/GFM renderer
35
+ * emits, and what the spec harnesses measure byte-for-byte against.
36
+ *
37
+ * If you assemble `Block.html` yourself (rather than calling this), use the same
38
+ * rule.
28
39
  */
29
40
  export declare function renderToString(markdown: string, opts?: {
30
41
  config?: ParserConfig;
package/dist/server.js CHANGED
@@ -40,6 +40,8 @@ function makeParser(config) {
40
40
  p.setGfmFootnotes(config?.gfmFootnotes ?? false);
41
41
  p.setGfmMath(config?.gfmMath ?? false);
42
42
  p.setDirAuto(config?.dirAuto ?? false);
43
+ p.setLenientLists(config?.lenientLists ?? false);
44
+ p.setSoftBreaks(config?.softBreaks ?? false);
43
45
  p.setA11y(config?.a11y ?? false);
44
46
  p.setUnsafeHtml(config?.unsafeHtml ?? false);
45
47
  p.setComponentTags(config?.componentTags ?? []);
@@ -49,6 +51,8 @@ function makeParser(config) {
49
51
  config?.htmlAllowlist ?? [],
50
52
  config?.dropHtmlTags ?? []
51
53
  );
54
+ p.setBlockHtml(config?.blockHtml ?? false);
55
+ p.setAllowSchemes(config?.allowSchemes ?? []);
52
56
  p.setBlockData(config?.blockData ?? false);
53
57
  return p;
54
58
  }
@@ -71,7 +75,13 @@ function parseToBlocks(markdown, opts) {
71
75
  }
72
76
  }
73
77
  function renderToString(markdown, opts) {
74
- return parseToBlocks(markdown, opts).map((b) => b.html).join("");
78
+ let out = "";
79
+ for (const b of parseToBlocks(markdown, opts)) {
80
+ if (out.length > 0 && !out.endsWith("\n")) out += "\n";
81
+ out += b.html;
82
+ }
83
+ if (out.length > 0 && !out.endsWith("\n")) out += "\n";
84
+ return out;
75
85
  }
76
86
  export {
77
87
  initBrook,
@@ -89,15 +89,24 @@ export interface HeadingData {
89
89
  id: string;
90
90
  }
91
91
  /**
92
- * A CodeBlock's `kind.data` when {@link ParserConfig.blockData} is on. `lang` is
93
- * the always-on info-string language (`null` for none); `code` is the opt-in
94
- * DECODED source inside `<pre><code>…</code></pre>` (only present when `blockData`
95
- * is on). Build a copy-to-clipboard string / re-highlight from `code` alone — no
96
- * HTML re-parse, no entity-decode. When `blockData` is off, `code` is absent and
97
- * `kind.data` is just `{ lang }`, byte-identical to before.
92
+ * A CodeBlock's `kind.data`. `lang` is the always-on info-string language (`null`
93
+ * for none) — the info string's first word; `meta` is the always-on REMAINDER of
94
+ * that same info string, trimmed (```` ```ts title="src/main.ts" ```` ⇒
95
+ * `lang: "ts"`, `meta: 'title="src/main.ts"'`), absent when the fence carried
96
+ * none. `code` is the opt-in DECODED source inside `<pre><code>…</code></pre>`
97
+ * (only present when `blockData` is on). Build a copy-to-clipboard string /
98
+ * re-highlight from `code` alone — no HTML re-parse, no entity-decode. When
99
+ * `blockData` is off, `code` is absent and `kind.data` is just `{ lang }` (plus
100
+ * `meta` if the fence had one), byte-identical to before.
101
+ *
102
+ * Both halves are the RAW info-string text (backslash escapes / entity references
103
+ * left undecoded), and only `lang` appears in the rendered HTML
104
+ * (`class="language-…" data-lang="…"`) — there is deliberately no `data-meta`
105
+ * attribute, so a filename header needs a `components.CodeBlock` override.
98
106
  */
99
107
  export interface CodeBlockData {
100
108
  lang: string | null;
109
+ meta?: string;
101
110
  code?: string;
102
111
  }
103
112
  /**
@@ -117,6 +126,24 @@ export interface MathBlockData {
117
126
  */
118
127
  export interface ListItemData {
119
128
  html: string;
129
+ /**
130
+ * The item's DOCUMENT-ABSOLUTE source byte offset — the index, in the markdown
131
+ * fed so far, of the byte where this item's marker (`-`, `*`, `1.`, …) begins.
132
+ * Same origin as {@link Block.start}, and stable as the document grows (the
133
+ * parser's buffer is append-only), so `source.slice(item.start)` always begins
134
+ * at this item's marker. Use it to read or rewrite the item in place — e.g.
135
+ * find the task-list checkbox with your own `findTaskListMarkerOffset(source,
136
+ * item.start)` and flip `[ ]` ⇄ `[x]` in the original string.
137
+ *
138
+ * Present only when {@link ParserConfig.blockData} is on.
139
+ *
140
+ * KNOWN LIMITATION — absent for NESTED list items. A nested list is not a
141
+ * separate block: its items live inside the parent item's `html` and never
142
+ * reach the `items` channel, and the nested render runs against a synthesized
143
+ * de-indented string with no document offset. Nested items therefore carry no
144
+ * offset rather than a wrong one. Only top-level list items get a `start`.
145
+ */
146
+ start?: number;
120
147
  }
121
148
  /**
122
149
  * A List's `kind.data` when {@link ParserConfig.blockData} is on. `ordered` is the
@@ -243,6 +270,18 @@ export interface BlockComponentProps {
243
270
  text?: string;
244
271
  /** Info-string language — present for `CodeBlock` (from `kind.data.lang`). */
245
272
  language?: string;
273
+ /**
274
+ * Info-string META — everything after the language word, trimmed (from
275
+ * `kind.data.meta`), e.g. `title="src/main.ts"` or a bare `src/main.ts`.
276
+ * Always-on like `language` (no `blockData` needed); `undefined` when the
277
+ * fence carried none. Deliberately absent from the rendered HTML, so render a
278
+ * filename header from this prop.
279
+ *
280
+ * While streaming it appears once it can no longer change — when the opening
281
+ * fence line is terminated by a newline, or at finalize — so a header never
282
+ * flickers through a half-typed `title="src/ma`.
283
+ */
284
+ meta?: string;
246
285
  /** Component tag name — present for `Component` blocks (from `kind.data.tag`). */
247
286
  tag?: string;
248
287
  /**
@@ -274,11 +313,12 @@ export interface BlockComponentProps {
274
313
  heading?: HeadingData;
275
314
  /**
276
315
  * Structured code data — present for `CodeBlock` blocks when
277
- * {@link ParserConfig.blockData} is on (otherwise `undefined`). `{ lang, code }`
278
- * with `code` the DECODED source. Build a copy-to-clipboard string / re-highlight
279
- * from `code` — no HTML re-parse, no entity-decode. (`props.text` / `props.language`
280
- * carry the same source / lang and stay populated even when off, via the HTML
281
- * regex fallback.)
316
+ * {@link ParserConfig.blockData} is on (otherwise `undefined`). `{ lang, meta?,
317
+ * code }` with `code` the DECODED source. Build a copy-to-clipboard string /
318
+ * re-highlight from `code` — no HTML re-parse, no entity-decode. (`props.text` /
319
+ * `props.language` carry the same source / lang and stay populated even when off,
320
+ * via the HTML regex fallback; `props.meta` carries the same meta and is
321
+ * always-on, since it has no HTML form to fall back to.)
282
322
  */
283
323
  code?: CodeBlockData;
284
324
  /**
@@ -343,6 +383,28 @@ export interface ParserConfig {
343
383
  * apps that render RTL or mixed-direction content.
344
384
  */
345
385
  dirAuto?: boolean;
386
+ /**
387
+ * Lenient list indentation: a list marker followed by 6 or more columns of
388
+ * SPACE padding yields the item's text, where strict CommonMark (§5.2) keeps
389
+ * one column and renders the rest as an indented code block. Default false.
390
+ *
391
+ * Aimed at model output, which routinely over-indents after a bullet
392
+ * (`- const value = 1;`). Four cases stay strictly conformant: exactly
393
+ * 5 columns of padding, a fenced code block opened on the marker line itself,
394
+ * indented code that starts on a line AFTER the marker, and tab-padded
395
+ * markers (`-\t\tfoo`).
396
+ */
397
+ lenientLists?: boolean;
398
+ /**
399
+ * Render a CommonMark SOFT line break (a bare `\n` inside inline content) as
400
+ * a `<br>` — the `remark-breaks` / "GitHub comment" convention, where one
401
+ * Enter is one visual line. Default false (strict CommonMark: a soft break is
402
+ * whitespace). Hard breaks (two trailing spaces, or a trailing `\`) are `<br>`
403
+ * either way, so enabling this only ADDS breaks — it never removes one. Chat
404
+ * UIs streaming model output usually want this on, since models emit single
405
+ * newlines expecting a visible break.
406
+ */
407
+ softBreaks?: boolean;
346
408
  /**
347
409
  * Opt-in accessibility markup that deviates from strict GFM byte-output:
348
410
  * wraps a task-list checkbox + its text in a `<label>` (programmatic
@@ -385,8 +447,8 @@ export interface ParserConfig {
385
447
  * `input`, `svg`, …); a **non-empty** array renders only those tags (e.g.
386
448
  * `["br","sub","sup"]`) and escapes the rest. Every rendered tag's attributes
387
449
  * are sanitized (event handlers dropped, dangerous URL schemes → `#`), and HTML
388
- * comments are dropped. Block-level raw HTML stays escaped (sanitize is
389
- * inline-scoped for now). Unset/omitted = off (raw HTML handling unchanged).
450
+ * comments are dropped. Block-level raw HTML stays escaped unless you also set
451
+ * {@link blockHtml}. Unset/omitted = off (raw HTML handling unchanged).
390
452
  * Matching is case-insensitive. See also {@link dropHtmlTags}.
391
453
  */
392
454
  htmlAllowlist?: string[];
@@ -397,6 +459,52 @@ export interface ParserConfig {
397
459
  * raw-HTML sanitizer (see {@link htmlAllowlist}). Case-insensitive.
398
460
  */
399
461
  dropHtmlTags?: string[];
462
+ /**
463
+ * Extend the safe raw-HTML sanitizer to **block-level** raw HTML, so a model
464
+ * emitting `<details><summary>…</summary>…</details>` on its own lines renders
465
+ * as real elements instead of an escaped code block. Only takes effect when
466
+ * the sanitizer is engaged ({@link htmlAllowlist} / {@link dropHtmlTags}); on
467
+ * its own it does nothing. Default false — existing sanitizer users keep
468
+ * escaped block HTML until they opt in.
469
+ *
470
+ * Scope is CommonMark HTML block **types 6 and 7**: a known block-level tag
471
+ * (`<details>`, `<div>`, `<table>`, …) or any other complete tag alone on its
472
+ * line. Types 1–5 stay escaped/dropped: type 1 is the raw-text family
473
+ * (`<script>`, `<pre>`, `<style>`, `<textarea>`) — a browser reads everything
474
+ * after such a tag as unparsed text, so a speculative mid-stream close is
475
+ * mXSS-prone — and types 2–5 (comments, PIs, CDATA, declarations) carry no
476
+ * renderable element. The tag allow/drop/dangerous decision and the hardened
477
+ * attribute policy are exactly the inline sanitizer's.
478
+ *
479
+ * While the block streams, still-open elements get **speculative closers**, so
480
+ * what the reader has seen so far is a complete tree at every append; a
481
+ * half-arrived tag stays invisible until it completes. Markdown *inside* the
482
+ * HTML is not parsed (the body is text + tags).
483
+ */
484
+ blockHtml?: boolean;
485
+ /**
486
+ * Opt-in **un-blocklist** for URL schemes that brookmd blocks by default.
487
+ * Bare scheme names, **without** the colon (`["file"]`), matched
488
+ * case-insensitively. Empty/omitted = the built-in policy is unchanged.
489
+ *
490
+ * This never *restricts* anything — it is not a general allowlist. Schemes
491
+ * outside the built-in blocklist (`vscode:`, `ftp:`, `mailto:`, …) already
492
+ * render today and are unaffected. The only tier it can reach is the
493
+ * overridable-blocked one, currently just `file:`.
494
+ *
495
+ * The script-executing tier — `javascript:`, `vbscript:`, `data:text/html`,
496
+ * `data:text/javascript`, and the scriptable `data:` media types
497
+ * (`data:image/svg`, `data:application/xhtml`, …) — is **non-overridable**:
498
+ * listing one here is a silent no-op, exactly as allowlisting `<script>`
499
+ * cannot re-enable it via {@link htmlAllowlist}.
500
+ *
501
+ * Only enable `file:` in a host that intercepts link clicks instead of
502
+ * navigating (an Electron / extension UI that opens the path in an editor);
503
+ * local-resource disclosure then becomes the embedder's responsibility.
504
+ * Applies uniformly to links, URI autolinks, images, and sanitized URL
505
+ * attributes.
506
+ */
507
+ allowSchemes?: string[];
400
508
  /**
401
509
  * Opt-in structured table data. When on, a `Table` block's `kind.data` is
402
510
  * populated with `{ headers, rows, aligns }` (each cell `{ text, html }`) so a
@@ -52,13 +52,13 @@ entirely:
52
52
 
53
53
  ```toml
54
54
  [dependencies]
55
- brookmd-core = { version = "0.23", default-features = false }
55
+ brookmd-core = { version = "0.25", default-features = false }
56
56
  ```
57
57
 
58
58
  ## Wire format
59
59
 
60
60
  Blocks and patches serialize to a stable, language-agnostic JSON wire format —
61
- see [WIRE.md](WIRE.md) (wire contract v1.2.0). Native consumers can produce the
61
+ see [WIRE.md](WIRE.md) (wire contract v1.3.0). Native consumers can produce the
62
62
  same bytes as the WASM/JS boundary via `wire::patch_to_json` / `wire::blocks_to_json`.
63
63
 
64
64
  Contract v1.2.0 adds the opt-in **wire delta mode**
@@ -38,6 +38,18 @@ export class BrookParser {
38
38
  * to table header cells. Off by default (conformance output unchanged).
39
39
  */
40
40
  setA11y(on: boolean): void;
41
+ /**
42
+ * Un-block specific URL schemes that are blocked by DEFAULT — bare scheme
43
+ * names without the colon (`["file"]`), matched case-insensitively. Empty
44
+ * by default (built-in policy unchanged). This never RESTRICTS anything:
45
+ * schemes outside the built-in blocklist (`vscode:`, `ftp:`, …) already
46
+ * pass. The script-executing tier (`javascript:`, `vbscript:`,
47
+ * `data:text/html`, `data:text/javascript`, scriptable `data:` media types)
48
+ * is non-overridable — listing one here is a no-op. Only enable `file:` in
49
+ * a host that intercepts link clicks instead of navigating (Electron,
50
+ * extensions); local-resource disclosure is then the embedder's call.
51
+ */
52
+ setAllowSchemes(schemes: string[]): void;
41
53
  /**
42
54
  * Opt-in structured `kind.data` channel for Table blocks: a Table then
43
55
  * carries `{ headers, rows, aligns }` (per-cell `{ text, html }`) so a
@@ -46,6 +58,17 @@ export class BrookParser {
46
58
  * as `{"type":"Table"}` (no `data` key) and output is byte-identical.
47
59
  */
48
60
  setBlockData(on: boolean): void;
61
+ /**
62
+ * Extend the safe raw-HTML sanitizer to BLOCK-level raw HTML — a
63
+ * `<details><summary>…` block renders as real elements instead of escaping
64
+ * into a code block. Takes effect ONLY when the sanitizer is engaged
65
+ * (`setHtmlSanitize`), and only for CommonMark HTML block types 6 and 7.
66
+ * Types 1–5 (`<script>`/`<pre>`/`<style>`/`<textarea>`, comments, PIs,
67
+ * CDATA, declarations) stay escaped/dropped. Still-open elements get
68
+ * speculative closers while the block streams, so the emitted HTML is a
69
+ * complete tree at every prefix. Off by default (output unchanged).
70
+ */
71
+ setBlockHtml(on: boolean): void;
49
72
  /**
50
73
  * Set the opt-in component-tag allowlist (e.g. `["Thinking", "Callout"]`).
51
74
  * A `<Tag>…</Tag>` whose name is listed renders as a component whose inner
@@ -107,6 +130,23 @@ export class BrookParser {
107
130
  * cells, and list items. Empty by default (inline output unchanged).
108
131
  */
109
132
  setInlineComponentTags(tags: string[]): void;
133
+ /**
134
+ * Lenient list indentation: a list marker followed by 6+ columns of SPACE
135
+ * padding yields the item's text instead of an indented code block. Off by
136
+ * default (strict CommonMark §5.2). Useful for model output, which routinely
137
+ * over-indents after a bullet. Exactly-5-column padding, a fence opened on
138
+ * the marker line, indented code starting on a later line, and tab-padded
139
+ * markers all stay strictly conformant.
140
+ */
141
+ setLenientLists(on: boolean): void;
142
+ /**
143
+ * Render a CommonMark SOFT line break (a bare `\n` in inline content) as a
144
+ * `<br>` — the `remark-breaks` convention, where one Enter is one visual
145
+ * line. Off by default (strict CommonMark: a soft break is whitespace).
146
+ * Hard breaks (two trailing spaces / trailing `\`) are `<br>` either way,
147
+ * so turning this on only ADDS breaks; it never removes one.
148
+ */
149
+ setSoftBreaks(on: boolean): void;
110
150
  /**
111
151
  * Enable or disable raw-HTML pass-through. Default off. Do not enable
112
152
  * when rendering untrusted input — bypasses XSS protection.
@@ -135,7 +175,9 @@ export interface InitOutput {
135
175
  readonly brookparser_new: () => number;
136
176
  readonly brookparser_retainedBytes: (a: number) => number;
137
177
  readonly brookparser_setA11y: (a: number, b: number) => void;
178
+ readonly brookparser_setAllowSchemes: (a: number, b: number, c: number) => void;
138
179
  readonly brookparser_setBlockData: (a: number, b: number) => void;
180
+ readonly brookparser_setBlockHtml: (a: number, b: number) => void;
139
181
  readonly brookparser_setComponentTags: (a: number, b: number, c: number) => void;
140
182
  readonly brookparser_setDirAuto: (a: number, b: number) => void;
141
183
  readonly brookparser_setGfmAlerts: (a: number, b: number) => void;
@@ -145,6 +187,8 @@ export interface InitOutput {
145
187
  readonly brookparser_setGfmTagfilter: (a: number, b: number) => void;
146
188
  readonly brookparser_setHtmlSanitize: (a: number, b: number, c: number, d: number, e: number, f: number) => void;
147
189
  readonly brookparser_setInlineComponentTags: (a: number, b: number, c: number) => void;
190
+ readonly brookparser_setLenientLists: (a: number, b: number) => void;
191
+ readonly brookparser_setSoftBreaks: (a: number, b: number) => void;
148
192
  readonly brookparser_setUnsafeHtml: (a: number, b: number) => void;
149
193
  readonly brookparser_setWireDelta: (a: number, b: number) => void;
150
194
  readonly __wbindgen_export: (a: number, b: number) => number;
@@ -138,6 +138,23 @@ export class BrookParser {
138
138
  setA11y(on) {
139
139
  wasm.brookparser_setA11y(this.__wbg_ptr, on);
140
140
  }
141
+ /**
142
+ * Un-block specific URL schemes that are blocked by DEFAULT — bare scheme
143
+ * names without the colon (`["file"]`), matched case-insensitively. Empty
144
+ * by default (built-in policy unchanged). This never RESTRICTS anything:
145
+ * schemes outside the built-in blocklist (`vscode:`, `ftp:`, …) already
146
+ * pass. The script-executing tier (`javascript:`, `vbscript:`,
147
+ * `data:text/html`, `data:text/javascript`, scriptable `data:` media types)
148
+ * is non-overridable — listing one here is a no-op. Only enable `file:` in
149
+ * a host that intercepts link clicks instead of navigating (Electron,
150
+ * extensions); local-resource disclosure is then the embedder's call.
151
+ * @param {string[]} schemes
152
+ */
153
+ setAllowSchemes(schemes) {
154
+ const ptr0 = passArrayJsValueToWasm0(schemes, wasm.__wbindgen_export);
155
+ const len0 = WASM_VECTOR_LEN;
156
+ wasm.brookparser_setAllowSchemes(this.__wbg_ptr, ptr0, len0);
157
+ }
141
158
  /**
142
159
  * Opt-in structured `kind.data` channel for Table blocks: a Table then
143
160
  * carries `{ headers, rows, aligns }` (per-cell `{ text, html }`) so a
@@ -149,6 +166,20 @@ export class BrookParser {
149
166
  setBlockData(on) {
150
167
  wasm.brookparser_setBlockData(this.__wbg_ptr, on);
151
168
  }
169
+ /**
170
+ * Extend the safe raw-HTML sanitizer to BLOCK-level raw HTML — a
171
+ * `<details><summary>…` block renders as real elements instead of escaping
172
+ * into a code block. Takes effect ONLY when the sanitizer is engaged
173
+ * (`setHtmlSanitize`), and only for CommonMark HTML block types 6 and 7.
174
+ * Types 1–5 (`<script>`/`<pre>`/`<style>`/`<textarea>`, comments, PIs,
175
+ * CDATA, declarations) stay escaped/dropped. Still-open elements get
176
+ * speculative closers while the block streams, so the emitted HTML is a
177
+ * complete tree at every prefix. Off by default (output unchanged).
178
+ * @param {boolean} on
179
+ */
180
+ setBlockHtml(on) {
181
+ wasm.brookparser_setBlockHtml(this.__wbg_ptr, on);
182
+ }
152
183
  /**
153
184
  * Set the opt-in component-tag allowlist (e.g. `["Thinking", "Callout"]`).
154
185
  * A `<Tag>…</Tag>` whose name is listed renders as a component whose inner
@@ -247,6 +278,29 @@ export class BrookParser {
247
278
  const len0 = WASM_VECTOR_LEN;
248
279
  wasm.brookparser_setInlineComponentTags(this.__wbg_ptr, ptr0, len0);
249
280
  }
281
+ /**
282
+ * Lenient list indentation: a list marker followed by 6+ columns of SPACE
283
+ * padding yields the item's text instead of an indented code block. Off by
284
+ * default (strict CommonMark §5.2). Useful for model output, which routinely
285
+ * over-indents after a bullet. Exactly-5-column padding, a fence opened on
286
+ * the marker line, indented code starting on a later line, and tab-padded
287
+ * markers all stay strictly conformant.
288
+ * @param {boolean} on
289
+ */
290
+ setLenientLists(on) {
291
+ wasm.brookparser_setLenientLists(this.__wbg_ptr, on);
292
+ }
293
+ /**
294
+ * Render a CommonMark SOFT line break (a bare `\n` in inline content) as a
295
+ * `<br>` — the `remark-breaks` convention, where one Enter is one visual
296
+ * line. Off by default (strict CommonMark: a soft break is whitespace).
297
+ * Hard breaks (two trailing spaces / trailing `\`) are `<br>` either way,
298
+ * so turning this on only ADDS breaks; it never removes one.
299
+ * @param {boolean} on
300
+ */
301
+ setSoftBreaks(on) {
302
+ wasm.brookparser_setSoftBreaks(this.__wbg_ptr, on);
303
+ }
250
304
  /**
251
305
  * Enable or disable raw-HTML pass-through. Default off. Do not enable
252
306
  * when rendering untrusted input — bypasses XSS protection.
Binary file
@@ -9,7 +9,9 @@ export const brookparser_finalize: (a: number, b: number) => void;
9
9
  export const brookparser_new: () => number;
10
10
  export const brookparser_retainedBytes: (a: number) => number;
11
11
  export const brookparser_setA11y: (a: number, b: number) => void;
12
+ export const brookparser_setAllowSchemes: (a: number, b: number, c: number) => void;
12
13
  export const brookparser_setBlockData: (a: number, b: number) => void;
14
+ export const brookparser_setBlockHtml: (a: number, b: number) => void;
13
15
  export const brookparser_setComponentTags: (a: number, b: number, c: number) => void;
14
16
  export const brookparser_setDirAuto: (a: number, b: number) => void;
15
17
  export const brookparser_setGfmAlerts: (a: number, b: number) => void;
@@ -19,6 +21,8 @@ export const brookparser_setGfmMath: (a: number, b: number) => void;
19
21
  export const brookparser_setGfmTagfilter: (a: number, b: number) => void;
20
22
  export const brookparser_setHtmlSanitize: (a: number, b: number, c: number, d: number, e: number, f: number) => void;
21
23
  export const brookparser_setInlineComponentTags: (a: number, b: number, c: number) => void;
24
+ export const brookparser_setLenientLists: (a: number, b: number) => void;
25
+ export const brookparser_setSoftBreaks: (a: number, b: number) => void;
22
26
  export const brookparser_setUnsafeHtml: (a: number, b: number) => void;
23
27
  export const brookparser_setWireDelta: (a: number, b: number) => void;
24
28
  export const __wbindgen_export: (a: number, b: number) => number;
package/dist/worker.js CHANGED
@@ -15,6 +15,8 @@ const core = new WorkerCore({
15
15
  p.setGfmFootnotes(c?.gfmFootnotes ?? false);
16
16
  p.setGfmMath(c?.gfmMath ?? false);
17
17
  p.setDirAuto(c?.dirAuto ?? false);
18
+ p.setLenientLists(c?.lenientLists ?? false);
19
+ p.setSoftBreaks(c?.softBreaks ?? false);
18
20
  p.setA11y(c?.a11y ?? false);
19
21
  p.setUnsafeHtml(c?.unsafeHtml ?? false);
20
22
  p.setComponentTags(c?.componentTags ?? []);
@@ -24,6 +26,8 @@ const core = new WorkerCore({
24
26
  c?.htmlAllowlist ?? [],
25
27
  c?.dropHtmlTags ?? []
26
28
  );
29
+ p.setBlockHtml(c?.blockHtml ?? false);
30
+ p.setAllowSchemes(c?.allowSchemes ?? []);
27
31
  p.setBlockData(c?.blockData ?? false);
28
32
  p.setWireDelta(true);
29
33
  return p;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "brookmd",
3
- "version": "0.25.1",
3
+ "version": "0.26.0",
4
4
  "description": "Zero-dep streaming markdown for the browser. Rust→WASM core, Web Worker per stream, incremental parse with speculative closure.",
5
5
  "type": "module",
6
6
  "sideEffects": ["./dist/worker.js", "./dist/styles.css"],