brookmd 0.29.1 → 0.30.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/dist/splice.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { escapeHtml } from "./hi.js";
1
2
  const SPLICE = /* @__PURE__ */ new WeakMap();
2
3
  const SPLICE_DEPTH = 8;
3
4
  function noteSplice(next, prev, keep) {
@@ -18,23 +19,31 @@ function spliceKeep(from, to) {
18
19
  }
19
20
  return void 0;
20
21
  }
21
- function newIncCode(code, lang, st) {
22
+ function incView(st, markup, mode) {
23
+ if (mode !== "wavefront" || markup !== st.html) return markup;
24
+ return st.frozenHtml + escapeHtml(st.text.slice(st.c));
25
+ }
26
+ function newIncCode(code, lang, st, mode) {
22
27
  return {
23
28
  code,
24
29
  lang,
30
+ mode,
25
31
  frozenEnd: null,
26
32
  frozenLen: 0,
27
33
  frozenRev: st.frozenRev,
28
34
  frozenEnd0: null,
29
35
  frozenLen0: 0,
30
- tail: ""
36
+ tail: "",
37
+ tailText: null
31
38
  };
32
39
  }
33
40
  function paintIncCode(ic, st, markup) {
34
41
  const frozen = st.frozenHtml;
35
42
  if (markup.length < frozen.length) return false;
43
+ const wave = ic.mode === "wavefront";
44
+ if (wave && markup !== st.html) return false;
36
45
  let rewound = ic.frozenRev !== st.frozenRev || frozen.length < ic.frozenLen;
37
- const tail = markup.slice(frozen.length);
46
+ const tail = wave ? st.text.slice(st.c) : markup.slice(frozen.length);
38
47
  if (!rewound && frozen.length === ic.frozenLen && tail === ic.tail) return true;
39
48
  if (rewound && st.frozenRev === ic.frozenRev + 1 && st.frozenCut === ic.frozenLen0) {
40
49
  ic.frozenEnd = ic.frozenEnd0;
@@ -44,23 +53,42 @@ function paintIncCode(ic, st, markup) {
44
53
  ic.frozenLen0 = 0;
45
54
  rewound = false;
46
55
  }
47
- const keep = rewound ? null : ic.frozenEnd;
48
- while (ic.code.lastChild !== keep) ic.code.removeChild(ic.code.lastChild);
49
56
  if (rewound) {
57
+ while (ic.code.lastChild !== null) ic.code.removeChild(ic.code.lastChild);
50
58
  ic.frozenEnd = null;
51
59
  ic.frozenLen = 0;
52
60
  ic.frozenEnd0 = null;
53
61
  ic.frozenLen0 = 0;
54
62
  ic.frozenRev = st.frozenRev;
63
+ ic.tailText = null;
64
+ } else {
65
+ const t = ic.tailText;
66
+ if (t !== null) {
67
+ while (ic.code.lastChild !== t) ic.code.removeChild(ic.code.lastChild);
68
+ while (t.previousSibling !== ic.frozenEnd) ic.code.removeChild(t.previousSibling);
69
+ } else {
70
+ while (ic.code.lastChild !== ic.frozenEnd) ic.code.removeChild(ic.code.lastChild);
71
+ }
55
72
  }
56
73
  if (frozen.length > ic.frozenLen) {
57
74
  ic.frozenEnd0 = ic.frozenEnd;
58
75
  ic.frozenLen0 = ic.frozenLen;
76
+ const t = ic.tailText;
77
+ if (t !== null) ic.code.removeChild(t);
59
78
  ic.code.insertAdjacentHTML("beforeend", frozen.slice(ic.frozenLen));
60
79
  ic.frozenEnd = ic.code.lastChild;
61
80
  ic.frozenLen = frozen.length;
81
+ if (t !== null) ic.code.appendChild(t);
82
+ }
83
+ if (wave) {
84
+ if (ic.tailText === null && tail !== "") {
85
+ ic.tailText = ic.code.ownerDocument.createTextNode("");
86
+ ic.code.appendChild(ic.tailText);
87
+ }
88
+ if (ic.tailText !== null) ic.tailText.nodeValue = tail;
89
+ } else if (tail) {
90
+ ic.code.insertAdjacentHTML("beforeend", tail);
62
91
  }
63
- if (tail) ic.code.insertAdjacentHTML("beforeend", tail);
64
92
  ic.tail = tail;
65
93
  return true;
66
94
  }
@@ -204,6 +232,7 @@ function __resetSpliceStats() {
204
232
  export {
205
233
  __resetSpliceStats,
206
234
  __spliceStats,
235
+ incView,
207
236
  newIncCode,
208
237
  noteSplice,
209
238
  paintIncCode,
package/dist/styles.css CHANGED
@@ -23,6 +23,13 @@
23
23
  --brook-bg-inline: rgba(129, 139, 152, 0.16);
24
24
  --brook-bg-quote: rgba(129, 139, 152, 0.08);
25
25
  --brook-accent: #0969da;
26
+ /* alert accents (light) — `note` tracks --brook-accent, so re-theming the
27
+ link color re-themes the note alert with it. */
28
+ --brook-alert-note: var(--brook-accent);
29
+ --brook-alert-tip: #1a7f37;
30
+ --brook-alert-important: #8250df;
31
+ --brook-alert-warning: #9a6700;
32
+ --brook-alert-caution: #cf222e;
26
33
  /* syntax tokens (light) */
27
34
  --brook-t-kw: #cf222e;
28
35
  --brook-t-str: #0a3069;
@@ -39,6 +46,15 @@
39
46
  --brook-radius: 6px;
40
47
  --brook-gap: 16px;
41
48
 
49
+ /* Motion + caret. Mode-independent (the caret paints in `currentColor`, so it
50
+ tracks --brook-fg on its own), so unlike the palette these need no dark
51
+ twin — they sit on the always-matching base selector. Both animations are
52
+ routed through a variable so the ONE `prefers-reduced-motion` block at the
53
+ foot of this file switches every one of them off. */
54
+ --brook-caret: currentColor;
55
+ --brook-caret-anim: brook-caret-blink 1s steps(2, jump-none) infinite;
56
+ --brook-pill-anim: brook-pill-pulse 1.6s ease-in-out infinite;
57
+
42
58
  color: var(--brook-fg);
43
59
  font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
44
60
  line-height: 1.6;
@@ -58,6 +74,10 @@
58
74
  --brook-bg-inline: rgba(101, 108, 118, 0.2);
59
75
  --brook-bg-quote: rgba(101, 108, 118, 0.1);
60
76
  --brook-accent: #4493f8;
77
+ --brook-alert-tip: #3fb950;
78
+ --brook-alert-important: #ab7df8;
79
+ --brook-alert-warning: #d29922;
80
+ --brook-alert-caution: #f85149;
61
81
  --brook-t-kw: #ff7b72;
62
82
  --brook-t-str: #a5d6ff;
63
83
  --brook-t-num: #79c0ff;
@@ -79,6 +99,10 @@
79
99
  --brook-bg-inline: rgba(101, 108, 118, 0.2);
80
100
  --brook-bg-quote: rgba(101, 108, 118, 0.1);
81
101
  --brook-accent: #4493f8;
102
+ --brook-alert-tip: #3fb950;
103
+ --brook-alert-important: #ab7df8;
104
+ --brook-alert-warning: #d29922;
105
+ --brook-alert-caution: #f85149;
82
106
  --brook-t-kw: #ff7b72;
83
107
  --brook-t-str: #a5d6ff;
84
108
  --brook-t-num: #79c0ff;
@@ -95,6 +119,25 @@
95
119
  .brook-md > * { margin: 0 0 var(--brook-gap) 0; }
96
120
  .brook-md > *:last-child { margin-bottom: 0; }
97
121
 
122
+ /* Every block the renderers emit on the generic path is wrapped in a
123
+ layout-transparent `<div class="brook-block brook-block-KIND">`, so the gap
124
+ above belongs to the WRAPPER and the wrapped element must not stack its own
125
+ leading/trailing margin on top of it (without this the last block's margin
126
+ escapes the root and `> *:last-child` above never bites). */
127
+ .brook-md .brook-block > :first-child { margin-top: 0; }
128
+ .brook-md .brook-block > :last-child { margin-bottom: 0; }
129
+ /* …which would also flatten a heading's extra lead-in, so hand that to the
130
+ wrapper, where it collapses with the previous block's gap instead of adding
131
+ to it. */
132
+ .brook-md .brook-block-heading { margin-top: 24px; }
133
+ .brook-md > .brook-block-heading:first-child { margin-top: 0; }
134
+
135
+ /* `brook-speculative` marks a block whose closing delimiter the parser GUESSED
136
+ and may retract. It is deliberately invisible: a speculative block that looks
137
+ different from the settled block it becomes would flash on every guess. Kept
138
+ as a declared hook so overriding it is a one-liner. */
139
+ .brook-md .brook-speculative { --brook-speculative: 1; }
140
+
98
141
  /* ---- headings -------------------------------------------------------------- */
99
142
  .brook-md h1, .brook-md h2, .brook-md h3,
100
143
  .brook-md h4, .brook-md h5, .brook-md h6 {
@@ -141,6 +184,31 @@
141
184
  }
142
185
  .brook-md blockquote > :last-child { margin-bottom: 0; }
143
186
 
187
+ /* GitHub-style alerts (`> [!NOTE]` &c. — `gfmAlerts`). A colored left rule plus
188
+ a colored title; the body stays on the page background so a run of alerts
189
+ does not read as a wall of tinted boxes. */
190
+ .brook-md .markdown-alert {
191
+ margin: 0 0 var(--brook-gap);
192
+ padding: 0.4em 1em;
193
+ border-left: 0.25em solid var(--brook-alert-note);
194
+ color: inherit;
195
+ }
196
+ .brook-md .markdown-alert > :last-child { margin-bottom: 0; }
197
+ .brook-md .markdown-alert-title {
198
+ margin: 0 0 0.35em;
199
+ font-weight: 600;
200
+ line-height: 1.4;
201
+ color: var(--brook-alert-note);
202
+ }
203
+ .brook-md .markdown-alert-tip { border-left-color: var(--brook-alert-tip); }
204
+ .brook-md .markdown-alert-tip > .markdown-alert-title { color: var(--brook-alert-tip); }
205
+ .brook-md .markdown-alert-important { border-left-color: var(--brook-alert-important); }
206
+ .brook-md .markdown-alert-important > .markdown-alert-title { color: var(--brook-alert-important); }
207
+ .brook-md .markdown-alert-warning { border-left-color: var(--brook-alert-warning); }
208
+ .brook-md .markdown-alert-warning > .markdown-alert-title { color: var(--brook-alert-warning); }
209
+ .brook-md .markdown-alert-caution { border-left-color: var(--brook-alert-caution); }
210
+ .brook-md .markdown-alert-caution > .markdown-alert-title { color: var(--brook-alert-caution); }
211
+
144
212
  /* ---- tables ---------------------------------------------------------------- */
145
213
  .brook-md table { border-collapse: collapse; width: 100%; display: block; overflow-x: auto; }
146
214
  .brook-md th, .brook-md td { padding: 6px 13px; border: 1px solid var(--brook-border); }
@@ -186,3 +254,230 @@
186
254
  .brook-md .t-var { color: var(--brook-t-var); }
187
255
  .brook-md .t-pun { color: var(--brook-t-pun); }
188
256
  .brook-md .t-txt { color: inherit; }
257
+
258
+ /* ---- fenced-block chrome (code / math / mermaid) --------------------------- */
259
+ /* The built-in CodeBlock, MathBlock and Mermaid renderers all emit the same
260
+ three-part shape:
261
+ <div class="brook-CHROME-block [brook-streaming]">
262
+ <div class="brook-CHROME-header">
263
+ <span class="brook-CHROME-lang">…</span>
264
+ <span class="brook-code-streaming-pill">streaming</span> (open)
265
+ <button class="brook-code-copy">…</button> (closed, code only)
266
+ </div>
267
+ <div class="brook-CHROME-body">…</div>
268
+ </div>
269
+ The container owns the border, the radius and the background; the header is
270
+ the top edge; the body's `pre` gives all of that up so there is no second
271
+ frame drawn inside the first. */
272
+ .brook-md .brook-code-block,
273
+ .brook-md .brook-math-block,
274
+ .brook-md .brook-mermaid-block {
275
+ border: 1px solid var(--brook-border);
276
+ border-radius: var(--brook-radius);
277
+ background: var(--brook-bg-code);
278
+ /* Clips the body's square corners back to the container's radius. */
279
+ overflow: hidden;
280
+ }
281
+
282
+ .brook-md .brook-code-header,
283
+ .brook-md .brook-math-header,
284
+ .brook-md .brook-mermaid-header {
285
+ display: flex;
286
+ align-items: center;
287
+ justify-content: space-between;
288
+ gap: 8px;
289
+ min-height: 34px;
290
+ padding: 4px 8px 4px 12px;
291
+ border-bottom: 1px solid var(--brook-border);
292
+ }
293
+
294
+ .brook-md .brook-code-lang,
295
+ .brook-md .brook-math-lang,
296
+ .brook-md .brook-mermaid-lang {
297
+ font-family: ui-monospace, "SF Mono", "JetBrains Mono", Menlo, Consolas, monospace;
298
+ font-size: 0.75em;
299
+ letter-spacing: 0.06em;
300
+ text-transform: uppercase;
301
+ color: var(--brook-fg-muted);
302
+ /* The label is the flex item that may need to shrink; never let a long
303
+ language name push the copy button off the right edge. */
304
+ min-width: 0;
305
+ overflow: hidden;
306
+ text-overflow: ellipsis;
307
+ white-space: nowrap;
308
+ }
309
+
310
+ /* Copy button. Ghost by default so the chrome stays quiet until pointed at. */
311
+ .brook-md .brook-code-copy {
312
+ display: inline-flex;
313
+ align-items: center;
314
+ flex: none;
315
+ gap: 5px;
316
+ margin: 0;
317
+ padding: 3px 8px;
318
+ font: inherit;
319
+ font-size: 0.75em;
320
+ line-height: 1.5;
321
+ color: var(--brook-fg-muted);
322
+ background: none;
323
+ border: 1px solid transparent;
324
+ border-radius: var(--brook-radius);
325
+ cursor: pointer;
326
+ }
327
+ .brook-md .brook-code-copy:hover {
328
+ color: var(--brook-fg);
329
+ background: var(--brook-bg-inline);
330
+ border-color: var(--brook-border);
331
+ }
332
+ .brook-md .brook-code-copy:focus-visible {
333
+ outline: 2px solid var(--brook-accent);
334
+ outline-offset: 1px;
335
+ }
336
+ /* The "copied" state is carried on `aria-label` — the React renderer swaps the
337
+ prop, the DOM renderer setAttribute()s it — so the accessible name IS the
338
+ selector. Both adapters revert it after ~1.5s. */
339
+ .brook-md .brook-code-copy[aria-label="Copied"] {
340
+ color: var(--brook-accent);
341
+ border-color: currentColor;
342
+ }
343
+ .brook-md .brook-code-copy svg { display: block; flex: none; }
344
+
345
+ /* "streaming" pill, shown in place of the copy button while a fence is open. */
346
+ .brook-md .brook-code-streaming-pill {
347
+ display: inline-flex;
348
+ align-items: center;
349
+ flex: none;
350
+ padding: 2px 9px;
351
+ font-size: 0.7em;
352
+ letter-spacing: 0.06em;
353
+ text-transform: uppercase;
354
+ color: var(--brook-fg-muted);
355
+ background: var(--brook-bg-inline);
356
+ border-radius: 999px;
357
+ animation: var(--brook-pill-anim);
358
+ }
359
+
360
+ /* `brook-streaming` (the open-fence modifier) is intentionally inert: the pill
361
+ above already says "still arriving", and restyling the frame mid-stream would
362
+ make every fence twitch as it closes. Declared as an override hook. */
363
+ .brook-md .brook-streaming { --brook-streaming: 1; }
364
+
365
+ /* Body: the `pre` hands its frame to the container and keeps only its padding
366
+ and its own horizontal scroll. */
367
+ .brook-md .brook-code-body pre,
368
+ .brook-md .brook-mermaid-body pre {
369
+ margin: 0;
370
+ border: 0;
371
+ border-radius: 0;
372
+ background: none;
373
+ }
374
+ .brook-md .brook-math-body {
375
+ padding: 14px 16px;
376
+ overflow-x: auto;
377
+ }
378
+ /* The scrollable code region is focusable (tabIndex=0 on the `pre`, or on the
379
+ wrapping div before highlighting lands) so it can be scrolled from the
380
+ keyboard — inset the ring so the clipped container does not eat it. */
381
+ .brook-md .brook-code-body pre:focus-visible,
382
+ .brook-md .brook-code-body > div:focus-visible,
383
+ .brook-md .brook-mermaid-body pre:focus-visible {
384
+ outline: 2px solid var(--brook-accent);
385
+ outline-offset: -2px;
386
+ }
387
+
388
+ /* ---- math ------------------------------------------------------------------ */
389
+ /* Defaults for the zero-dep output (the raw LaTeX in a `.math` node). Kept
390
+ deliberately thin — no font or color of its own — so dropping a typesetter
391
+ over these nodes replaces the content without fighting the theme. */
392
+ .brook-md .math.math-display {
393
+ display: block;
394
+ overflow-x: auto;
395
+ text-align: center;
396
+ margin: var(--brook-gap) 0;
397
+ }
398
+ /* Inside the math chrome the body already supplies the padding. */
399
+ .brook-md .brook-math-body > .math.math-display { margin: 0; }
400
+ .brook-md .math.math-inline { display: inline; }
401
+
402
+ /* ---- footnotes ------------------------------------------------------------- */
403
+ .brook-md .footnotes {
404
+ padding-top: var(--brook-gap);
405
+ border-top: 1px solid var(--brook-border);
406
+ font-size: 0.875em;
407
+ color: var(--brook-fg-muted);
408
+ }
409
+ .brook-md .footnotes ol { padding-left: 1.6em; }
410
+ .brook-md .footnotes li { margin: 0.4em 0; }
411
+ .brook-md .footnotes li:target { background: var(--brook-bg-quote); }
412
+ .brook-md sup.footnote-ref { line-height: 0; }
413
+ .brook-md sup.footnote-ref > a { padding: 0 0.15em; text-decoration: none; }
414
+ .brook-md .footnote-backref { margin-left: 0.25em; text-decoration: none; }
415
+
416
+ /* ---- task lists ------------------------------------------------------------ */
417
+ /* GFM task boxes are rendered `disabled` (they mirror the source, they do not
418
+ edit it). Keep them legible rather than greyed out, and drop the
419
+ not-allowed cursor a disabled control would otherwise show. */
420
+ .brook-md input[type="checkbox"][disabled] { opacity: 1; cursor: default; }
421
+
422
+ /* ---- scroll sentinel ------------------------------------------------------- */
423
+ /* `stickToBottom` appends an empty aria-hidden anchor as the last child of the
424
+ root. It is a scroll-snap target, not content: it must occupy no space, which
425
+ means opting out of the root's block gap as well as having no height. */
426
+ .brook-md .brook-bottom-anchor { height: 0; margin: 0; }
427
+
428
+ /* ---- streaming caret ------------------------------------------------------- */
429
+ /* Opt in per root: `<div class="brook-md brook-caret">`. Draws one blinking bar
430
+ at the tail of the block still being streamed (`brook-open`). Deliberately
431
+ NOT drawn inside code/math/mermaid fences — those show the "streaming" pill
432
+ instead — and each selector below is anchored so exactly one element in an
433
+ open block can match (a bare `p:last-child` would light up every paragraph in
434
+ a loose list). Retint with `--brook-caret`, or switch it off wholesale with
435
+ `.brook-md.brook-caret ::after { content: none }`. */
436
+ .brook-md.brook-caret .brook-open > p:last-child::after,
437
+ .brook-md.brook-caret .brook-open > :is(h1, h2, h3, h4, h5, h6):last-child::after,
438
+ .brook-md.brook-caret .brook-open > blockquote > :last-child:not(pre)::after,
439
+ .brook-md.brook-caret .brook-open > :is(ul, ol) > li:last-child > p:last-child::after,
440
+ .brook-md.brook-caret .brook-open > :is(ul, ol) > li:last-child > :is(ul, ol) > li:last-child > p:last-child::after,
441
+ .brook-md.brook-caret .brook-open table tr:last-child > td:last-child::after {
442
+ content: "";
443
+ display: inline-block;
444
+ width: 0.5em;
445
+ height: 1em;
446
+ margin-left: 0.08em;
447
+ vertical-align: text-bottom;
448
+ background: var(--brook-caret);
449
+ animation: var(--brook-caret-anim);
450
+ }
451
+ /* Tight list items (no <p> wrapper) need `:has()` to tell "last leaf item" from
452
+ "item that contains a nested list". A selector list is all-or-nothing, so
453
+ these two live in their own rule: an engine without `:has()` drops only the
454
+ tight-list caret, not the paragraph/heading/quote/table ones above. */
455
+ .brook-md.brook-caret .brook-open > :is(ul, ol) > li:last-child:not(:has(> :is(p, ul, ol)))::after,
456
+ .brook-md.brook-caret .brook-open > :is(ul, ol) > li:last-child > :is(ul, ol) > li:last-child:not(:has(> :is(p, ul, ol)))::after {
457
+ content: "";
458
+ display: inline-block;
459
+ width: 0.5em;
460
+ height: 1em;
461
+ margin-left: 0.08em;
462
+ vertical-align: text-bottom;
463
+ background: var(--brook-caret);
464
+ animation: var(--brook-caret-anim);
465
+ }
466
+
467
+ /* ---- animations ------------------------------------------------------------ */
468
+ @keyframes brook-caret-blink {
469
+ from { opacity: 1; }
470
+ to { opacity: 0; }
471
+ }
472
+ @keyframes brook-pill-pulse {
473
+ 50% { opacity: 0.45; }
474
+ }
475
+ /* One switch for every animation in this file: both are applied through a
476
+ custom property, and custom properties inherit into `::after`, so redefining
477
+ them on the root turns the caret and the pill off together. */
478
+ @media (prefers-reduced-motion: reduce) {
479
+ .brook-md {
480
+ --brook-caret-anim: none;
481
+ --brook-pill-anim: none;
482
+ }
483
+ }
package/dist/svelte.d.ts CHANGED
@@ -2,7 +2,7 @@ import type { ActionReturn } from "svelte/action";
2
2
  import { type Readable } from "svelte/store";
3
3
  import { BrookClient } from "./client.js";
4
4
  import type { ParserConfig } from "./types-core.js";
5
- import { type DomComponents } from "./dom.js";
5
+ import { type DomComponents, type LinkClickInfo } from "./dom.js";
6
6
  /**
7
7
  * Svelte action that mounts a streaming {@link BrookClient} into the host node.
8
8
  * Plain `.ts` — no `.svelte` compile step — so `use:` works unchanged in
@@ -20,6 +20,9 @@ export interface BrookMarkdownParams {
20
20
  sanitize?: (h: string) => string;
21
21
  virtualize?: boolean;
22
22
  stickToBottom?: boolean;
23
+ /** Delegated link-click hook — ONE listener on the renderer root, never per
24
+ * anchor (see `MountOptions.onLinkClick`). */
25
+ onLinkClick?: (event: MouseEvent, link: LinkClickInfo) => void;
23
26
  }
24
27
  export declare function brookMarkdown(node: HTMLElement, params: BrookMarkdownParams): ActionReturn<BrookMarkdownParams>;
25
28
  /**
@@ -65,7 +68,8 @@ export declare function tailBlockId(client: BrookClient): Readable<number | null
65
68
  * Lifecycle differs from {@link brookMarkdown}: this action constructs the client
66
69
  * once (a later `config` change is ignored, like a created-once instance) and
67
70
  * `destroy()`s it on teardown — it OWNS the client. The mount-option reconcile
68
- * (`components`/`sanitize`/`virtualize`/`stickToBottom`) matches `brookMarkdown`,
71
+ * (`components`/`sanitize`/`virtualize`/`stickToBottom`/`onLinkClick`) matches
72
+ * `brookMarkdown`,
69
73
  * but the remount reuses the SAME client so its `setContent` diff baseline
70
74
  * survives.
71
75
  */
package/dist/svelte.js CHANGED
@@ -1,12 +1,15 @@
1
1
  import { readable } from "svelte/store";
2
2
  import { BrookClient } from "./client.js";
3
- import { mountBrookMarkdown, tailOpenBlockId } from "./dom.js";
3
+ import {
4
+ mountBrookMarkdown,
5
+ tailOpenBlockId
6
+ } from "./dom.js";
4
7
  function brookMarkdown(node, params) {
5
8
  let { client, ...options } = params;
6
9
  let handle = mountBrookMarkdown(client, node, options);
7
10
  return {
8
11
  update(next) {
9
- if (next.client === client && next.components === options.components && next.sanitize === options.sanitize && next.virtualize === options.virtualize && next.stickToBottom === options.stickToBottom) {
12
+ if (next.client === client && next.components === options.components && next.sanitize === options.sanitize && next.virtualize === options.virtualize && next.stickToBottom === options.stickToBottom && next.onLinkClick === options.onLinkClick) {
10
13
  return;
11
14
  }
12
15
  handle.destroy();
@@ -39,7 +42,7 @@ function brookMarkdownString(node, params) {
39
42
  return {
40
43
  update(next) {
41
44
  client.setContent(next.content, { done: next.streaming === false });
42
- if (next.components === options.components && next.sanitize === options.sanitize && next.virtualize === options.virtualize && next.stickToBottom === options.stickToBottom) {
45
+ if (next.components === options.components && next.sanitize === options.sanitize && next.virtualize === options.virtualize && next.stickToBottom === options.stickToBottom && next.onLinkClick === options.onLinkClick) {
43
46
  return;
44
47
  }
45
48
  handle.destroy();
@@ -48,6 +48,26 @@ export type UrlTransform = (url: string, ctx: {
48
48
  tag: string;
49
49
  attr: "href" | "src" | "poster";
50
50
  }) => string;
51
+ /**
52
+ * The link a delegated click landed on, handed to every renderer's
53
+ * `onLinkClick` hook alongside the raw event.
54
+ *
55
+ * The hook is DELEGATED by design: ONE listener sits on the renderer's
56
+ * `.brook-md` root and resolves the anchor from the event target, so turning it
57
+ * on adds no per-block and no per-anchor work — the streaming path is untouched
58
+ * and no block's memo/node reuse is affected.
59
+ *
60
+ * A still-streaming anchor (`data-brook-pending`: label rendered, URL not yet
61
+ * arrived) is never reported — there is no `href` to hand you yet.
62
+ */
63
+ export interface LinkClickInfo {
64
+ /** The anchor's `href` attribute verbatim, as the core emitted it. */
65
+ href: string;
66
+ /** The anchor's rendered text content. */
67
+ text: string;
68
+ /** The live `<a>` element that was clicked. */
69
+ element: HTMLAnchorElement;
70
+ }
51
71
  /** Column alignment from the `|:--|:-:|--:|` delimiter row; `null` = unset. */
52
72
  export type Align = "left" | "center" | "right" | null;
53
73
  /**
@@ -397,7 +417,7 @@ export interface ParserConfig {
397
417
  lenientLists?: boolean;
398
418
  /**
399
419
  * Render a CommonMark SOFT line break (a bare `\n` inside inline content) as
400
- * a `<br>` — the `remark-breaks` / "GitHub comment" convention, where one
420
+ * a `<br>` — the "GitHub comment" convention, where one
401
421
  * Enter is one visual line. Default false (strict CommonMark: a soft break is
402
422
  * whitespace). Hard breaks (two trailing spaces, or a trailing `\`) are `<br>`
403
423
  * either way, so enabling this only ADDS breaks — it never removes one. Chat
@@ -2,7 +2,7 @@ import type { ComponentType } from "react";
2
2
  import type { BlockComponentProps, BlockKindTag } from "./types-core.js";
3
3
  /**
4
4
  * Override map for {@link BrookMarkdown}. Keys are either lowercase HTML tag
5
- * names (`table`, `a`, `code`, `h1`… — react-markdown style, applied inside a
5
+ * names (`table`, `a`, `code`, `h1`… — applied inside a
6
6
  * block's HTML) or capitalized block-kind names (`BlockKindTag`, e.g.
7
7
  * `CodeBlock`, `Table` — replace the whole block renderer). Values are a React
8
8
  * component or an HTML tag string.
package/dist/vue.d.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import type { DefineComponent, Ref } from "vue";
2
2
  import { BrookClient } from "./client.js";
3
3
  import type { ParserConfig } from "./types-core.js";
4
- import { type DomComponents, type MountOptions } from "./dom.js";
4
+ import { type DomComponents, type LinkClickInfo, type MountOptions } from "./dom.js";
5
5
  /**
6
6
  * Vue 3 bindings for {@link mountBrookMarkdown}. Thin lifecycle glue: mount the
7
7
  * framework-neutral DOM renderer on `onMounted`, tear it down on `onUnmounted`.
@@ -20,12 +20,12 @@ export type UseBrookMarkdownOptions = {
20
20
  * `{ container }` — bind it as the `ref` of the element you want filled.
21
21
  *
22
22
  * `getOpts` must read its fields lazily (e.g. `() => ({ client: props.client,
23
- * ... })`) so the watcher sees live prop identities. We watch the five
23
+ * ... })`) so the watcher sees live prop identities. We watch the six
24
24
  * identities individually — `[client, components, sanitize, virtualize,
25
- * stickToBottom]` — rather than a freshly-composed object, which would change
26
- * identity every call and remount on every patch. On any of those changing we
27
- * destroy and remount; `batch`/`highlightCode` still flow through to the mount
28
- * but are intentionally not remount triggers.
25
+ * stickToBottom, onLinkClick]` — rather than a freshly-composed object, which
26
+ * would change identity every call and remount on every patch. On any of those
27
+ * changing we destroy and remount; `batch`/`highlightCode` still flow through to
28
+ * the mount but are intentionally not remount triggers.
29
29
  */
30
30
  export declare function useBrookMarkdown(getOpts: () => UseBrookMarkdownOptions): {
31
31
  container: Ref<HTMLElement | null>;
@@ -48,6 +48,8 @@ export interface BrookMarkdownVueProps {
48
48
  sanitize?: (html: string) => string;
49
49
  virtualize?: boolean;
50
50
  stickToBottom?: boolean;
51
+ /** Delegated link-click hook (one listener on the root; see `MountOptions`). */
52
+ onLinkClick?: (event: MouseEvent, link: LinkClickInfo) => void;
51
53
  }
52
54
  /**
53
55
  * Component wrapper around {@link useBrookMarkdown}. Renders a single `<div>`
package/dist/vue.js CHANGED
@@ -23,7 +23,8 @@ function useBrookMarkdown(getOpts) {
23
23
  () => getOpts().components,
24
24
  () => getOpts().sanitize,
25
25
  () => getOpts().virtualize,
26
- () => getOpts().stickToBottom
26
+ () => getOpts().stickToBottom,
27
+ () => getOpts().onLinkClick
27
28
  ],
28
29
  () => {
29
30
  teardown();
@@ -48,7 +49,13 @@ const BrookMarkdown = defineComponent({
48
49
  components: { type: Object, default: void 0 },
49
50
  sanitize: { type: Function, default: void 0 },
50
51
  virtualize: { type: Boolean, default: void 0 },
51
- stickToBottom: { type: Boolean, default: void 0 }
52
+ stickToBottom: { type: Boolean, default: void 0 },
53
+ // Declared as a real prop (not an emit listener): Vue resolves a declared
54
+ // `onX` prop from `props` before it ever reaches `attrs`.
55
+ onLinkClick: {
56
+ type: Function,
57
+ default: void 0
58
+ }
52
59
  },
53
60
  setup(props) {
54
61
  const { container } = useBrookMarkdown(() => ({
@@ -56,7 +63,8 @@ const BrookMarkdown = defineComponent({
56
63
  components: props.components,
57
64
  sanitize: props.sanitize,
58
65
  virtualize: props.virtualize,
59
- stickToBottom: props.stickToBottom
66
+ stickToBottom: props.stickToBottom,
67
+ onLinkClick: props.onLinkClick
60
68
  }));
61
69
  return () => h("div", { ref: container });
62
70
  }
@@ -153,7 +153,7 @@ export class BrookParser {
153
153
  setRetainCommittedHtml(on: boolean): void;
154
154
  /**
155
155
  * Render a CommonMark SOFT line break (a bare `\n` in inline content) as a
156
- * `<br>` — the `remark-breaks` convention, where one Enter is one visual
156
+ * `<br>` — the "GitHub comment" convention, where one Enter is one visual
157
157
  * line. Off by default (strict CommonMark: a soft break is whitespace).
158
158
  * Hard breaks (two trailing spaces / trailing `\`) are `<br>` either way,
159
159
  * so turning this on only ADDS breaks; it never removes one.
@@ -307,7 +307,7 @@ export class BrookParser {
307
307
  }
308
308
  /**
309
309
  * Render a CommonMark SOFT line break (a bare `\n` in inline content) as a
310
- * `<br>` — the `remark-breaks` convention, where one Enter is one visual
310
+ * `<br>` — the "GitHub comment" convention, where one Enter is one visual
311
311
  * line. Off by default (strict CommonMark: a soft break is whitespace).
312
312
  * Hard breaks (two trailing spaces / trailing `\`) are `<br>` either way,
313
313
  * so turning this on only ADDS breaks; it never removes one.
Binary file
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "brookmd",
3
- "version": "0.29.1",
3
+ "version": "0.30.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"],