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/dom.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import type { BrookClient } from "./client.js";
2
- import type { Block, BlockComponentProps, Decorator, RenderMetricsHook, UrlTransform } from "./types-core.js";
2
+ import type { Block, BlockComponentProps, Decorator, LinkClickInfo, RenderMetricsHook, UrlTransform } from "./types-core.js";
3
3
  /**
4
4
  * Framework-neutral DOM renderer for a {@link BrookClient}. Mounts the streaming
5
5
  * document into a container and keeps it in sync via direct DOM mutation,
@@ -37,6 +37,7 @@ export interface MountHandle {
37
37
  */
38
38
  openBlockId(): number | null;
39
39
  }
40
+ export type { LinkClickInfo };
40
41
  export type DomBlockComponent = (props: BlockComponentProps) => HTMLElement | string;
41
42
  /** Override map: capitalized block-kind / component-tag keys only. */
42
43
  export type DomComponents = Record<string, DomBlockComponent>;
@@ -72,9 +73,19 @@ export interface MountOptions {
72
73
  * An open block keeps a frozen prefix and re-tokenizes only its tail on each
73
74
  * patch, so this stays linear in the block's size. The settled markup is
74
75
  * byte-identical either way — only the tail's colours are provisional, and
75
- * they may shift as bytes arrive. Set `false` for the pre-0.27 behaviour.
76
+ * they may shift as bytes arrive.
77
+ *
78
+ * - `true` / omitted — `"wavefront"`.
79
+ * - `"wavefront"` — the frozen prefix is coloured; the speculative tail (in
80
+ * practice the line being typed) renders as plain text until its line
81
+ * completes. The tail is one text node updated through its character data,
82
+ * which is what keeps the option's cost at the DOM near zero.
83
+ * - `"eager"` — colour the tail on every patch too, by rebuilding its span
84
+ * markup each time. Sub-line colour latency, ~all of the option's
85
+ * style/layout cost.
86
+ * - `false` — the pre-0.27 behaviour: plain body until the fence closes.
76
87
  */
77
- streamingHighlight?: boolean;
88
+ streamingHighlight?: boolean | "wavefront" | "eager";
78
89
  /** Coalesce patches into one DOM write per animation frame. Default true. */
79
90
  batch?: boolean;
80
91
  /**
@@ -107,6 +118,17 @@ export interface MountOptions {
107
118
  * scheme. O(1) per attribute.
108
119
  */
109
120
  urlTransform?: UrlTransform;
121
+ /**
122
+ * Called when a rendered link is clicked (parity with the React `onLinkClick`
123
+ * prop). Exactly ONE `click` listener is added to the renderer root and the
124
+ * anchor is resolved from the event target — never a listener per anchor, so
125
+ * this costs nothing per block and the streaming path is untouched. The
126
+ * listener is removed by {@link MountHandle.destroy}.
127
+ *
128
+ * `event.preventDefault()` cancels the navigation. A still-streaming anchor
129
+ * (`data-brook-pending`, no href yet) is never reported.
130
+ */
131
+ onLinkClick?: (event: MouseEvent, link: LinkClickInfo) => void;
110
132
  /** Appended to the root's `className` (the `brook-md` class is always present). */
111
133
  className?: string;
112
134
  /** Set on the root element. */
package/dist/dom.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { highlightDeferred } from "./hi-defer.js";
2
2
  import { createInc, incHighlight, incSeed } from "./hi-inc.js";
3
3
  import { morph } from "./morph.js";
4
- import { newIncCode, paintIncCode, spliceHtml, spliceKeep } from "./splice.js";
4
+ import { incView, newIncCode, paintIncCode, spliceHtml, spliceKeep } from "./splice.js";
5
5
  import { blockProps, extractLang } from "./block-props.js";
6
6
  import { decorateSegments } from "./decorate.js";
7
7
  import { safeUrl } from "./url-safety.js";
@@ -28,18 +28,26 @@ const INTRINSIC_PX = {
28
28
  Html: 80,
29
29
  Component: 120
30
30
  };
31
+ function linkFromEvent(target, root) {
32
+ const el = target;
33
+ if (!el || typeof el.closest !== "function") return null;
34
+ const a = el.closest("a[href]");
35
+ if (!a || !root.contains(a) || a.hasAttribute("data-brook-pending")) return null;
36
+ return a;
37
+ }
31
38
  function mountBrookMarkdown(client, container, options = {}) {
32
39
  if (typeof document === "undefined") {
33
40
  throw new Error("mountBrookMarkdown is browser-only; call it after the DOM exists.");
34
41
  }
35
42
  const components = options.components && Object.keys(options.components).length > 0 ? options.components : void 0;
36
- const { sanitize, virtualize, stickToBottom, onRenderMetrics } = options;
43
+ const { sanitize, virtualize, stickToBottom, onRenderMetrics, onLinkClick } = options;
37
44
  const decorators = options.decorators && options.decorators.length > 0 ? options.decorators : void 0;
38
45
  const urlTransform = options.urlTransform;
39
46
  const hasInlineTransforms = !!decorators || !!urlTransform;
40
47
  const hasPerf = typeof performance !== "undefined";
41
48
  const highlightCode = options.highlightCode !== false && !components?.CodeBlock;
42
49
  const streamingHighlight = options.streamingHighlight !== false;
50
+ const tailMode = options.streamingHighlight === "eager" ? "eager" : "wavefront";
43
51
  const batch = options.batch !== false && typeof requestAnimationFrame === "function";
44
52
  const morphOpenBlocks = options.morphOpenBlocks === true;
45
53
  const fullRebuild = options.__fullRebuild === true;
@@ -58,6 +66,16 @@ function mountBrookMarkdown(client, container, options = {}) {
58
66
  anchor.style.scrollSnapAlign = "end";
59
67
  root.appendChild(anchor);
60
68
  }
69
+ const linkClickListener = onLinkClick ? (ev) => {
70
+ const a = linkFromEvent(ev.target, root);
71
+ if (!a) return;
72
+ onLinkClick(ev, {
73
+ href: a.getAttribute("href") ?? "",
74
+ text: a.textContent ?? "",
75
+ element: a
76
+ });
77
+ } : null;
78
+ if (linkClickListener) root.addEventListener("click", linkClickListener);
61
79
  const mounted = /* @__PURE__ */ new Map();
62
80
  let order = [];
63
81
  let dead = false;
@@ -510,10 +528,11 @@ function mountBrookMarkdown(client, container, options = {}) {
510
528
  }
511
529
  if (inc !== void 0) openMarkup = incHighlight(inc, codeText(b));
512
530
  }
531
+ const openView = openMarkup === null || mb.inc === void 0 ? openMarkup : incView(mb.inc, openMarkup, tailMode);
513
532
  const seed = !b.open && mb.inc !== void 0 ? incSeed(mb.inc, text, lang) : void 0;
514
533
  const run = text ? highlightDeferred(text, lang, seed) : null;
515
534
  if (!b.open) mb.inc = void 0;
516
- const highlighted = openMarkup ?? (run ? run.html : null);
535
+ const highlighted = openView ?? (run ? run.html : null);
517
536
  const block = document.createElement("div");
518
537
  block.className = "brook-code-block" + (b.open ? " brook-streaming" : "");
519
538
  const header = document.createElement("div");
@@ -572,9 +591,9 @@ function mountBrookMarkdown(client, container, options = {}) {
572
591
  pre.setAttribute("role", "region");
573
592
  pre.setAttribute("aria-label", `${lang} code`);
574
593
  const code = document.createElement("code");
575
- const ic = newIncCode(code, lang, st);
594
+ const ic = newIncCode(code, lang, st, tailMode);
576
595
  if (paintIncCode(ic, st, markup)) mb.codeInc = ic;
577
- else code.innerHTML = markup;
596
+ else code.innerHTML = incView(st, markup, tailMode);
578
597
  pre.appendChild(code);
579
598
  return pre;
580
599
  }
@@ -680,6 +699,7 @@ function mountBrookMarkdown(client, container, options = {}) {
680
699
  frame = 0;
681
700
  }
682
701
  unsubscribe();
702
+ if (linkClickListener) root.removeEventListener("click", linkClickListener);
683
703
  for (const mb of mounted.values()) {
684
704
  if (mb.highlight) {
685
705
  mb.highlight.cancel();
package/dist/element.d.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { type LinkClickInfo } from "./dom.js";
1
2
  /**
2
3
  * `<brook-markdown>` custom element — thin lifecycle glue over
3
4
  * {@link mountBrookMarkdown}. It owns no diffing: connect mounts the DOM
@@ -17,4 +18,6 @@
17
18
  * browser code.
18
19
  */
19
20
  export declare function parseTriBool(value: string | null): boolean | undefined;
21
+ /** The `onLinkClick` handler shape, mirroring `MountOptions.onLinkClick`. */
22
+ export type LinkClickHandler = (event: MouseEvent, link: LinkClickInfo) => void;
20
23
  export declare function defineBrookMarkdown(tag?: string): void;
package/dist/element.js CHANGED
@@ -23,17 +23,19 @@ const CONFIG_ATTRS = [
23
23
  "block-html",
24
24
  "retain-committed-html"
25
25
  ];
26
+ const MOUNT_ATTRS = ["stick-to-bottom", "virtualize"];
26
27
  function defineBrookMarkdown(tag = "brook-markdown") {
27
28
  if (typeof customElements === "undefined") return;
28
29
  if (customElements.get(tag)) return;
29
30
  class BrookMarkdownElement extends HTMLElement {
30
31
  static get observedAttributes() {
31
- return ["markdown", "src", "component-tags", "allow-schemes", ...CONFIG_ATTRS];
32
+ return ["markdown", "src", "component-tags", "allow-schemes", ...MOUNT_ATTRS, ...CONFIG_ATTRS];
32
33
  }
33
34
  #client = null;
34
35
  #ownsClient = false;
35
36
  #components = void 0;
36
37
  #sanitize = void 0;
38
+ #onLinkClick = void 0;
37
39
  #handle = null;
38
40
  #connected = false;
39
41
  // In-flight `src` fetch supersession. A self-owned client is REUSED across
@@ -71,6 +73,14 @@ function defineBrookMarkdown(tag = "brook-markdown") {
71
73
  this.#sanitize = value;
72
74
  if (this.#connected) this.#remount();
73
75
  }
76
+ get onLinkClick() {
77
+ return this.#onLinkClick;
78
+ }
79
+ set onLinkClick(value) {
80
+ if (value === this.#onLinkClick) return;
81
+ this.#onLinkClick = value;
82
+ if (this.#connected) this.#remount();
83
+ }
74
84
  // --- Self-owned-client methods -------------------------------------------
75
85
  append(chunk) {
76
86
  this.#cancelSrcStream();
@@ -95,6 +105,7 @@ function defineBrookMarkdown(tag = "brook-markdown") {
95
105
  this.#upgradeProperty("client");
96
106
  this.#upgradeProperty("components");
97
107
  this.#upgradeProperty("sanitize");
108
+ this.#upgradeProperty("onLinkClick");
98
109
  this.#mountIfReady();
99
110
  if (!this.#client || this.#ownsClient) {
100
111
  this.#resolveInitialContent();
@@ -109,6 +120,10 @@ function defineBrookMarkdown(tag = "brook-markdown") {
109
120
  }
110
121
  return;
111
122
  }
123
+ if (MOUNT_ATTRS.includes(name)) {
124
+ this.#remount();
125
+ return;
126
+ }
112
127
  if (this.#client && !this.#ownsClient) {
113
128
  console.warn(
114
129
  "<brook-markdown>: config attributes are ignored while a caller-owned `client` is set (ParserConfig is immutable per stream)."
@@ -194,7 +209,12 @@ function defineBrookMarkdown(tag = "brook-markdown") {
194
209
  if (!this.#connected || !this.#client || this.#handle) return;
195
210
  this.#handle = mountBrookMarkdown(this.#client, this, {
196
211
  components: this.#components,
197
- sanitize: this.#sanitize
212
+ sanitize: this.#sanitize,
213
+ onLinkClick: this.#onLinkClick,
214
+ // Renderer options read fresh on every mount (MOUNT_ATTRS remounts on a
215
+ // change); `undefined` = absent = the renderer's own default.
216
+ stickToBottom: parseTriBool(this.getAttribute("stick-to-bottom")),
217
+ virtualize: parseTriBool(this.getAttribute("virtualize"))
198
218
  });
199
219
  }
200
220
  // Destroy the current mount and remount against the current client+options.
package/dist/hi-inc.d.ts CHANGED
@@ -49,6 +49,19 @@ export interface OpenerScanState {
49
49
  export interface IncState {
50
50
  /** The language key this state's tables were chosen for. */
51
51
  readonly lang: string;
52
+ /** The unbounded openers of `lang` — resolved once, at {@link createInc}. */
53
+ readonly openers: Opener[];
54
+ /**
55
+ * May this state freeze a prefix at all?
56
+ *
57
+ * False for a language added through `registerLanguage`: the checkpoint rule
58
+ * is derived from knowing which of a table's forms can run past a newline, and
59
+ * a caller-supplied table does not say. Such a block is re-tokenized from the
60
+ * top on each patch (the {@link CAP} bound still applies) and freezes nothing,
61
+ * which is the one setting that is safe for ANY pattern list — with `c` at 0
62
+ * there is no frozen byte to be wrong, and the close-time run is unseeded.
63
+ */
64
+ readonly freeze: boolean;
52
65
  /** Settled through here: `[0, c)` of the source will never re-tokenize. */
53
66
  c: number;
54
67
  /** Markup for `[0, c)`. Always a byte-prefix of the block's final markup. */
package/dist/hi-inc.js CHANGED
@@ -1,4 +1,4 @@
1
- import { escapeHtml, stepHighlight } from "./hi.js";
1
+ import { escapeHtml, hasLang, isRegisteredLang, stepHighlight } from "./hi.js";
2
2
  const GAP = 3;
3
3
  const CAP = 8192;
4
4
  const CLIFF = 5e4;
@@ -43,6 +43,8 @@ const CSS_OPENERS = [
43
43
  { re: /"/y, cls: "str", end: '"', pred: { k: "char", ch: '"' } },
44
44
  { re: /'/y, cls: "str", end: "'", pred: { k: "char", ch: "'" } }
45
45
  ];
46
+ const C_OPENERS = [BLOCK_COMMENT];
47
+ const NO_OPENERS = [];
46
48
  const OPENERS = {
47
49
  js: JS_OPENERS,
48
50
  javascript: JS_OPENERS,
@@ -62,14 +64,34 @@ const OPENERS = {
62
64
  sql: SQL_OPENERS,
63
65
  html: HTML_OPENERS,
64
66
  xml: HTML_OPENERS,
65
- css: CSS_OPENERS
67
+ css: CSS_OPENERS,
68
+ java: C_OPENERS,
69
+ c: C_OPENERS,
70
+ cpp: C_OPENERS,
71
+ "c++": C_OPENERS,
72
+ cs: C_OPENERS,
73
+ csharp: C_OPENERS,
74
+ swift: C_OPENERS,
75
+ kt: C_OPENERS,
76
+ kotlin: C_OPENERS,
77
+ php: C_OPENERS,
78
+ rb: NO_OPENERS,
79
+ ruby: NO_OPENERS,
80
+ yaml: NO_OPENERS,
81
+ yml: NO_OPENERS,
82
+ toml: NO_OPENERS,
83
+ diff: NO_OPENERS,
84
+ dockerfile: NO_OPENERS
66
85
  };
67
86
  const GT_CHECKPOINT = /* @__PURE__ */ new Set(["html", "xml"]);
68
87
  function createInc(lang) {
69
88
  const key = lang.toLowerCase();
70
- if (!Object.prototype.hasOwnProperty.call(OPENERS, key)) return null;
89
+ const known = Object.prototype.hasOwnProperty.call(OPENERS, key) && !isRegisteredLang(key);
90
+ if (!known && !hasLang(key)) return null;
71
91
  return {
72
92
  lang: key,
93
+ openers: known ? OPENERS[key] : NO_OPENERS,
94
+ freeze: known,
73
95
  c: 0,
74
96
  frozenHtml: "",
75
97
  frozenRev: 0,
@@ -191,7 +213,7 @@ function plainTail(st, text) {
191
213
  return st.plain;
192
214
  }
193
215
  function rescan(st, text) {
194
- const openers = OPENERS[st.lang];
216
+ const openers = st.openers;
195
217
  const gt = GT_CHECKPOINT.has(st.lang);
196
218
  const limit = text.length - GAP;
197
219
  let cp = -1;
@@ -201,6 +223,7 @@ function rescan(st, text) {
201
223
  let liveLen = 0;
202
224
  const sink = (cls, start, end, outLen) => {
203
225
  if (liveAt >= 0) return;
226
+ if (!st.freeze) return;
204
227
  for (let i = 0; i < openers.length; i++) {
205
228
  const op = openers[i];
206
229
  op.re.lastIndex = start;
package/dist/hi.d.ts CHANGED
@@ -1,13 +1,27 @@
1
1
  /**
2
- * In-house syntax highlighter. Native RegExp only no Shiki, no Prism, no
3
- * Highlight.js. Covers the languages an LLM typically emits:
4
- * js/ts/tsx/jsx, rust, python, go, bash, json, html, css, sql. Unknown
5
- * languages fall through to plain escaped text. ~6KB minified.
2
+ * In-house syntax highlighter. Native RegExp only. Covers the languages an LLM
3
+ * typically emits: js/ts/tsx/jsx, rust, python, go, bash, json, html, css, sql,
4
+ * yaml, toml, diff, java, c/c++, c#, php, ruby, swift, kotlin and dockerfile.
5
+ * {@link registerLanguage} adds more at runtime; anything still unknown falls
6
+ * through to plain escaped text.
6
7
  *
7
8
  * Highlighting is per-block, runs once when the block closes. We never
8
9
  * highlight an open (streaming) block, which avoids re-highlighting the same
9
10
  * code on every chunk — the main perf win for streaming code.
10
11
  */
12
+ /** @internal Test-only: the built-in languages, without registered additions. */
13
+ export declare function __builtinLangs(): string[];
14
+ /** @internal Whether `lang` has a tokenizer table (built-in or registered). */
15
+ /**
16
+ * Whether `lang` currently resolves to a table supplied through
17
+ * {@link registerLanguage} — including a built-in name the caller replaced. The
18
+ * streaming highlighter asks this so a replaced built-in loses the frozen-prefix
19
+ * mode that was derived from the ORIGINAL table's forms.
20
+ */
21
+ export declare function isRegisteredLang(lang: string): boolean;
22
+ /** @internal Test-only: restore the built-in tables and forget registrations. */
23
+ export declare function __resetLanguages(): void;
24
+ export declare function hasLang(lang: string): boolean;
11
25
  export declare function escapeHtml(s: string): string;
12
26
  /**
13
27
  * The resumable tokenizer's cursor: `pos` is the next source index to consume,
@@ -50,4 +64,48 @@ export type TokenSink = (cls: string, start: number, end: number, outLen: number
50
64
  */
51
65
  export declare function stepHighlight(code: string, lang: string, state: HighlightState, chars: number, sink?: TokenSink): boolean;
52
66
  export declare function highlight(code: string, lang: string): string;
67
+ /** A language table for {@link registerLanguage}. */
68
+ export interface LanguageDef {
69
+ /**
70
+ * Ordered `[token class, sticky regex]` pairs. At each cursor position the
71
+ * FIRST pattern that matches wins, so put the longer forms first. Every regex
72
+ * must carry the `y` flag; it is matched against the whole source with
73
+ * `lastIndex` at the cursor, so `^`/`$` (with `m`) and lookaheads work, and
74
+ * `\b` sees the real neighbouring characters.
75
+ */
76
+ pats: Array<[token: string, re: RegExp]>;
77
+ /** Words an `ident` token is promoted to `kw` for. Stored as a `Set`. */
78
+ kw?: Iterable<string>;
79
+ }
80
+ /**
81
+ * Teach {@link highlight} a language, under one name or several aliases.
82
+ *
83
+ * registerLanguage(["hcl", "tf"], {
84
+ * pats: [
85
+ * ["com", /#.+/y],
86
+ * ["str", /"(?:\\.|[^"\\\n])*"/y],
87
+ * ["num", /\b\d+(?:\.\d+)?/y],
88
+ * ["ident", /\w+/y],
89
+ * ["pun", /[=[\]{}(),.]/y],
90
+ * ["ws", /\s+/y],
91
+ * ],
92
+ * kw: ["resource", "variable", "module", "output", "true", "false"],
93
+ * });
94
+ *
95
+ * Names are lower-cased, and registering a name that already exists REPLACES
96
+ * its table — including a built-in one, which is how a caller retunes `yaml` or
97
+ * `json` rather than living with this module's taste.
98
+ *
99
+ * Throws a `TypeError`, naming the offending pattern index, for a regex that is
100
+ * not sticky or a token class that has no style.
101
+ *
102
+ * A registered language highlights exactly like a built-in one when its block
103
+ * closes. While the block is still STREAMING it is re-tokenized from the top on
104
+ * each patch instead of growing a frozen prefix: the frozen-prefix rule needs to
105
+ * know which of a table's forms can run past a newline (a block comment, a
106
+ * multi-line string), and a caller-supplied table does not say. The size cap in
107
+ * the streaming path bounds that work; nothing about the settled markup differs.
108
+ */
109
+ export declare function registerLanguage(names: string | string[], def_: LanguageDef): void;
110
+ /** Every language {@link highlight} knows, built-in and registered alike. */
53
111
  export declare function supportedLangs(): string[];