brookmd 0.29.0 → 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/CHANGELOG.md +170 -1
- package/README.md +643 -30
- package/dist/block-props.js +8 -2
- package/dist/dom.d.ts +25 -3
- package/dist/dom.js +33 -8
- package/dist/element.d.ts +3 -0
- package/dist/element.js +22 -2
- package/dist/hi-inc.d.ts +28 -0
- package/dist/hi-inc.js +40 -16
- package/dist/hi.d.ts +62 -4
- package/dist/hi.js +243 -22
- package/dist/index.d.ts +4 -3
- package/dist/index.js +2 -1
- package/dist/react.d.ts +30 -4
- package/dist/react.js +25 -3
- package/dist/renderers/CodeBlock.d.ts +3 -2
- package/dist/renderers/CodeBlock.js +29 -11
- package/dist/solid.js +1 -0
- package/dist/splice.d.ts +58 -11
- package/dist/splice.js +35 -6
- package/dist/styles.css +295 -0
- package/dist/svelte.d.ts +6 -2
- package/dist/svelte.js +6 -3
- package/dist/types-core.d.ts +21 -1
- package/dist/types-react.d.ts +1 -1
- package/dist/vue.d.ts +8 -6
- package/dist/vue.js +11 -3
- package/dist/wasm/brook_md_core.d.ts +1 -1
- package/dist/wasm/brook_md_core.js +1 -1
- package/dist/wasm/brook_md_core_bg.wasm +0 -0
- package/package.json +1 -1
package/dist/splice.d.ts
CHANGED
|
@@ -63,6 +63,41 @@ export declare function noteSplice(next: Block, prev: Block, keep: number): void
|
|
|
63
63
|
* @internal Renderer-only; not part of the public API.
|
|
64
64
|
*/
|
|
65
65
|
export declare function spliceKeep(from: Block, to: Block): number | undefined;
|
|
66
|
+
/**
|
|
67
|
+
* How an open fence's SPECULATIVE TAIL is painted. The frozen prefix is painted
|
|
68
|
+
* the same way either way — appended once, never touched again.
|
|
69
|
+
*
|
|
70
|
+
* - `"wavefront"` (the default) — the tail is ONE TEXT NODE holding the plain
|
|
71
|
+
* source, updated per patch through its character data. No elements are
|
|
72
|
+
* created, no markup is parsed, and nothing before it is disturbed, so a patch
|
|
73
|
+
* costs the browser a text measure instead of a style/layout pass over a
|
|
74
|
+
* freshly built span tree. Colour therefore arrives at the checkpoint — in
|
|
75
|
+
* practice one source line behind the stream head.
|
|
76
|
+
* - `"eager"` — the tail is its highlighted MARKUP, re-parsed on every patch.
|
|
77
|
+
* Colour is immediate, at the cost of rebuilding a span-dense subtree per
|
|
78
|
+
* frame. This is what the mirror always did.
|
|
79
|
+
*
|
|
80
|
+
* Both settle to the same bytes: the tail is thrown away and re-derived at every
|
|
81
|
+
* patch regardless, and the close-time markup comes from the frozen prefix plus
|
|
82
|
+
* one final tokenizer run (see hi-inc.ts).
|
|
83
|
+
*/
|
|
84
|
+
export type TailMode = "wavefront" | "eager";
|
|
85
|
+
/**
|
|
86
|
+
* The markup an open fence's `<code>` holds under `mode` — what the mirror
|
|
87
|
+
* paints incrementally, and what a caller that cannot mirror (a rebuild, the
|
|
88
|
+
* test-only full-rebuild reference) has to write in one go so the two agree.
|
|
89
|
+
*
|
|
90
|
+
* For `"eager"` that is hi-inc's markup verbatim. For `"wavefront"` the frozen
|
|
91
|
+
* prefix keeps its spans and the tail is the escaped source, which is exactly
|
|
92
|
+
* what the tail TEXT NODE serializes to.
|
|
93
|
+
*
|
|
94
|
+
* The wavefront tail comes from `st.text`/`st.c` rather than from `markup`,
|
|
95
|
+
* because the plain source is not recoverable from the tail's markup without
|
|
96
|
+
* re-parsing it. That is only sound while `markup` is the very string this state
|
|
97
|
+
* just produced, so a stale pair is handed back untouched rather than paired
|
|
98
|
+
* with a tail it does not belong to.
|
|
99
|
+
*/
|
|
100
|
+
export declare function incView(st: IncState, markup: string, mode: TailMode): string;
|
|
66
101
|
/**
|
|
67
102
|
* The live `<code>` of an OPEN code block, split the way hi-inc splits its
|
|
68
103
|
* markup: a **frozen** run of children (proven immutable — appended once and
|
|
@@ -71,15 +106,17 @@ export declare function spliceKeep(from: Block, to: Block): number | undefined;
|
|
|
71
106
|
*
|
|
72
107
|
* The two regions are NOT wrapped in elements — `frozenEnd` is simply the last
|
|
73
108
|
* child that belongs to the frozen run — so the resulting `innerHTML` is
|
|
74
|
-
* byte-identical to the `code.innerHTML =
|
|
75
|
-
* *count* differs (a splice can leave two adjacent text nodes where a
|
|
76
|
-
* parse would have made one), which serializes the same and is exactly
|
|
77
|
-
* browser does for any streamed append.
|
|
109
|
+
* byte-identical to the `code.innerHTML = incView(...)` this replaces. Only the
|
|
110
|
+
* node *count* differs (a splice can leave two adjacent text nodes where a
|
|
111
|
+
* one-shot parse would have made one), which serializes the same and is exactly
|
|
112
|
+
* what a browser does for any streamed append.
|
|
78
113
|
*/
|
|
79
114
|
export interface IncCode {
|
|
80
115
|
code: Element;
|
|
81
116
|
/** The language the mirror was built for; a change invalidates it. */
|
|
82
117
|
lang: string;
|
|
118
|
+
/** How the tail is painted; fixed for the mirror's whole life. */
|
|
119
|
+
mode: TailMode;
|
|
83
120
|
/** Last child of the frozen run — everything after it is the tail. */
|
|
84
121
|
frozenEnd: ChildNode | null;
|
|
85
122
|
/** Chars of `IncState.frozenHtml` already mirrored into the DOM. */
|
|
@@ -93,21 +130,31 @@ export interface IncCode {
|
|
|
93
130
|
* else on the streaming path combined. */
|
|
94
131
|
frozenEnd0: ChildNode | null;
|
|
95
132
|
frozenLen0: number;
|
|
96
|
-
/**
|
|
133
|
+
/** What is on screen after `frozenEnd`, so an unchanged tail is not rewritten:
|
|
134
|
+
* the tail MARKUP in `"eager"` mode, the tail SOURCE in `"wavefront"`. */
|
|
97
135
|
tail: string;
|
|
136
|
+
/** `"wavefront"` only: the single text node carrying the tail source. Created
|
|
137
|
+
* once per mirror and then only ever written through its character data —
|
|
138
|
+
* that is the whole cost saving, so it is never replaced while it lives. */
|
|
139
|
+
tailText: Text | null;
|
|
98
140
|
}
|
|
99
141
|
/** A fresh, empty mirror for a `<code>` that has nothing painted into it yet. */
|
|
100
|
-
export declare function newIncCode(code: Element, lang: string, st: IncState): IncCode;
|
|
142
|
+
export declare function newIncCode(code: Element, lang: string, st: IncState, mode: TailMode): IncCode;
|
|
101
143
|
/**
|
|
102
144
|
* Mirror hi-inc's frozen/tail split into a live `<code>`: append whatever the
|
|
103
|
-
* frozen prefix settled since the last patch, then
|
|
104
|
-
*
|
|
105
|
-
*
|
|
145
|
+
* frozen prefix settled since the last patch, then update the speculative tail.
|
|
146
|
+
* Returns false when the mirror cannot be trusted (see the invariants below) so
|
|
147
|
+
* the caller falls back to a full node rebuild.
|
|
106
148
|
*
|
|
107
149
|
* Cost per patch is |newly frozen| + |tail|. The frozen term sums, across the
|
|
108
150
|
* whole stream, to one pass over the final markup; the tail is bounded by
|
|
109
|
-
* hi-inc's CAP. That is what makes an open fence linear at the DOM, not just
|
|
110
|
-
*
|
|
151
|
+
* hi-inc's CAP. That is what makes an open fence linear at the DOM, not just at
|
|
152
|
+
* the tokenizer.
|
|
153
|
+
*
|
|
154
|
+
* In `"wavefront"` mode the tail term is also the CHEAPEST shape the DOM has:
|
|
155
|
+
* one character-data write, no elements created and no markup parsed. Only a
|
|
156
|
+
* checkpoint advance parses anything, and then only the newly frozen slice — so
|
|
157
|
+
* span creation drops from (tail spans × patches) to one pass over the block.
|
|
111
158
|
*/
|
|
112
159
|
export declare function paintIncCode(ic: IncCode, st: IncState, markup: string): boolean;
|
|
113
160
|
/**
|
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
|
|
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
|
|
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 {
|
|
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();
|
package/dist/types-core.d.ts
CHANGED
|
@@ -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
|
|
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
|
package/dist/types-react.d.ts
CHANGED
|
@@ -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`… —
|
|
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
|
|
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
|
|
26
|
-
* identity every call and remount on every patch. On any of those
|
|
27
|
-
* destroy and remount; `batch`/`highlightCode` still flow through to
|
|
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>`
|