brookmd 0.28.0 → 0.29.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 +39 -0
- package/README.md +100 -0
- package/dist/client.d.ts +139 -0
- package/dist/client.js +247 -1
- package/dist/element.js +3 -1
- package/dist/index.d.ts +2 -1
- package/dist/index.js +2 -1
- package/dist/server.js +1 -0
- package/dist/types-core.d.ts +16 -0
- package/dist/wasm/brook_md_core.d.ts +13 -0
- package/dist/wasm/brook_md_core.js +15 -0
- package/dist/wasm/brook_md_core_bg.wasm +0 -0
- package/dist/wasm/brook_md_core_bg.wasm.d.ts +1 -0
- package/dist/worker.js +1 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,45 @@ 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.29.0 — 2026-07-31
|
|
8
|
+
|
|
9
|
+
**Sublinear resources.** Total parse work has an Ω(n) floor and stays exactly
|
|
10
|
+
there — but retained memory and reopen latency don't have to be O(n), and now
|
|
11
|
+
they aren't. Requires `brookmd-core` 0.26.0. Both features are **web-path**
|
|
12
|
+
(WASM + TS client); the native bindings (React Native / Kotlin / Swift /
|
|
13
|
+
Flutter) share every parser-core improvement but consume `allBlocks()` and so
|
|
14
|
+
keep full retention — see the platform matrix in the root README.
|
|
15
|
+
|
|
16
|
+
### Added
|
|
17
|
+
|
|
18
|
+
- **Instant thread reopen — `getPersistable()` / `hydrate()` /
|
|
19
|
+
`sourceFingerprint()`.** A finished (or in-flight) stream can be persisted
|
|
20
|
+
as a small JSON envelope — the blocks `getSnapshot()` showed plus a source
|
|
21
|
+
length/hash and a `done` flag — and a fresh `BrookClient` hydrates from it
|
|
22
|
+
with **no worker, no WASM, no parsing**: 1 MB / 2,250 blocks re-streams in
|
|
23
|
+
~110 ms, hydrates in **~5.5 ms** (and a real browser reopen also skips
|
|
24
|
+
worker spin-up and WASM init). To *continue* a non-finalized thread, pass
|
|
25
|
+
the original source to `beginResume()`: the UI keeps showing hydrated
|
|
26
|
+
blocks while a background re-parse catches up — reusing the existing
|
|
27
|
+
divergence/adoption machinery, so every hydrated block keeps its exact id
|
|
28
|
+
across the swap and nothing visible remounts; appends stream live once
|
|
29
|
+
caught up. The envelope is a versioned **package** format
|
|
30
|
+
(`hydrateVersion`), deliberately not the wire contract; the README's
|
|
31
|
+
"Instant thread reopen" section documents the persistence and invalidation
|
|
32
|
+
contract.
|
|
33
|
+
- **`retainCommittedHtml` — the parser no longer hoards the rendered
|
|
34
|
+
document.** The core kept every committed block's HTML alive for
|
|
35
|
+
`allBlocks()` — which the web worker path never calls. The worker now
|
|
36
|
+
defaults the flag **off**: retained memory beyond the source buffer drops
|
|
37
|
+
to the open tail only (measured: **53–70% less total retention** on 1 MB
|
|
38
|
+
streams, with the flag-off overhead above the raw source staying under
|
|
39
|
+
300 bytes at every point). Patches are byte-identical either way — committed
|
|
40
|
+
blocks are emitted exactly once and never re-read. Rust-side default stays
|
|
41
|
+
ON, so native bindings and `allBlocks()` consumers are unaffected;
|
|
42
|
+
`renderToString` pins it on (it assembles from `allBlocks`). Set
|
|
43
|
+
`retainCommittedHtml: true` in `ParserConfig` if you drive the worker and
|
|
44
|
+
want `allBlocks` fidelity.
|
|
45
|
+
|
|
7
46
|
## 0.28.0 — 2026-07-31
|
|
8
47
|
|
|
9
48
|
**Browser-side linearity.** The parser and wire have been O(new bytes) per
|
package/README.md
CHANGED
|
@@ -586,6 +586,11 @@ class BrookClient {
|
|
|
586
586
|
whenReady(): Promise<void>; // resolves once WASM loaded; rejects on init failure
|
|
587
587
|
subscribe(listener: () => void): () => void; // React-friendly store
|
|
588
588
|
getSnapshot(): Block[]; // ordered current blocks
|
|
589
|
+
getPersistable(source?: string): PersistableSnapshot; // capture the rendered doc as JSON
|
|
590
|
+
hydrate( // restore it: no worker, no parse
|
|
591
|
+
snapshot: PersistableSnapshot,
|
|
592
|
+
opts?: { source?: string }, // source ⇒ a live thread can resume
|
|
593
|
+
): void;
|
|
589
594
|
outline(): { level: number; text: string; id: number }[]; // heading table-of-contents (works mid-stream)
|
|
590
595
|
toPlaintext(): string; // rendered document as plain text (search / summaries)
|
|
591
596
|
getMetrics(): { bytes, patches, totalParseMs, throughputKBs,
|
|
@@ -1284,6 +1289,101 @@ WASM `BrookParser`, native bindings, C ABI) keep byte-identical v1 wire by
|
|
|
1284
1289
|
default and can opt in with `setWireDelta(true)` — see
|
|
1285
1290
|
[`WIRE.md` §11](https://github.com/siinghd/brookmd/blob/main/crates/brookmd-core/WIRE.md).
|
|
1286
1291
|
|
|
1292
|
+
## Instant thread reopen — persist and hydrate
|
|
1293
|
+
|
|
1294
|
+
Reopening a long thread normally means re-feeding its whole source through the
|
|
1295
|
+
parser before the first paint: O(history) work between the click and the pixels,
|
|
1296
|
+
which is exactly when a chat UI feels frozen.
|
|
1297
|
+
|
|
1298
|
+
It is also unnecessary. A committed block is emitted **once** and is **final**,
|
|
1299
|
+
so the blocks a stream already produced are a complete description of the
|
|
1300
|
+
document — there is nothing to re-derive. Capture them, store them, restore them:
|
|
1301
|
+
|
|
1302
|
+
```ts
|
|
1303
|
+
// When the thread closes (or at any checkpoint):
|
|
1304
|
+
await db.put(threadId, JSON.stringify(client.getPersistable()));
|
|
1305
|
+
|
|
1306
|
+
// When it reopens:
|
|
1307
|
+
const client = new BrookClient();
|
|
1308
|
+
client.hydrate(JSON.parse(await db.get(threadId))); // no worker, no WASM, no parse
|
|
1309
|
+
// <BrookMarkdown client={client} /> paints the whole thread on the first frame.
|
|
1310
|
+
```
|
|
1311
|
+
|
|
1312
|
+
Measured on a 1 MB / ~2250-block thread: **~5 ms to hydrate vs ~110 ms to
|
|
1313
|
+
re-stream** it in 2 KB chunks (~51 ms even for a single-shot re-parse) — before
|
|
1314
|
+
counting the worker spin-up and WASM init a re-stream also pays. Hydration is
|
|
1315
|
+
O(blocks) of JSON handling; nothing parses.
|
|
1316
|
+
|
|
1317
|
+
Hydrated blocks are **ordinary committed blocks**: both renderers mount them
|
|
1318
|
+
through the same path as live ones, so a reopened thread renders byte-identical
|
|
1319
|
+
to the one the user closed.
|
|
1320
|
+
|
|
1321
|
+
### What to store, and when to throw it away
|
|
1322
|
+
|
|
1323
|
+
```ts
|
|
1324
|
+
interface PersistableSnapshot {
|
|
1325
|
+
hydrateVersion: number; // envelope version; hydrate() refuses what it can't read
|
|
1326
|
+
blocks: Block[]; // exactly what getSnapshot() showed
|
|
1327
|
+
sourceLength: number; // UTF-16 length of the markdown behind them
|
|
1328
|
+
sourceHash: string; // fingerprint of that markdown — a staleness check
|
|
1329
|
+
done: boolean; // had the stream been finalized?
|
|
1330
|
+
}
|
|
1331
|
+
```
|
|
1332
|
+
|
|
1333
|
+
Store it as JSON beside the thread. **Invalidate it when your source moves on**:
|
|
1334
|
+
hydrate deliberately does *not* verify the blocks against the source, so a stale
|
|
1335
|
+
snapshot would paint stale HTML. That is what `sourceHash` is for:
|
|
1336
|
+
|
|
1337
|
+
```ts
|
|
1338
|
+
import { sourceFingerprint } from "brookmd";
|
|
1339
|
+
|
|
1340
|
+
if (snapshot.sourceHash === sourceFingerprint(mySource)) {
|
|
1341
|
+
client.hydrate(snapshot); // instant
|
|
1342
|
+
} else {
|
|
1343
|
+
client.setContent(mySource, { done: true }); // the text changed — re-stream
|
|
1344
|
+
}
|
|
1345
|
+
```
|
|
1346
|
+
|
|
1347
|
+
Discard a snapshot whose `hydrateVersion` your build doesn't read, too. Either
|
|
1348
|
+
way a failed hydrate is safe to fall back from: a malformed or unreadable
|
|
1349
|
+
snapshot is rejected **whole**, with nothing written until every block has
|
|
1350
|
+
validated.
|
|
1351
|
+
|
|
1352
|
+
> This envelope is a brookmd **package** format, *not* the parser wire contract
|
|
1353
|
+
> ([`WIRE.md`](https://github.com/siinghd/brookmd/blob/main/crates/brookmd-core/WIRE.md)).
|
|
1354
|
+
> The two are versioned independently.
|
|
1355
|
+
|
|
1356
|
+
### Resuming a thread that was still streaming
|
|
1357
|
+
|
|
1358
|
+
A `done: false` snapshot was captured mid-stream. Pass the original markdown as
|
|
1359
|
+
`source` and the thread simply continues:
|
|
1360
|
+
|
|
1361
|
+
```ts
|
|
1362
|
+
client.hydrate(snapshot, { source: markdownSoFar });
|
|
1363
|
+
client.append(nextDelta); // or client.pipeFrom(await fetch("/api/chat"))
|
|
1364
|
+
```
|
|
1365
|
+
|
|
1366
|
+
The parser's internal state cannot be serialized (it is an `Rc` graph, not
|
|
1367
|
+
data), so continuing a document genuinely requires re-parsing what came before
|
|
1368
|
+
it — but that cost moves **off the critical path**. On the first `append`
|
|
1369
|
+
brookmd spins up the worker, re-feeds the source in the background, and keeps
|
|
1370
|
+
the hydrated blocks on screen throughout: the reader scrolls and reads while the
|
|
1371
|
+
parse catches up. New chunks need no buffering — they queue behind the re-feed
|
|
1372
|
+
and land the moment it completes (they arrive at model token speed; the parser
|
|
1373
|
+
catches up far faster). When the re-parse lands, unchanged blocks are adopted
|
|
1374
|
+
**by reference and by id**, so nothing re-renders and nothing remounts at the
|
|
1375
|
+
swap.
|
|
1376
|
+
|
|
1377
|
+
A `done: true` snapshot is terminal: it needs no `source`, no worker is ever
|
|
1378
|
+
created for it, and `append()` throws — a finished thread cannot be extended
|
|
1379
|
+
(`reset()` to start a new one). A `done: false` snapshot hydrated *without*
|
|
1380
|
+
`source` is view-only: it paints, but appending throws, because a document
|
|
1381
|
+
cannot be continued correctly without the text preceding it.
|
|
1382
|
+
|
|
1383
|
+
Nothing on the hydrate path touches a worker: `whenReady()` resolves immediately
|
|
1384
|
+
and `client.ready` is `true`, so a readiness gate never sits over a thread that
|
|
1385
|
+
is already on screen.
|
|
1386
|
+
|
|
1287
1387
|
## Security
|
|
1288
1388
|
|
|
1289
1389
|
brookmd is XSS-safe by default — its HTML output is meant to be injected via
|
package/dist/client.d.ts
CHANGED
|
@@ -26,6 +26,50 @@ export interface OutlineEntry {
|
|
|
26
26
|
/** Stable block id — usable as a scroll target / React key. */
|
|
27
27
|
id: number;
|
|
28
28
|
}
|
|
29
|
+
/**
|
|
30
|
+
* A self-contained, JSON-serializable capture of everything a stream currently
|
|
31
|
+
* renders — produced by {@link BrookClient.getPersistable}, restored by
|
|
32
|
+
* {@link BrookClient.hydrate}.
|
|
33
|
+
*
|
|
34
|
+
* Reopening a long thread normally re-feeds its whole source through the parser
|
|
35
|
+
* before the first paint (O(source), and it blocks the reopen). Persist this
|
|
36
|
+
* next to the thread instead and restoring it is pure JSON handling: the blocks
|
|
37
|
+
* go straight into the store and both renderers mount them as ordinary
|
|
38
|
+
* committed blocks — **no worker, no WASM, no parse**.
|
|
39
|
+
*
|
|
40
|
+
* NOT the wire contract. The parser↔renderer wire (WIRE.md) is a separate,
|
|
41
|
+
* separately-versioned boundary; this envelope is a brookmd *package* format
|
|
42
|
+
* that only {@link BrookClient.hydrate} consumes.
|
|
43
|
+
*/
|
|
44
|
+
export interface PersistableSnapshot {
|
|
45
|
+
/** Envelope version. {@link BrookClient.hydrate} refuses anything it does not
|
|
46
|
+
* know how to read, so a stored snapshot never half-restores. */
|
|
47
|
+
hydrateVersion: number;
|
|
48
|
+
/**
|
|
49
|
+
* The blocks exactly as {@link BrookClient.getSnapshot} showed them at
|
|
50
|
+
* capture: the committed history plus whatever tail was on screen. Plain
|
|
51
|
+
* JSON — a `Block` carries no hidden state (the renderers' splice bookkeeping
|
|
52
|
+
* lives in a WeakMap, never on the block), so `JSON.stringify` round-trips it.
|
|
53
|
+
*/
|
|
54
|
+
blocks: Block[];
|
|
55
|
+
/** UTF-16 length of the markdown that produced `blocks`. */
|
|
56
|
+
sourceLength: number;
|
|
57
|
+
/** {@link sourceFingerprint} of that markdown. A staleness check — "is the
|
|
58
|
+
* source I stored still the one these blocks came from?" — and deliberately
|
|
59
|
+
* nothing more: not a checksum, not security. */
|
|
60
|
+
sourceHash: string;
|
|
61
|
+
/** Whether the captured stream had been finalized. A `done` snapshot is a
|
|
62
|
+
* terminal document: it needs no source and can never be resumed. */
|
|
63
|
+
done: boolean;
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* 32-bit FNV-1a over a string's UTF-16 code units, as 8 hex digits. Backs
|
|
67
|
+
* {@link PersistableSnapshot.sourceHash}: cheap enough to run over a megabyte
|
|
68
|
+
* of markdown, sharp enough to notice that a stored source changed. Exported so
|
|
69
|
+
* a caller can make the same staleness decision brookmd does —
|
|
70
|
+
* `snap.sourceHash === sourceFingerprint(mySource)`. Not a security primitive.
|
|
71
|
+
*/
|
|
72
|
+
export declare function sourceFingerprint(source: string): string;
|
|
29
73
|
export declare function applyPatch(store: BlockStore, patch: Patch): void;
|
|
30
74
|
interface PoolWorker {
|
|
31
75
|
worker: WorkerLike;
|
|
@@ -171,6 +215,7 @@ export declare class BrookClient {
|
|
|
171
215
|
private recoveryBuffer;
|
|
172
216
|
private recoveredLen;
|
|
173
217
|
private recoveryAttempted;
|
|
218
|
+
private appendedAny;
|
|
174
219
|
private coalesce;
|
|
175
220
|
private rafHandle;
|
|
176
221
|
private finalizePending;
|
|
@@ -182,6 +227,12 @@ export declare class BrookClient {
|
|
|
182
227
|
/** Set by mergeStale when it had to compact a hole out of the view, so
|
|
183
228
|
* getSnapshot skips caching a view whose indices no longer track `base`. */
|
|
184
229
|
private mergeDropped;
|
|
230
|
+
private hydrated;
|
|
231
|
+
private hydratedDone;
|
|
232
|
+
private hydratedLength;
|
|
233
|
+
private hydratedHash;
|
|
234
|
+
private hydratedSource;
|
|
235
|
+
private resumed;
|
|
185
236
|
private appendedBytes;
|
|
186
237
|
private patchCount;
|
|
187
238
|
private totalParseMicros;
|
|
@@ -261,6 +312,10 @@ export declare class BrookClient {
|
|
|
261
312
|
*/
|
|
262
313
|
private settleRebind;
|
|
263
314
|
get ready(): boolean;
|
|
315
|
+
/** True while a hydrated document has never been handed to a parser: the
|
|
316
|
+
* store holds restored blocks and no worker exists. Cleared once the resume
|
|
317
|
+
* re-feed is issued (or the caller re-drives the whole document itself). */
|
|
318
|
+
private get hydrationPending();
|
|
264
319
|
/**
|
|
265
320
|
* The fatal error that killed this client's worker, or `null` if healthy.
|
|
266
321
|
*
|
|
@@ -329,6 +384,90 @@ export declare class BrookClient {
|
|
|
329
384
|
setContent(content: string, opts?: {
|
|
330
385
|
done?: boolean;
|
|
331
386
|
}): void;
|
|
387
|
+
/**
|
|
388
|
+
* Capture everything this client currently renders as a plain-JSON
|
|
389
|
+
* {@link PersistableSnapshot}: `JSON.stringify` it, store it beside the
|
|
390
|
+
* thread, and {@link hydrate} it back later to repaint with **no parse at
|
|
391
|
+
* all**. This is the persistence half of instant thread reopen.
|
|
392
|
+
*
|
|
393
|
+
* The committed wire is already a complete serialization — a committed block
|
|
394
|
+
* is emitted exactly once and is final (WIRE.md §2) — so there is nothing to
|
|
395
|
+
* re-derive: the snapshot IS the document. Cost here is one pass to
|
|
396
|
+
* fingerprint the source; cost on the way back in is a `Map` fill.
|
|
397
|
+
*
|
|
398
|
+
* @param source the markdown driven into this stream, used ONLY to compute
|
|
399
|
+
* {@link PersistableSnapshot.sourceHash}. Optional, because a client with
|
|
400
|
+
* `recovery` on (the default) already retains it, as does a
|
|
401
|
+
* `setContent`-driven one; required in the single configuration that holds
|
|
402
|
+
* neither — `recovery: false` plus manual `append()` — where omitting it
|
|
403
|
+
* throws rather than persisting a snapshot no one can check for staleness.
|
|
404
|
+
*/
|
|
405
|
+
getPersistable(source?: string): PersistableSnapshot;
|
|
406
|
+
/** The full driven document when this client happens to hold it: the recovery
|
|
407
|
+
* buffer is exactly that whenever `recovery` is on (the default), and
|
|
408
|
+
* setContent's baseline covers the recovery-off controlled-string mode.
|
|
409
|
+
* `null` means genuinely unknown — reachable only with recovery off AND
|
|
410
|
+
* manual appends. */
|
|
411
|
+
private retainedSource;
|
|
412
|
+
/**
|
|
413
|
+
* Restore a {@link PersistableSnapshot} into an untouched client. The blocks
|
|
414
|
+
* land in the store as ordinary committed blocks and `getSnapshot()` returns
|
|
415
|
+
* them immediately, so the first paint already has the whole document —
|
|
416
|
+
* **no worker is created, no WASM loads, nothing is parsed**. Reopening a
|
|
417
|
+
* thread costs O(blocks) of JSON handling instead of O(source) of parsing.
|
|
418
|
+
*
|
|
419
|
+
* Call it on a fresh client before anything is appended and, ideally, before
|
|
420
|
+
* the renderer mounts (hydrating an already-mounted client works — it
|
|
421
|
+
* notifies subscribers — but costs an extra render). Hydrating a client that
|
|
422
|
+
* already holds content throws.
|
|
423
|
+
*
|
|
424
|
+
* **Resuming a live thread.** A snapshot with `done: false` was still
|
|
425
|
+
* streaming. Pass `source` — the markdown behind the snapshot — and the first
|
|
426
|
+
* {@link append} rebuilds parser state in the background: the parser's
|
|
427
|
+
* internals are an `Rc` graph with no serialized form, so continuing a
|
|
428
|
+
* document genuinely requires re-parsing what came before it, but that cost
|
|
429
|
+
* moves OFF the critical path. The hydrated blocks stay on screen and the
|
|
430
|
+
* reader scrolls them while the worker catches up; new chunks queue behind the
|
|
431
|
+
* re-feed and land the moment it does. Without `source` the thread is
|
|
432
|
+
* view-only and appending throws — continuing a document correctly is not
|
|
433
|
+
* possible without the text that precedes it.
|
|
434
|
+
*
|
|
435
|
+
* **Hydration does not verify the blocks against the source.** The snapshot is
|
|
436
|
+
* trusted as produced; `sourceHash` exists so the CALLER can notice its stored
|
|
437
|
+
* source moved on and re-stream instead of painting stale HTML. The resume
|
|
438
|
+
* path re-checks it, lets the fresh parse win, and warns in dev.
|
|
439
|
+
*
|
|
440
|
+
* @throws if the envelope version is unknown, the snapshot is malformed, or
|
|
441
|
+
* this client already holds content. Validation completes before anything is
|
|
442
|
+
* written, so a rejected snapshot leaves the client exactly as it was.
|
|
443
|
+
*/
|
|
444
|
+
hydrate(snapshot: PersistableSnapshot, opts?: {
|
|
445
|
+
source?: string;
|
|
446
|
+
}): void;
|
|
447
|
+
/**
|
|
448
|
+
* The first content-bearing op on a hydrated thread: give the parser back the
|
|
449
|
+
* state it could not be handed.
|
|
450
|
+
*
|
|
451
|
+
* There is no way around re-parsing — the core keeps `Rc` graphs with no
|
|
452
|
+
* `Deserialize` — but every part of that cost sits off the critical path. The
|
|
453
|
+
* hydrated blocks are already painted and STAY painted: `softReset` preserves
|
|
454
|
+
* them exactly as the setContent divergence swap does, so the reader keeps
|
|
455
|
+
* reading and scrolling while the worker chews through the history on its own
|
|
456
|
+
* thread. The caller's new chunks need no buffer of ours — `postMessage` is
|
|
457
|
+
* FIFO per worker, so they queue behind the re-feed and are parsed the instant
|
|
458
|
+
* it catches up. When the re-parse's patch lands, `mergeStale` adopts every
|
|
459
|
+
* unchanged block BY REFERENCE (same object, same id), so the swap re-renders
|
|
460
|
+
* and remounts nothing, and the live tail streams on from there.
|
|
461
|
+
*/
|
|
462
|
+
private beginResume;
|
|
463
|
+
/** The resume re-feed is no longer wanted: either it has just been issued, or
|
|
464
|
+
* the caller took over by driving the whole document itself. Releases the
|
|
465
|
+
* retained source so one document, not two, stays in memory. */
|
|
466
|
+
private retireHydration;
|
|
467
|
+
/** Drop every trace of a hydrate. An explicit reset() is a brand-new
|
|
468
|
+
* document, so a restored view must not keep intercepting appends or re-feed
|
|
469
|
+
* a source that is no longer the one being streamed. */
|
|
470
|
+
private clearHydration;
|
|
332
471
|
reset(): void;
|
|
333
472
|
/**
|
|
334
473
|
* setContent's divergence reset: rebuild the parser exactly like {@link reset},
|
package/dist/client.js
CHANGED
|
@@ -4,6 +4,20 @@ import { noteSplice } from "./splice.js";
|
|
|
4
4
|
function emptyBlockStore() {
|
|
5
5
|
return { committed: /* @__PURE__ */ new Map(), committedOrder: [], active: [], snapshot: [] };
|
|
6
6
|
}
|
|
7
|
+
const HYDRATE_VERSION = 1;
|
|
8
|
+
function sourceFingerprint(source) {
|
|
9
|
+
let h = 2166136261;
|
|
10
|
+
for (let i = 0; i < source.length; i++) {
|
|
11
|
+
h ^= source.charCodeAt(i);
|
|
12
|
+
h = Math.imul(h, 16777619);
|
|
13
|
+
}
|
|
14
|
+
return (h >>> 0).toString(16).padStart(8, "0");
|
|
15
|
+
}
|
|
16
|
+
function isPersistedBlock(b) {
|
|
17
|
+
if (typeof b !== "object" || b === null) return false;
|
|
18
|
+
const x = b;
|
|
19
|
+
return typeof x.id === "number" && Number.isFinite(x.id) && typeof x.html === "string" && typeof x.start === "number" && typeof x.end === "number" && typeof x.open === "boolean" && typeof x.speculative === "boolean" && typeof x.kind === "object" && x.kind !== null && typeof x.kind.type === "string";
|
|
20
|
+
}
|
|
7
21
|
function htmlToText(html) {
|
|
8
22
|
return html.replace(/<[^>]*>/g, " ").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, '"').replace(/'/g, "'").replace(/&/g, "&").replace(/\s+/g, " ").trim();
|
|
9
23
|
}
|
|
@@ -344,6 +358,12 @@ class BrookClient {
|
|
|
344
358
|
// same-length-or-shorter replacement the growth check misses). Also cleared on
|
|
345
359
|
// an explicit caller reset().
|
|
346
360
|
recoveryAttempted = false;
|
|
361
|
+
// Whether any chunk has been appended in this generation. Read only by
|
|
362
|
+
// getPersistable's source fallback, to tell "nothing was ever driven" (whose
|
|
363
|
+
// source is the empty document) from "driven, but this client did not retain
|
|
364
|
+
// it" — the one configuration, recovery off plus manual appends, where the
|
|
365
|
+
// source is genuinely unknown and the caller must supply it.
|
|
366
|
+
appendedAny = false;
|
|
347
367
|
// Opt-in rAF coalescing (see constructor `coalesce`). When on AND
|
|
348
368
|
// requestAnimationFrame exists, intra-frame emit()s collapse into ONE
|
|
349
369
|
// rAF-scheduled flush to listeners — the React useSyncExternalStore path then
|
|
@@ -388,6 +408,24 @@ class BrookClient {
|
|
|
388
408
|
/** Set by mergeStale when it had to compact a hole out of the view, so
|
|
389
409
|
* getSnapshot skips caching a view whose indices no longer track `base`. */
|
|
390
410
|
mergeDropped = false;
|
|
411
|
+
// --- Hydration (see getPersistable / hydrate) ---
|
|
412
|
+
// Set by hydrate(): this store was FILLED FROM JSON, never parsed, so no
|
|
413
|
+
// worker has ever seen this document. Sticky until reset() — a resumed client
|
|
414
|
+
// is still "a hydrated client" as far as getPersistable is concerned.
|
|
415
|
+
hydrated = false;
|
|
416
|
+
// The restored envelope's own fields, kept so the resume can re-check
|
|
417
|
+
// staleness and so getPersistable() can re-emit an untouched snapshot exactly.
|
|
418
|
+
hydratedDone = false;
|
|
419
|
+
hydratedLength = 0;
|
|
420
|
+
hydratedHash = "";
|
|
421
|
+
// The original markdown behind a hydrated snapshot (hydrate's `source`), held
|
|
422
|
+
// ONLY until the resume re-feed consumes it. Null for a `done` snapshot, which
|
|
423
|
+
// is terminal and needs none.
|
|
424
|
+
hydratedSource = null;
|
|
425
|
+
// Latch: the resume re-feed has been issued, or the caller took over by
|
|
426
|
+
// driving the whole document itself. From here append()/finalize() behave
|
|
427
|
+
// exactly as on any other client.
|
|
428
|
+
resumed = false;
|
|
391
429
|
// Perf
|
|
392
430
|
appendedBytes = 0;
|
|
393
431
|
patchCount = 0;
|
|
@@ -492,8 +530,15 @@ class BrookClient {
|
|
|
492
530
|
this.resetParser();
|
|
493
531
|
}
|
|
494
532
|
get ready() {
|
|
533
|
+
if (this.hydrationPending) return true;
|
|
495
534
|
return this.pw?.ready ?? false;
|
|
496
535
|
}
|
|
536
|
+
/** True while a hydrated document has never been handed to a parser: the
|
|
537
|
+
* store holds restored blocks and no worker exists. Cleared once the resume
|
|
538
|
+
* re-feed is issued (or the caller re-drives the whole document itself). */
|
|
539
|
+
get hydrationPending() {
|
|
540
|
+
return this.hydrated && !this.resumed;
|
|
541
|
+
}
|
|
497
542
|
/**
|
|
498
543
|
* The fatal error that killed this client's worker, or `null` if healthy.
|
|
499
544
|
*
|
|
@@ -508,6 +553,7 @@ class BrookClient {
|
|
|
508
553
|
return this.failedError;
|
|
509
554
|
}
|
|
510
555
|
whenReady() {
|
|
556
|
+
if (this.hydrationPending) return Promise.resolve();
|
|
511
557
|
const pw = this.ensureAcquired();
|
|
512
558
|
return this.pool.whenWorkerReady(pw);
|
|
513
559
|
}
|
|
@@ -520,9 +566,11 @@ class BrookClient {
|
|
|
520
566
|
return this.config;
|
|
521
567
|
}
|
|
522
568
|
append(chunk) {
|
|
569
|
+
if (this.hydrationPending) this.beginResume();
|
|
523
570
|
const pw = this.ensureAcquired();
|
|
524
571
|
this.settleRebind();
|
|
525
572
|
if (this.firstAppendMs === 0) this.firstAppendMs = performance.now();
|
|
573
|
+
this.appendedAny = true;
|
|
526
574
|
if (this.recovery) {
|
|
527
575
|
this.recoveryBuffer += chunk;
|
|
528
576
|
if (this.recoveryAttempted && this.recoveryBuffer.length > this.recoveredLen) {
|
|
@@ -532,6 +580,10 @@ class BrookClient {
|
|
|
532
580
|
this.pool.send(pw, { type: "append", streamId: this.streamId, chunk, config: this.firstConfig(), epoch: this.epoch });
|
|
533
581
|
}
|
|
534
582
|
finalize() {
|
|
583
|
+
if (this.hydrationPending) {
|
|
584
|
+
if (this.hydratedDone) return;
|
|
585
|
+
this.beginResume();
|
|
586
|
+
}
|
|
535
587
|
const pw = this.ensureAcquired();
|
|
536
588
|
this.settleRebind();
|
|
537
589
|
this.finalizePending = true;
|
|
@@ -633,12 +685,14 @@ class BrookClient {
|
|
|
633
685
|
if (!this.contentDone && content.startsWith(this.lastContent)) {
|
|
634
686
|
if (this.lastContent === "" && content.length > 0 && this.getSnapshot().length > 0) {
|
|
635
687
|
this.softReset(this.getSnapshot());
|
|
688
|
+
this.retireHydration();
|
|
636
689
|
}
|
|
637
690
|
this.append(content.slice(this.lastContent.length));
|
|
638
691
|
} else {
|
|
639
692
|
const displayed = this.getSnapshot();
|
|
640
693
|
if (content.length > 0 && displayed.length > 0) this.softReset(displayed);
|
|
641
694
|
else this.reset();
|
|
695
|
+
this.retireHydration();
|
|
642
696
|
this.append(content);
|
|
643
697
|
}
|
|
644
698
|
this.lastContent = content;
|
|
@@ -649,6 +703,195 @@ class BrookClient {
|
|
|
649
703
|
this.contentDone = true;
|
|
650
704
|
}
|
|
651
705
|
}
|
|
706
|
+
/**
|
|
707
|
+
* Capture everything this client currently renders as a plain-JSON
|
|
708
|
+
* {@link PersistableSnapshot}: `JSON.stringify` it, store it beside the
|
|
709
|
+
* thread, and {@link hydrate} it back later to repaint with **no parse at
|
|
710
|
+
* all**. This is the persistence half of instant thread reopen.
|
|
711
|
+
*
|
|
712
|
+
* The committed wire is already a complete serialization — a committed block
|
|
713
|
+
* is emitted exactly once and is final (WIRE.md §2) — so there is nothing to
|
|
714
|
+
* re-derive: the snapshot IS the document. Cost here is one pass to
|
|
715
|
+
* fingerprint the source; cost on the way back in is a `Map` fill.
|
|
716
|
+
*
|
|
717
|
+
* @param source the markdown driven into this stream, used ONLY to compute
|
|
718
|
+
* {@link PersistableSnapshot.sourceHash}. Optional, because a client with
|
|
719
|
+
* `recovery` on (the default) already retains it, as does a
|
|
720
|
+
* `setContent`-driven one; required in the single configuration that holds
|
|
721
|
+
* neither — `recovery: false` plus manual `append()` — where omitting it
|
|
722
|
+
* throws rather than persisting a snapshot no one can check for staleness.
|
|
723
|
+
*/
|
|
724
|
+
getPersistable(source) {
|
|
725
|
+
const blocks = this.getSnapshot();
|
|
726
|
+
if (source === void 0 && this.hydrationPending) {
|
|
727
|
+
return {
|
|
728
|
+
hydrateVersion: HYDRATE_VERSION,
|
|
729
|
+
blocks,
|
|
730
|
+
sourceLength: this.hydratedLength,
|
|
731
|
+
sourceHash: this.hydratedHash,
|
|
732
|
+
done: this.hydratedDone
|
|
733
|
+
};
|
|
734
|
+
}
|
|
735
|
+
const src = source ?? this.retainedSource();
|
|
736
|
+
if (src === null) {
|
|
737
|
+
throw new Error(
|
|
738
|
+
"brookmd: getPersistable() needs the source markdown to fingerprint, and this client retains none (constructed with `recovery: false` and driven with append()). Pass it explicitly: client.getPersistable(source)."
|
|
739
|
+
);
|
|
740
|
+
}
|
|
741
|
+
return {
|
|
742
|
+
hydrateVersion: HYDRATE_VERSION,
|
|
743
|
+
blocks,
|
|
744
|
+
sourceLength: src.length,
|
|
745
|
+
sourceHash: sourceFingerprint(src),
|
|
746
|
+
done: this.contentDone
|
|
747
|
+
};
|
|
748
|
+
}
|
|
749
|
+
/** The full driven document when this client happens to hold it: the recovery
|
|
750
|
+
* buffer is exactly that whenever `recovery` is on (the default), and
|
|
751
|
+
* setContent's baseline covers the recovery-off controlled-string mode.
|
|
752
|
+
* `null` means genuinely unknown — reachable only with recovery off AND
|
|
753
|
+
* manual appends. */
|
|
754
|
+
retainedSource() {
|
|
755
|
+
if (this.recovery) return this.recoveryBuffer;
|
|
756
|
+
if (this.lastContent.length > 0) return this.lastContent;
|
|
757
|
+
if (!this.appendedAny) return "";
|
|
758
|
+
return null;
|
|
759
|
+
}
|
|
760
|
+
/**
|
|
761
|
+
* Restore a {@link PersistableSnapshot} into an untouched client. The blocks
|
|
762
|
+
* land in the store as ordinary committed blocks and `getSnapshot()` returns
|
|
763
|
+
* them immediately, so the first paint already has the whole document —
|
|
764
|
+
* **no worker is created, no WASM loads, nothing is parsed**. Reopening a
|
|
765
|
+
* thread costs O(blocks) of JSON handling instead of O(source) of parsing.
|
|
766
|
+
*
|
|
767
|
+
* Call it on a fresh client before anything is appended and, ideally, before
|
|
768
|
+
* the renderer mounts (hydrating an already-mounted client works — it
|
|
769
|
+
* notifies subscribers — but costs an extra render). Hydrating a client that
|
|
770
|
+
* already holds content throws.
|
|
771
|
+
*
|
|
772
|
+
* **Resuming a live thread.** A snapshot with `done: false` was still
|
|
773
|
+
* streaming. Pass `source` — the markdown behind the snapshot — and the first
|
|
774
|
+
* {@link append} rebuilds parser state in the background: the parser's
|
|
775
|
+
* internals are an `Rc` graph with no serialized form, so continuing a
|
|
776
|
+
* document genuinely requires re-parsing what came before it, but that cost
|
|
777
|
+
* moves OFF the critical path. The hydrated blocks stay on screen and the
|
|
778
|
+
* reader scrolls them while the worker catches up; new chunks queue behind the
|
|
779
|
+
* re-feed and land the moment it does. Without `source` the thread is
|
|
780
|
+
* view-only and appending throws — continuing a document correctly is not
|
|
781
|
+
* possible without the text that precedes it.
|
|
782
|
+
*
|
|
783
|
+
* **Hydration does not verify the blocks against the source.** The snapshot is
|
|
784
|
+
* trusted as produced; `sourceHash` exists so the CALLER can notice its stored
|
|
785
|
+
* source moved on and re-stream instead of painting stale HTML. The resume
|
|
786
|
+
* path re-checks it, lets the fresh parse win, and warns in dev.
|
|
787
|
+
*
|
|
788
|
+
* @throws if the envelope version is unknown, the snapshot is malformed, or
|
|
789
|
+
* this client already holds content. Validation completes before anything is
|
|
790
|
+
* written, so a rejected snapshot leaves the client exactly as it was.
|
|
791
|
+
*/
|
|
792
|
+
hydrate(snapshot, opts) {
|
|
793
|
+
if (this.hydrated || this.pw !== null || this.getSnapshot().length > 0) {
|
|
794
|
+
throw new Error(
|
|
795
|
+
"brookmd: hydrate() must be called on an untouched client, before any append()/setContent()/finalize(). Construct a new BrookClient (or reset() this one)."
|
|
796
|
+
);
|
|
797
|
+
}
|
|
798
|
+
if (typeof snapshot !== "object" || snapshot === null) {
|
|
799
|
+
throw new Error("brookmd: hydrate() expects a PersistableSnapshot object.");
|
|
800
|
+
}
|
|
801
|
+
if (snapshot.hydrateVersion !== HYDRATE_VERSION) {
|
|
802
|
+
throw new Error(
|
|
803
|
+
`brookmd: cannot hydrate a version ${String(snapshot.hydrateVersion)} snapshot \u2014 this build reads version ${HYDRATE_VERSION}. Discard it and re-stream the source.`
|
|
804
|
+
);
|
|
805
|
+
}
|
|
806
|
+
if (!Array.isArray(snapshot.blocks) || typeof snapshot.done !== "boolean" || typeof snapshot.sourceHash !== "string" || typeof snapshot.sourceLength !== "number" || !Number.isFinite(snapshot.sourceLength) || snapshot.sourceLength < 0) {
|
|
807
|
+
throw new Error(
|
|
808
|
+
"brookmd: malformed PersistableSnapshot (bad blocks / done / sourceHash / sourceLength)."
|
|
809
|
+
);
|
|
810
|
+
}
|
|
811
|
+
const committed = /* @__PURE__ */ new Map();
|
|
812
|
+
const committedOrder = new Array(snapshot.blocks.length);
|
|
813
|
+
for (let i = 0; i < snapshot.blocks.length; i++) {
|
|
814
|
+
const b = snapshot.blocks[i];
|
|
815
|
+
if (!isPersistedBlock(b)) {
|
|
816
|
+
throw new Error(
|
|
817
|
+
`brookmd: malformed PersistableSnapshot \u2014 block at index ${i} is not a Block.`
|
|
818
|
+
);
|
|
819
|
+
}
|
|
820
|
+
if (committed.has(b.id)) {
|
|
821
|
+
throw new Error(`brookmd: malformed PersistableSnapshot \u2014 duplicate block id ${b.id}.`);
|
|
822
|
+
}
|
|
823
|
+
committedOrder[i] = b.id;
|
|
824
|
+
committed.set(b.id, b);
|
|
825
|
+
}
|
|
826
|
+
this.store = { committed, committedOrder, active: [], snapshot: snapshot.blocks.slice() };
|
|
827
|
+
this.hydrated = true;
|
|
828
|
+
this.hydratedDone = snapshot.done;
|
|
829
|
+
this.hydratedLength = snapshot.sourceLength;
|
|
830
|
+
this.hydratedHash = snapshot.sourceHash;
|
|
831
|
+
this.hydratedSource = snapshot.done ? null : opts?.source ?? null;
|
|
832
|
+
if (this.hydratedSource !== null) this.lastContent = this.hydratedSource;
|
|
833
|
+
this.contentDone = snapshot.done;
|
|
834
|
+
this.emit(true);
|
|
835
|
+
}
|
|
836
|
+
/**
|
|
837
|
+
* The first content-bearing op on a hydrated thread: give the parser back the
|
|
838
|
+
* state it could not be handed.
|
|
839
|
+
*
|
|
840
|
+
* There is no way around re-parsing — the core keeps `Rc` graphs with no
|
|
841
|
+
* `Deserialize` — but every part of that cost sits off the critical path. The
|
|
842
|
+
* hydrated blocks are already painted and STAY painted: `softReset` preserves
|
|
843
|
+
* them exactly as the setContent divergence swap does, so the reader keeps
|
|
844
|
+
* reading and scrolling while the worker chews through the history on its own
|
|
845
|
+
* thread. The caller's new chunks need no buffer of ours — `postMessage` is
|
|
846
|
+
* FIFO per worker, so they queue behind the re-feed and are parsed the instant
|
|
847
|
+
* it catches up. When the re-parse's patch lands, `mergeStale` adopts every
|
|
848
|
+
* unchanged block BY REFERENCE (same object, same id), so the swap re-renders
|
|
849
|
+
* and remounts nothing, and the live tail streams on from there.
|
|
850
|
+
*/
|
|
851
|
+
beginResume() {
|
|
852
|
+
if (this.hydratedLength === 0 && this.store.snapshot.length === 0) {
|
|
853
|
+
this.retireHydration();
|
|
854
|
+
return;
|
|
855
|
+
}
|
|
856
|
+
if (this.hydratedDone) {
|
|
857
|
+
throw new Error(
|
|
858
|
+
"brookmd: this client was hydrated from a FINALIZED snapshot (done: true), and a completed thread cannot be appended to. Call reset() to start a new stream, or hydrate a `done: false` snapshot with its `source` to resume one."
|
|
859
|
+
);
|
|
860
|
+
}
|
|
861
|
+
const source = this.hydratedSource;
|
|
862
|
+
if (source === null) {
|
|
863
|
+
throw new Error(
|
|
864
|
+
"brookmd: cannot append to a hydrated client without its source. Continuing a document means re-parsing the text that came before it \u2014 pass it at hydrate time: client.hydrate(snapshot, { source })."
|
|
865
|
+
);
|
|
866
|
+
}
|
|
867
|
+
this.retireHydration();
|
|
868
|
+
if (typeof process !== "undefined" && process.env.NODE_ENV !== "production" && (source.length !== this.hydratedLength || sourceFingerprint(source) !== this.hydratedHash)) {
|
|
869
|
+
warnOnce(
|
|
870
|
+
"hydrate-stale",
|
|
871
|
+
"brookmd: the source passed to hydrate() does not match the snapshot's fingerprint, so the resume re-parse will replace the hydrated blocks. Compare `snapshot.sourceHash` with `sourceFingerprint(source)` before hydrating and re-stream when they differ."
|
|
872
|
+
);
|
|
873
|
+
}
|
|
874
|
+
this.softReset(this.getSnapshot());
|
|
875
|
+
this.append(source);
|
|
876
|
+
}
|
|
877
|
+
/** The resume re-feed is no longer wanted: either it has just been issued, or
|
|
878
|
+
* the caller took over by driving the whole document itself. Releases the
|
|
879
|
+
* retained source so one document, not two, stays in memory. */
|
|
880
|
+
retireHydration() {
|
|
881
|
+
this.resumed = true;
|
|
882
|
+
this.hydratedSource = null;
|
|
883
|
+
}
|
|
884
|
+
/** Drop every trace of a hydrate. An explicit reset() is a brand-new
|
|
885
|
+
* document, so a restored view must not keep intercepting appends or re-feed
|
|
886
|
+
* a source that is no longer the one being streamed. */
|
|
887
|
+
clearHydration() {
|
|
888
|
+
this.hydrated = false;
|
|
889
|
+
this.resumed = false;
|
|
890
|
+
this.hydratedDone = false;
|
|
891
|
+
this.hydratedSource = null;
|
|
892
|
+
this.hydratedLength = 0;
|
|
893
|
+
this.hydratedHash = "";
|
|
894
|
+
}
|
|
652
895
|
reset() {
|
|
653
896
|
const hadContent = this.getSnapshot().length > 0;
|
|
654
897
|
this.staleSnapshot = null;
|
|
@@ -657,6 +900,7 @@ class BrookClient {
|
|
|
657
900
|
this.failedError = null;
|
|
658
901
|
this.recoveryAttempted = false;
|
|
659
902
|
this.pendingRebind = false;
|
|
903
|
+
this.clearHydration();
|
|
660
904
|
this.resetParser();
|
|
661
905
|
if (hadContent) this.emit(true);
|
|
662
906
|
}
|
|
@@ -712,6 +956,7 @@ class BrookClient {
|
|
|
712
956
|
this.firstAppendMs = 0;
|
|
713
957
|
this.retainedBytes = 0;
|
|
714
958
|
this.wasmMemoryBytes = 0;
|
|
959
|
+
this.appendedAny = false;
|
|
715
960
|
this.lastContent = "";
|
|
716
961
|
this.contentDone = false;
|
|
717
962
|
this.recoveryBuffer = "";
|
|
@@ -1035,5 +1280,6 @@ export {
|
|
|
1035
1280
|
__resetDefaultPool,
|
|
1036
1281
|
applyPatch,
|
|
1037
1282
|
emptyBlockStore,
|
|
1038
|
-
getDefaultPool
|
|
1283
|
+
getDefaultPool,
|
|
1284
|
+
sourceFingerprint
|
|
1039
1285
|
};
|
package/dist/element.js
CHANGED
|
@@ -20,7 +20,8 @@ const CONFIG_ATTRS = [
|
|
|
20
20
|
"soft-breaks",
|
|
21
21
|
"a11y",
|
|
22
22
|
"unsafe-html",
|
|
23
|
-
"block-html"
|
|
23
|
+
"block-html",
|
|
24
|
+
"retain-committed-html"
|
|
24
25
|
];
|
|
25
26
|
function defineBrookMarkdown(tag = "brook-markdown") {
|
|
26
27
|
if (typeof customElements === "undefined") return;
|
|
@@ -162,6 +163,7 @@ function defineBrookMarkdown(tag = "brook-markdown") {
|
|
|
162
163
|
set("a11y", "a11y");
|
|
163
164
|
set("unsafe-html", "unsafeHtml");
|
|
164
165
|
set("block-html", "blockHtml");
|
|
166
|
+
set("retain-committed-html", "retainCommittedHtml");
|
|
165
167
|
const tags = this.getAttribute("component-tags");
|
|
166
168
|
if (tags !== null) {
|
|
167
169
|
const list = tags.split(/[\s,]+/).filter(Boolean);
|
package/dist/index.d.ts
CHANGED
|
@@ -15,7 +15,8 @@
|
|
|
15
15
|
* // ... wherever your tokens land: client.append(deltaText);
|
|
16
16
|
* client.finalize();
|
|
17
17
|
*/
|
|
18
|
-
export { BrookClient, BrookPool, getDefaultPool } from "./client.js";
|
|
18
|
+
export { BrookClient, BrookPool, getDefaultPool, sourceFingerprint } from "./client.js";
|
|
19
|
+
export type { PersistableSnapshot } from "./client.js";
|
|
19
20
|
export { BrookMarkdown, useBrookStream, useBrookMarkdownString } from "./react.js";
|
|
20
21
|
export { highlight, supportedLangs } from "./hi.js";
|
|
21
22
|
export { htmlToReact, parseTrustedHtml, safeUrl, wrapLink } from "./html-to-react.js";
|
package/dist/index.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { BrookClient, BrookPool, getDefaultPool } from "./client.js";
|
|
1
|
+
import { BrookClient, BrookPool, getDefaultPool, sourceFingerprint } from "./client.js";
|
|
2
2
|
import { BrookMarkdown, useBrookStream, useBrookMarkdownString } from "./react.js";
|
|
3
3
|
import { highlight, supportedLangs } from "./hi.js";
|
|
4
4
|
import { htmlToReact, parseTrustedHtml, safeUrl, wrapLink } from "./html-to-react.js";
|
|
@@ -11,6 +11,7 @@ export {
|
|
|
11
11
|
htmlToReact,
|
|
12
12
|
parseTrustedHtml,
|
|
13
13
|
safeUrl,
|
|
14
|
+
sourceFingerprint,
|
|
14
15
|
supportedLangs,
|
|
15
16
|
useBrookMarkdownString,
|
|
16
17
|
useBrookStream,
|
package/dist/server.js
CHANGED
package/dist/types-core.d.ts
CHANGED
|
@@ -513,6 +513,22 @@ export interface ParserConfig {
|
|
|
513
513
|
* serde bytes; output and the `kind` serde shape stay byte-identical when off).
|
|
514
514
|
*/
|
|
515
515
|
blockData?: boolean;
|
|
516
|
+
/**
|
|
517
|
+
* Keep every committed block's rendered HTML retained **inside the parser**.
|
|
518
|
+
*
|
|
519
|
+
* Defaults to **false on the streaming path** (the worker), which is what you
|
|
520
|
+
* want: the client receives each committed block exactly once, in the patch
|
|
521
|
+
* that commits it, and stores it itself — so a second copy sitting in WASM for
|
|
522
|
+
* the life of the stream serves nobody. Dropping it roughly halves a long
|
|
523
|
+
* stream's retained bytes (`onPatch`'s `retainedBytes`); the wire is
|
|
524
|
+
* byte-identical either way.
|
|
525
|
+
*
|
|
526
|
+
* Set `true` only if you need the parser itself to still hold the whole
|
|
527
|
+
* rendered document. The server one-shot renderers (`renderToString`,
|
|
528
|
+
* `parseToBlocks`) read the document back out of the parser and therefore pin
|
|
529
|
+
* this on regardless of what you pass.
|
|
530
|
+
*/
|
|
531
|
+
retainCommittedHtml?: boolean;
|
|
516
532
|
}
|
|
517
533
|
export type ToWorker = {
|
|
518
534
|
type: "append";
|
|
@@ -139,6 +139,18 @@ export class BrookParser {
|
|
|
139
139
|
* markers all stay strictly conformant.
|
|
140
140
|
*/
|
|
141
141
|
setLenientLists(on: boolean): void;
|
|
142
|
+
/**
|
|
143
|
+
* Keep every committed block's rendered HTML retained inside the parser for
|
|
144
|
+
* `allBlocks()`. ON by default. Turn it OFF in a pure STREAMING consumer —
|
|
145
|
+
* one that reads each committed block exactly once out of its patch and
|
|
146
|
+
* never calls `allBlocks()` (the npm worker): the HTML is then released the
|
|
147
|
+
* moment the block is emitted, so a long stream retains the source buffer
|
|
148
|
+
* plus the open tail instead of the whole rendered document (`retainedBytes`
|
|
149
|
+
* reflects it). Patch bytes are identical either way; the only cost is that
|
|
150
|
+
* `allBlocks()` then reports committed blocks with an EMPTY `html` (id,
|
|
151
|
+
* kind, start, end, open, speculative all stay exact).
|
|
152
|
+
*/
|
|
153
|
+
setRetainCommittedHtml(on: boolean): void;
|
|
142
154
|
/**
|
|
143
155
|
* Render a CommonMark SOFT line break (a bare `\n` in inline content) as a
|
|
144
156
|
* `<br>` — the `remark-breaks` convention, where one Enter is one visual
|
|
@@ -188,6 +200,7 @@ export interface InitOutput {
|
|
|
188
200
|
readonly brookparser_setHtmlSanitize: (a: number, b: number, c: number, d: number, e: number, f: number) => void;
|
|
189
201
|
readonly brookparser_setInlineComponentTags: (a: number, b: number, c: number) => void;
|
|
190
202
|
readonly brookparser_setLenientLists: (a: number, b: number) => void;
|
|
203
|
+
readonly brookparser_setRetainCommittedHtml: (a: number, b: number) => void;
|
|
191
204
|
readonly brookparser_setSoftBreaks: (a: number, b: number) => void;
|
|
192
205
|
readonly brookparser_setUnsafeHtml: (a: number, b: number) => void;
|
|
193
206
|
readonly brookparser_setWireDelta: (a: number, b: number) => void;
|
|
@@ -290,6 +290,21 @@ export class BrookParser {
|
|
|
290
290
|
setLenientLists(on) {
|
|
291
291
|
wasm.brookparser_setLenientLists(this.__wbg_ptr, on);
|
|
292
292
|
}
|
|
293
|
+
/**
|
|
294
|
+
* Keep every committed block's rendered HTML retained inside the parser for
|
|
295
|
+
* `allBlocks()`. ON by default. Turn it OFF in a pure STREAMING consumer —
|
|
296
|
+
* one that reads each committed block exactly once out of its patch and
|
|
297
|
+
* never calls `allBlocks()` (the npm worker): the HTML is then released the
|
|
298
|
+
* moment the block is emitted, so a long stream retains the source buffer
|
|
299
|
+
* plus the open tail instead of the whole rendered document (`retainedBytes`
|
|
300
|
+
* reflects it). Patch bytes are identical either way; the only cost is that
|
|
301
|
+
* `allBlocks()` then reports committed blocks with an EMPTY `html` (id,
|
|
302
|
+
* kind, start, end, open, speculative all stay exact).
|
|
303
|
+
* @param {boolean} on
|
|
304
|
+
*/
|
|
305
|
+
setRetainCommittedHtml(on) {
|
|
306
|
+
wasm.brookparser_setRetainCommittedHtml(this.__wbg_ptr, on);
|
|
307
|
+
}
|
|
293
308
|
/**
|
|
294
309
|
* Render a CommonMark SOFT line break (a bare `\n` in inline content) as a
|
|
295
310
|
* `<br>` — the `remark-breaks` convention, where one Enter is one visual
|
|
Binary file
|
|
@@ -22,6 +22,7 @@ export const brookparser_setGfmTagfilter: (a: number, b: number) => void;
|
|
|
22
22
|
export const brookparser_setHtmlSanitize: (a: number, b: number, c: number, d: number, e: number, f: number) => void;
|
|
23
23
|
export const brookparser_setInlineComponentTags: (a: number, b: number, c: number) => void;
|
|
24
24
|
export const brookparser_setLenientLists: (a: number, b: number) => void;
|
|
25
|
+
export const brookparser_setRetainCommittedHtml: (a: number, b: number) => void;
|
|
25
26
|
export const brookparser_setSoftBreaks: (a: number, b: number) => void;
|
|
26
27
|
export const brookparser_setUnsafeHtml: (a: number, b: number) => void;
|
|
27
28
|
export const brookparser_setWireDelta: (a: number, b: number) => void;
|
package/dist/worker.js
CHANGED
|
@@ -29,6 +29,7 @@ const core = new WorkerCore({
|
|
|
29
29
|
p.setBlockHtml(c?.blockHtml ?? false);
|
|
30
30
|
p.setAllowSchemes(c?.allowSchemes ?? []);
|
|
31
31
|
p.setBlockData(c?.blockData ?? false);
|
|
32
|
+
p.setRetainCommittedHtml(c?.retainCommittedHtml ?? false);
|
|
32
33
|
p.setWireDelta(true);
|
|
33
34
|
return p;
|
|
34
35
|
},
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "brookmd",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.29.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"],
|