flux-md 0.9.0 → 0.10.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 +31 -0
- package/README.md +35 -2
- package/package.json +2 -1
- package/src/block-props.ts +39 -4
- package/src/client.ts +51 -9
- package/src/index.ts +7 -0
- package/src/react.tsx +27 -4
- package/src/solid.tsx +5 -0
- package/src/types-core.ts +129 -0
- package/src/wasm/flux_md_core.d.ts +9 -0
- package/src/wasm/flux_md_core.js +11 -0
- package/src/wasm/flux_md_core_bg.wasm +0 -0
- package/src/wasm/flux_md_core_bg.wasm.d.ts +1 -0
- package/src/wasm/package.json +1 -1
- package/src/worker.ts +1 -0
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,37 @@ Notable changes to 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.10.0 — 2026-05-30
|
|
8
|
+
|
|
9
|
+
Server-side rendering safety, plus an opt-in structured-data channel so consumers
|
|
10
|
+
build toolbars / tables of contents / charts from **data** instead of re-parsing
|
|
11
|
+
rendered HTML (no hast tree, no rehype).
|
|
12
|
+
|
|
13
|
+
### Added
|
|
14
|
+
|
|
15
|
+
- **SSR-safe.** `new FluxClient()` and `renderToString(<FluxMarkdown …/>)` no
|
|
16
|
+
longer touch a Web Worker during construction or server render — worker
|
|
17
|
+
creation is deferred to the first `append`/`pipeFrom` (client-side) — so the
|
|
18
|
+
library imports and server-renders cleanly across React / Vue / Solid / Svelte.
|
|
19
|
+
A fresh-process SSR cold-import check guards it in CI.
|
|
20
|
+
- **Structured block data — `blockData: true`** (per-stream config; opt-in,
|
|
21
|
+
default off — output and CommonMark/GFM conformance are **byte-identical** when
|
|
22
|
+
off). When on, `block.kind.data` carries typed structured data per kind, also
|
|
23
|
+
surfaced as typed `BlockComponentProps` fields, and it **streams** in lock-step
|
|
24
|
+
with the HTML:
|
|
25
|
+
- **Table** → `{ headers, rows, aligns }`, cells `{ text, html }` (`props.table`)
|
|
26
|
+
— sort / filter / transpose / CSV / chart.
|
|
27
|
+
- **Heading** → `{ level, text, id }` (`props.heading`) — TOC with anchors.
|
|
28
|
+
- **CodeBlock** → `{ lang, code }` (`props.code`) — decoded source.
|
|
29
|
+
- **MathBlock** → `{ latex }` (`props.math`) — LaTeX source.
|
|
30
|
+
- **List** → `{ ordered, start }` (`props.list`).
|
|
31
|
+
|
|
32
|
+
### Fixed
|
|
33
|
+
|
|
34
|
+
- Packaging: the published tarball ships the WASM deterministically on every npm
|
|
35
|
+
version (build removes wasm-pack's nested `.gitignore`), with a tarball tripwire
|
|
36
|
+
in CI and the publish workflow.
|
|
37
|
+
|
|
7
38
|
## 0.9.0 — 2026-05-29
|
|
8
39
|
|
|
9
40
|
Kills the React streaming boilerplate. The common case — render an LLM stream —
|
package/README.md
CHANGED
|
@@ -336,6 +336,7 @@ const client = new FluxClient({
|
|
|
336
336
|
a11y: true, // task-list <label> + <th scope="col"> a11y markup (default false)
|
|
337
337
|
unsafeHtml: false, // pass raw HTML through (default false — keep it false for untrusted input)
|
|
338
338
|
componentTags: ["Thinking", "Callout"], // custom tags with markdown inside (default none)
|
|
339
|
+
blockData: true, // opt-in structured kind.data per block (default false — see "Structured block data")
|
|
339
340
|
},
|
|
340
341
|
});
|
|
341
342
|
```
|
|
@@ -463,8 +464,11 @@ type is at `block.kind.data.kind`).
|
|
|
463
464
|
|
|
464
465
|
Rules worth knowing:
|
|
465
466
|
|
|
466
|
-
- **There is no `node` prop
|
|
467
|
-
`
|
|
467
|
+
- **There is no `node` prop / no hast tree.** Introspect via `className` /
|
|
468
|
+
`data-*`, or — better — opt into the typed **[structured-data
|
|
469
|
+
channel](#structured-block-data-setblockdata)** (`blockData: true`) and read
|
|
470
|
+
`block.kind.data` (and the typed `props.table` / `heading` / `code` / `math` /
|
|
471
|
+
`list` fields) directly — no HTML re-parsing.
|
|
468
472
|
- **Open (streaming) blocks render via `innerHTML`** — their HTML is still
|
|
469
473
|
partial, so a tag-level override takes effect the moment the block commits.
|
|
470
474
|
- **No `components` prop ⇒ the original fast path** (`innerHTML`, byte-identical
|
|
@@ -474,6 +478,35 @@ Rules worth knowing:
|
|
|
474
478
|
(so your override wins) when you pass `components.CodeBlock`, `components.pre`,
|
|
475
479
|
or `components.code`.
|
|
476
480
|
|
|
481
|
+
### Structured block data (`setBlockData`)
|
|
482
|
+
|
|
483
|
+
Set `blockData: true` in the per-stream config and each block carries typed
|
|
484
|
+
structured data on `block.kind.data`, also surfaced as typed fields on the
|
|
485
|
+
component props — so you build toolbars, tables of contents, charts, copy
|
|
486
|
+
buttons, etc. from **data**, never by re-parsing the rendered HTML (no hast tree,
|
|
487
|
+
no rehype). Off by default; when off, output and CommonMark/GFM conformance are
|
|
488
|
+
byte-identical, so non-users pay nothing.
|
|
489
|
+
|
|
490
|
+
| Kind | `block.kind.data` | prop | use |
|
|
491
|
+
|------|-------------------|------|-----|
|
|
492
|
+
| `Table` | `{ headers, rows, aligns }`, cells `{ text, html }` | `props.table` | sort / filter / transpose / CSV / chart |
|
|
493
|
+
| `Heading` | `{ level, text, id }` | `props.heading` | table of contents with anchors |
|
|
494
|
+
| `CodeBlock` | `{ lang, code }` | `props.code` | decoded source (copy / run) |
|
|
495
|
+
| `MathBlock` | `{ latex }` | `props.math` | LaTeX source (re-render) |
|
|
496
|
+
| `List` | `{ ordered, start }` | `props.list` | ordered-list numbering |
|
|
497
|
+
|
|
498
|
+
Each cell's `text` is inline-stripped plaintext (for sort/filter/CSV/logic);
|
|
499
|
+
`html` is the inline-rendered display HTML. The data **streams** with the
|
|
500
|
+
document — a growing table or a heading carries its structured data on every
|
|
501
|
+
patch, in lock-step with the HTML — something a batch HTML-AST cannot do.
|
|
502
|
+
|
|
503
|
+
```tsx
|
|
504
|
+
// Table of contents from heading data — no DOM, works mid-stream:
|
|
505
|
+
const toc = client.getSnapshot()
|
|
506
|
+
.filter((b) => b.kind.type === "Heading" && b.kind.data)
|
|
507
|
+
.map((b) => b.kind.data as { level: number; text: string; id: string });
|
|
508
|
+
```
|
|
509
|
+
|
|
477
510
|
### Component tags
|
|
478
511
|
|
|
479
512
|
LLMs increasingly emit custom component tags like `<Thinking>…</Thinking>`. By
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "flux-md",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.10.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": ["./src/worker.ts"],
|
|
@@ -48,6 +48,7 @@
|
|
|
48
48
|
},
|
|
49
49
|
"scripts": {
|
|
50
50
|
"test": "bun test",
|
|
51
|
+
"test:ssr-cold": "bun test/ssr-cold.mjs",
|
|
51
52
|
"prepublishOnly": "cd ../.. && bun run build:wasm"
|
|
52
53
|
},
|
|
53
54
|
"keywords": ["markdown", "streaming", "wasm", "rust", "react", "vue", "svelte", "solid", "web-component", "custom-element", "incremental", "llm", "ai", "math", "katex", "latex", "bidi", "rtl"],
|
package/src/block-props.ts
CHANGED
|
@@ -1,4 +1,12 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type {
|
|
2
|
+
Block,
|
|
3
|
+
BlockComponentProps,
|
|
4
|
+
CodeBlockData,
|
|
5
|
+
HeadingData,
|
|
6
|
+
ListData,
|
|
7
|
+
MathBlockData,
|
|
8
|
+
TableData,
|
|
9
|
+
} from "./types-core";
|
|
2
10
|
|
|
3
11
|
// Pure helpers duplicated from the JSX renderer / its CodeBlock so the
|
|
4
12
|
// framework-neutral DOM renderer carries no framework dependency. The JSX
|
|
@@ -78,19 +86,46 @@ export function blockProps(block: Block): BlockComponentProps {
|
|
|
78
86
|
speculative: block.speculative,
|
|
79
87
|
};
|
|
80
88
|
const data = block.kind.data as
|
|
81
|
-
| { lang?: string | null; tag?: string; attrs?: [string, string][] }
|
|
89
|
+
| { lang?: string | null; code?: string; latex?: string; start?: number; ordered?: boolean; tag?: string; attrs?: [string, string][] }
|
|
82
90
|
| undefined;
|
|
83
91
|
if (block.kind.type === "CodeBlock") {
|
|
84
|
-
|
|
92
|
+
// Prefer the structured `code` (present when blockData is on) over the HTML
|
|
93
|
+
// regex — it is the lossless decoded source. Fall back to the regex when off.
|
|
94
|
+
props.text = data?.code ?? decodeCodeText(block.html);
|
|
85
95
|
props.language = data?.lang ?? "";
|
|
96
|
+
// Surface the typed convenience field only when the opt-in `code` is present.
|
|
97
|
+
if (typeof data?.code === "string") {
|
|
98
|
+
props.code = { lang: data.lang ?? null, code: data.code } as CodeBlockData;
|
|
99
|
+
}
|
|
86
100
|
} else if (block.kind.type === "MathBlock") {
|
|
87
|
-
|
|
101
|
+
// Prefer the structured `latex` (present when blockData is on) over the regex.
|
|
102
|
+
props.text = data?.latex ?? decodeMathText(block.html);
|
|
103
|
+
if (typeof data?.latex === "string") {
|
|
104
|
+
props.math = { latex: data.latex } as MathBlockData;
|
|
105
|
+
}
|
|
106
|
+
} else if (block.kind.type === "List") {
|
|
107
|
+
// Structured list data is present only when blockData is on (the `start` key
|
|
108
|
+
// rides the opt-in channel); surface it as the typed convenience field.
|
|
109
|
+
if (data && typeof data.start === "number") {
|
|
110
|
+
props.list = { ordered: !!data.ordered, start: data.start } as ListData;
|
|
111
|
+
}
|
|
88
112
|
} else if (block.kind.type === "Component") {
|
|
89
113
|
props.tag = data?.tag ?? "";
|
|
90
114
|
props.attrs = htmlAttrs(data?.attrs ?? []);
|
|
91
115
|
// An override replaces the `<tag>` wrapper, so it gets the *inner* HTML
|
|
92
116
|
// (markdown already rendered) rather than the full wrapped block.
|
|
93
117
|
props.html = componentInnerHtml(block.html, props.tag);
|
|
118
|
+
} else if (block.kind.type === "Table") {
|
|
119
|
+
// Structured data is present only when `blockData` is on (else `undefined`).
|
|
120
|
+
// Pure data — identical for the DOM and JSX renderers, no name-form divergence.
|
|
121
|
+
props.table = block.kind.data as TableData | undefined;
|
|
122
|
+
} else if (block.kind.type === "Heading") {
|
|
123
|
+
// When `blockData` is on, `kind.data` is the `{ level, text, id }` object;
|
|
124
|
+
// when off it is the bare level `number`. Surface the rich object only — the
|
|
125
|
+
// `typeof === "object"` guard keeps the off-path naked int out of `heading`.
|
|
126
|
+
if (typeof block.kind.data === "object" && block.kind.data !== null) {
|
|
127
|
+
props.heading = block.kind.data as HeadingData;
|
|
128
|
+
}
|
|
94
129
|
}
|
|
95
130
|
return props;
|
|
96
131
|
}
|
package/src/client.ts
CHANGED
|
@@ -277,8 +277,8 @@ export function getDefaultPool(): FluxPool {
|
|
|
277
277
|
*/
|
|
278
278
|
export class FluxClient {
|
|
279
279
|
private pool: FluxPool;
|
|
280
|
-
private pw: PoolWorker;
|
|
281
|
-
private streamId
|
|
280
|
+
private pw: PoolWorker | null = null;
|
|
281
|
+
private streamId = 0;
|
|
282
282
|
private config?: ParserConfig;
|
|
283
283
|
private configSent = false;
|
|
284
284
|
private listeners = new Set<() => void>();
|
|
@@ -321,17 +321,38 @@ export class FluxClient {
|
|
|
321
321
|
this.config = options.config;
|
|
322
322
|
this.onError = options.onError;
|
|
323
323
|
this.onBlock = options.onBlock;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/**
|
|
327
|
+
* Lazily reserve this client's stream id and bind it to a pool worker. The
|
|
328
|
+
* SOLE place that calls pool.acquire() — so the worker is created on the FIRST
|
|
329
|
+
* worker-bound operation (append/finalize/reset/pipeFrom/whenReady), never at
|
|
330
|
+
* construct time. This is what makes `new FluxClient()` SSR-safe: nothing here
|
|
331
|
+
* runs during an SSR render (which only subscribes + reads the snapshot).
|
|
332
|
+
*
|
|
333
|
+
* Idempotent: once this.pw is set it returns it immediately and never
|
|
334
|
+
* re-acquires — this.pw is never nulled (destroy() deliberately keeps it so
|
|
335
|
+
* StrictMode's destroy()→reattach() on the SAME instance re-registers the same
|
|
336
|
+
* slot). Note: streamId/worker assignment now follows first-worker-bound-op
|
|
337
|
+
* order, not construction order — a client constructed first no longer
|
|
338
|
+
* necessarily owns the lowest streamId. This affects neither the pool cap nor
|
|
339
|
+
* multiplexing (pick() is unchanged and remains the only path to create()).
|
|
340
|
+
*/
|
|
341
|
+
private ensureAcquired(): PoolWorker {
|
|
342
|
+
if (this.pw) return this.pw;
|
|
324
343
|
const { streamId, pw } = this.pool.acquire((msg) => this.onMessage(msg));
|
|
325
344
|
this.streamId = streamId;
|
|
326
345
|
this.pw = pw;
|
|
346
|
+
return pw;
|
|
327
347
|
}
|
|
328
348
|
|
|
329
349
|
get ready(): boolean {
|
|
330
|
-
return this.pw
|
|
350
|
+
return this.pw?.ready ?? false;
|
|
331
351
|
}
|
|
332
352
|
|
|
333
353
|
whenReady(): Promise<void> {
|
|
334
|
-
|
|
354
|
+
const pw = this.ensureAcquired();
|
|
355
|
+
return this.pool.whenWorkerReady(pw);
|
|
335
356
|
}
|
|
336
357
|
|
|
337
358
|
// The config rides on the first message a stream sends; the worker applies it
|
|
@@ -344,12 +365,14 @@ export class FluxClient {
|
|
|
344
365
|
}
|
|
345
366
|
|
|
346
367
|
append(chunk: string) {
|
|
368
|
+
const pw = this.ensureAcquired();
|
|
347
369
|
if (this.firstAppendMs === 0) this.firstAppendMs = performance.now();
|
|
348
|
-
this.pool.send(
|
|
370
|
+
this.pool.send(pw, { type: "append", streamId: this.streamId, chunk, config: this.firstConfig() });
|
|
349
371
|
}
|
|
350
372
|
|
|
351
373
|
finalize() {
|
|
352
|
-
|
|
374
|
+
const pw = this.ensureAcquired();
|
|
375
|
+
this.pool.send(pw, { type: "finalize", streamId: this.streamId, config: this.firstConfig() });
|
|
353
376
|
}
|
|
354
377
|
|
|
355
378
|
/**
|
|
@@ -433,14 +456,19 @@ export class FluxClient {
|
|
|
433
456
|
this.retainedBytes = 0;
|
|
434
457
|
this.wasmMemoryBytes = 0;
|
|
435
458
|
// Same streamId + worker — the worker frees and lazily recreates the parser.
|
|
436
|
-
|
|
459
|
+
const pw = this.ensureAcquired();
|
|
460
|
+
this.pool.send(pw, { type: "reset", streamId: this.streamId });
|
|
437
461
|
this.emit();
|
|
438
462
|
}
|
|
439
463
|
|
|
440
464
|
destroy() {
|
|
441
465
|
if (!this.attached) return; // idempotent
|
|
442
466
|
// Free this stream's parser; the shared worker stays warm for siblings.
|
|
443
|
-
|
|
467
|
+
// Only release a real slot — a never-acquired client (constructed during an
|
|
468
|
+
// SSR render then unmounted) has no pool slot to free, so skip the call.
|
|
469
|
+
// We deliberately do NOT null this.pw here: StrictMode's destroy()→reattach()
|
|
470
|
+
// on the SAME instance needs the same pw/streamId to re-register.
|
|
471
|
+
if (this.pw) this.pool.release(this.streamId, this.pw);
|
|
444
472
|
this.listeners.clear();
|
|
445
473
|
this.attached = false;
|
|
446
474
|
}
|
|
@@ -453,6 +481,14 @@ export class FluxClient {
|
|
|
453
481
|
*/
|
|
454
482
|
reattach() {
|
|
455
483
|
if (this.attached) return;
|
|
484
|
+
if (!this.pw) {
|
|
485
|
+
// Never acquired (e.g. constructed during SSR, first real mount on client).
|
|
486
|
+
// No prior pool slot to re-register; just mark attached. The next
|
|
487
|
+
// worker-bound op acquires lazily. configSent is already false, so the
|
|
488
|
+
// first append will carry config exactly as a brand-new client would.
|
|
489
|
+
this.attached = true;
|
|
490
|
+
return;
|
|
491
|
+
}
|
|
456
492
|
this.pool.reattach(this.streamId, this.pw, (msg) => this.onMessage(msg));
|
|
457
493
|
this.attached = true;
|
|
458
494
|
// The worker discarded this stream's config on `dispose` (unlike `reset`,
|
|
@@ -496,7 +532,13 @@ export class FluxClient {
|
|
|
496
532
|
const out: OutlineEntry[] = [];
|
|
497
533
|
for (const b of this.store.snapshot) {
|
|
498
534
|
if (b.kind.type === "Heading") {
|
|
499
|
-
|
|
535
|
+
// `kind.data` is the bare level `number` when `blockData` is off, or the
|
|
536
|
+
// `{ level, text, id }` object when on — accept both. `OutlineEntry.id`
|
|
537
|
+
// stays the numeric block id (stable, non-breaking); the anchor slug is
|
|
538
|
+
// reachable additively via `kind.data.id` for consumers who want it.
|
|
539
|
+
const d = b.kind.data as number | { level?: number } | undefined;
|
|
540
|
+
const level = typeof d === "number" ? d : d?.level ?? 1;
|
|
541
|
+
out.push({ level, text: htmlToText(b.html), id: b.id });
|
|
500
542
|
}
|
|
501
543
|
}
|
|
502
544
|
return out;
|
package/src/index.ts
CHANGED
package/src/react.tsx
CHANGED
|
@@ -8,7 +8,7 @@ import {
|
|
|
8
8
|
useSyncExternalStore,
|
|
9
9
|
type CSSProperties,
|
|
10
10
|
} from "react";
|
|
11
|
-
import type { Block, BlockComponentProps, Components } from "./types";
|
|
11
|
+
import type { Block, BlockComponentProps, Components, HeadingData, TableData } from "./types";
|
|
12
12
|
import { FluxClient } from "./client";
|
|
13
13
|
import type { ParserConfig } from "./types-core";
|
|
14
14
|
import { CodeBlock } from "./renderers/CodeBlock";
|
|
@@ -244,13 +244,25 @@ function blockKindProps(block: Block): BlockComponentProps {
|
|
|
244
244
|
speculative: block.speculative,
|
|
245
245
|
};
|
|
246
246
|
const data = block.kind.data as
|
|
247
|
-
| { lang?: string | null; tag?: string; attrs?: [string, string][] }
|
|
247
|
+
| { lang?: string | null; code?: string; latex?: string; start?: number; ordered?: boolean; tag?: string; attrs?: [string, string][] }
|
|
248
248
|
| undefined;
|
|
249
249
|
if (block.kind.type === "CodeBlock") {
|
|
250
|
-
|
|
250
|
+
// Prefer the structured `code` (present when blockData is on) over the HTML
|
|
251
|
+
// regex — the lossless decoded source. Fall back to the regex when off.
|
|
252
|
+
props.text = data?.code ?? decodeCodeText(block.html);
|
|
251
253
|
props.language = data?.lang ?? "";
|
|
254
|
+
if (typeof data?.code === "string") {
|
|
255
|
+
props.code = { lang: data.lang ?? null, code: data.code };
|
|
256
|
+
}
|
|
252
257
|
} else if (block.kind.type === "MathBlock") {
|
|
253
|
-
props.text = decodeMathText(block.html);
|
|
258
|
+
props.text = data?.latex ?? decodeMathText(block.html);
|
|
259
|
+
if (typeof data?.latex === "string") {
|
|
260
|
+
props.math = { latex: data.latex };
|
|
261
|
+
}
|
|
262
|
+
} else if (block.kind.type === "List") {
|
|
263
|
+
if (data && typeof data.start === "number") {
|
|
264
|
+
props.list = { ordered: !!data.ordered, start: data.start };
|
|
265
|
+
}
|
|
254
266
|
} else if (block.kind.type === "Component") {
|
|
255
267
|
props.tag = data?.tag ?? "";
|
|
256
268
|
// React-form attribute names, so `{...attrs}` spreads cleanly onto an element
|
|
@@ -259,6 +271,17 @@ function blockKindProps(block: Block): BlockComponentProps {
|
|
|
259
271
|
// An override replaces the `<tag>` wrapper, so it gets the *inner* HTML
|
|
260
272
|
// (markdown already rendered) rather than the full wrapped block.
|
|
261
273
|
props.html = componentInnerHtml(block.html, props.tag);
|
|
274
|
+
} else if (block.kind.type === "Table") {
|
|
275
|
+
// Pure structured data (present only when `blockData` is on) — unlike
|
|
276
|
+
// `attrs` there is no React/DOM name-form divergence, so this is the same
|
|
277
|
+
// line as block-props.ts's branch.
|
|
278
|
+
props.table = block.kind.data as TableData | undefined;
|
|
279
|
+
} else if (block.kind.type === "Heading") {
|
|
280
|
+
// When `blockData` is on, `kind.data` is `{ level, text, id }`; off, it is the
|
|
281
|
+
// bare level `number`. Surface the rich object only (mirrors block-props.ts).
|
|
282
|
+
if (typeof block.kind.data === "object" && block.kind.data !== null) {
|
|
283
|
+
props.heading = block.kind.data as HeadingData;
|
|
284
|
+
}
|
|
262
285
|
}
|
|
263
286
|
return props;
|
|
264
287
|
}
|
package/src/solid.tsx
CHANGED
|
@@ -59,6 +59,11 @@ export function mountSolid(
|
|
|
59
59
|
* is equivalent to `<div ref={container} class={props.class} style={props.style} />`.
|
|
60
60
|
*/
|
|
61
61
|
export function FluxMarkdown(props: FluxMarkdownProps): JSX.Element {
|
|
62
|
+
// SSR: this renderer is client-only and imperative (creates/owns its own DOM
|
|
63
|
+
// node, no hydration expected). Solid runs component bodies on the server, so
|
|
64
|
+
// guard the document access; the browser path is byte-identical (document is
|
|
65
|
+
// always defined there). onMount never fires on the server anyway.
|
|
66
|
+
if (typeof document === "undefined") return undefined as unknown as JSX.Element;
|
|
62
67
|
const container = document.createElement("div");
|
|
63
68
|
if (props.class) container.className = props.class;
|
|
64
69
|
if (typeof props.style === "string") container.setAttribute("style", props.style);
|
package/src/types-core.ts
CHANGED
|
@@ -17,6 +17,86 @@ export interface BlockKind {
|
|
|
17
17
|
data?: unknown;
|
|
18
18
|
}
|
|
19
19
|
|
|
20
|
+
/** Column alignment from the `|:--|:-:|--:|` delimiter row; `null` = unset. */
|
|
21
|
+
export type Align = "left" | "center" | "right" | null;
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* One table cell as STRUCTURED DATA (opt-in via {@link ParserConfig.blockData}).
|
|
25
|
+
* `text` is the inline-stripped plaintext — sort/filter/CSV/chart from DATA,
|
|
26
|
+
* with no HTML re-parse. `html` is the inline-rendered display markup, byte-for-
|
|
27
|
+
* byte the inline content inside the matching `<td>`/`<th>` of `block.html`.
|
|
28
|
+
*/
|
|
29
|
+
export interface TableCell {
|
|
30
|
+
text: string;
|
|
31
|
+
html: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* A Table block's `kind.data` when {@link ParserConfig.blockData} is on. Lets a
|
|
36
|
+
* consumer build a sort/filter/transpose/chart/CSV toolbar from DATA alone —
|
|
37
|
+
* no HAST tree, no HTML re-parse. `aligns[i]` is column `i`'s alignment.
|
|
38
|
+
*/
|
|
39
|
+
export interface TableData {
|
|
40
|
+
headers: TableCell[];
|
|
41
|
+
rows: TableCell[][];
|
|
42
|
+
aligns: Align[];
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* A Heading block's `kind.data` when {@link ParserConfig.blockData} is on. Lets a
|
|
47
|
+
* consumer build a table of contents — nested by `level`, anchored by `id` — from
|
|
48
|
+
* DATA alone, with no HTML re-parse. `text` is the inline-stripped plaintext (the
|
|
49
|
+
* heading rendered to plain text, e.g. `## **Bold** & x` → `"Bold & x"`); `id` is
|
|
50
|
+
* a GitHub-style anchor slug of that text (`"bold-x"`) for `#`-links. When
|
|
51
|
+
* `blockData` is off, a Heading's `kind.data` is instead the bare level `number`
|
|
52
|
+
* (byte-identical to before), so consumers reading `kind.data` must accept the
|
|
53
|
+
* `number | HeadingData` union.
|
|
54
|
+
*
|
|
55
|
+
* v1: duplicate heading texts produce identical slugs (no document-wide dedup
|
|
56
|
+
* counter yet) — give same-named headings distinct text if unique anchors matter.
|
|
57
|
+
*/
|
|
58
|
+
export interface HeadingData {
|
|
59
|
+
level: number;
|
|
60
|
+
text: string;
|
|
61
|
+
id: string;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* A CodeBlock's `kind.data` when {@link ParserConfig.blockData} is on. `lang` is
|
|
66
|
+
* the always-on info-string language (`null` for none); `code` is the opt-in
|
|
67
|
+
* DECODED source inside `<pre><code>…</code></pre>` (only present when `blockData`
|
|
68
|
+
* is on). Build a copy-to-clipboard string / re-highlight from `code` alone — no
|
|
69
|
+
* HTML re-parse, no entity-decode. When `blockData` is off, `code` is absent and
|
|
70
|
+
* `kind.data` is just `{ lang }`, byte-identical to before.
|
|
71
|
+
*/
|
|
72
|
+
export interface CodeBlockData {
|
|
73
|
+
lang: string | null;
|
|
74
|
+
code?: string;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* A MathBlock's `kind.data` when {@link ParserConfig.blockData} is on. `latex` is
|
|
79
|
+
* the DECODED LaTeX source (the display-math body, entity-decoded). Re-render with
|
|
80
|
+
* KaTeX from `latex` alone — no HTML re-parse. When `blockData` is off, a
|
|
81
|
+
* MathBlock has no `kind.data` at all (byte-identical to before).
|
|
82
|
+
*/
|
|
83
|
+
export interface MathBlockData {
|
|
84
|
+
latex: string;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* A List's `kind.data` when {@link ParserConfig.blockData} is on. `ordered` is the
|
|
89
|
+
* always-on flag; `start` is the opt-in ordered-list start number (the `start="N"`
|
|
90
|
+
* HTML attribute; `1` for an unordered list), only present when `blockData` is on.
|
|
91
|
+
* Renumber / continue a split list from `start` alone — no HTML re-parse. When
|
|
92
|
+
* `blockData` is off, `start` is absent and `kind.data` is just `{ ordered }`,
|
|
93
|
+
* byte-identical to before.
|
|
94
|
+
*/
|
|
95
|
+
export interface ListData {
|
|
96
|
+
ordered: boolean;
|
|
97
|
+
start?: number;
|
|
98
|
+
}
|
|
99
|
+
|
|
20
100
|
export interface Block {
|
|
21
101
|
id: number;
|
|
22
102
|
kind: BlockKind;
|
|
@@ -58,6 +138,47 @@ export interface BlockComponentProps {
|
|
|
58
138
|
* wrap it itself.
|
|
59
139
|
*/
|
|
60
140
|
attrs?: Record<string, string>;
|
|
141
|
+
/**
|
|
142
|
+
* Structured table data — present for `Table` blocks when
|
|
143
|
+
* {@link ParserConfig.blockData} is on (otherwise `undefined`). Equivalent to
|
|
144
|
+
* `block.kind.data`, given a typed, documented name. `{ headers, rows, aligns }`
|
|
145
|
+
* with each cell carrying `text` (plaintext, for sort/filter/CSV/chart) and
|
|
146
|
+
* `html` (display). Build a sort/filter/transpose/chart/CSV toolbar from DATA —
|
|
147
|
+
* no HTML re-parse, no HAST tree.
|
|
148
|
+
*/
|
|
149
|
+
table?: TableData;
|
|
150
|
+
/**
|
|
151
|
+
* Structured heading data — present for `Heading` blocks when
|
|
152
|
+
* {@link ParserConfig.blockData} is on (otherwise `undefined`). `{ level, text,
|
|
153
|
+
* id }` with `text` the inline-stripped plaintext and `id` a GitHub-style anchor
|
|
154
|
+
* slug. Build a table of contents (nested by `level`, anchored by `id`) from
|
|
155
|
+
* DATA — no HTML re-parse.
|
|
156
|
+
*/
|
|
157
|
+
heading?: HeadingData;
|
|
158
|
+
/**
|
|
159
|
+
* Structured code data — present for `CodeBlock` blocks when
|
|
160
|
+
* {@link ParserConfig.blockData} is on (otherwise `undefined`). `{ lang, code }`
|
|
161
|
+
* with `code` the DECODED source. Build a copy-to-clipboard string / re-highlight
|
|
162
|
+
* from `code` — no HTML re-parse, no entity-decode. (`props.text` / `props.language`
|
|
163
|
+
* carry the same source / lang and stay populated even when off, via the HTML
|
|
164
|
+
* regex fallback.)
|
|
165
|
+
*/
|
|
166
|
+
code?: CodeBlockData;
|
|
167
|
+
/**
|
|
168
|
+
* Structured math data — present for `MathBlock` blocks when
|
|
169
|
+
* {@link ParserConfig.blockData} is on (otherwise `undefined`). `{ latex }` — the
|
|
170
|
+
* DECODED LaTeX source. Re-render with KaTeX from `latex` — no HTML re-parse.
|
|
171
|
+
* (`props.text` carries the same source and stays populated even when off, via
|
|
172
|
+
* the HTML regex fallback.)
|
|
173
|
+
*/
|
|
174
|
+
math?: MathBlockData;
|
|
175
|
+
/**
|
|
176
|
+
* Structured list data — present for `List` blocks when
|
|
177
|
+
* {@link ParserConfig.blockData} is on (otherwise `undefined`). `{ ordered,
|
|
178
|
+
* start }` — renumber / continue a split list from `start` (the ordered-list
|
|
179
|
+
* start number) without re-parsing the `<ol start=…>` attribute.
|
|
180
|
+
*/
|
|
181
|
+
list?: ListData;
|
|
61
182
|
}
|
|
62
183
|
|
|
63
184
|
/**
|
|
@@ -108,6 +229,14 @@ export interface ParserConfig {
|
|
|
108
229
|
* off. Names match case-sensitively.
|
|
109
230
|
*/
|
|
110
231
|
componentTags?: string[];
|
|
232
|
+
/**
|
|
233
|
+
* Opt-in structured table data. When on, a `Table` block's `kind.data` is
|
|
234
|
+
* populated with `{ headers, rows, aligns }` (each cell `{ text, html }`) so a
|
|
235
|
+
* consumer can build a sort/filter/transpose/chart/CSV toolbar from DATA — no
|
|
236
|
+
* HTML re-parse, no HAST tree. Default false (non-users pay zero allocation /
|
|
237
|
+
* serde bytes; output and the `kind` serde shape stay byte-identical when off).
|
|
238
|
+
*/
|
|
239
|
+
blockData?: boolean;
|
|
111
240
|
}
|
|
112
241
|
|
|
113
242
|
// Each message carries a `streamId` so one worker can multiplex many parsers
|
|
@@ -20,6 +20,14 @@ export class FluxParser {
|
|
|
20
20
|
* to table header cells. Off by default (conformance output unchanged).
|
|
21
21
|
*/
|
|
22
22
|
setA11y(on: boolean): void;
|
|
23
|
+
/**
|
|
24
|
+
* Opt-in structured `kind.data` channel for Table blocks: a Table then
|
|
25
|
+
* carries `{ headers, rows, aligns }` (per-cell `{ text, html }`) so a
|
|
26
|
+
* consumer can build a sort/filter/transpose/chart/CSV toolbar from DATA
|
|
27
|
+
* without re-parsing the HTML. Off by default — when off, Table serializes
|
|
28
|
+
* as `{"type":"Table"}` (no `data` key) and output is byte-identical.
|
|
29
|
+
*/
|
|
30
|
+
setBlockData(on: boolean): void;
|
|
23
31
|
/**
|
|
24
32
|
* Set the opt-in component-tag allowlist (e.g. `["Thinking", "Callout"]`).
|
|
25
33
|
* A `<Tag>…</Tag>` whose name is listed renders as a component whose inner
|
|
@@ -74,6 +82,7 @@ export interface InitOutput {
|
|
|
74
82
|
readonly fluxparser_new: () => number;
|
|
75
83
|
readonly fluxparser_retainedBytes: (a: number) => number;
|
|
76
84
|
readonly fluxparser_setA11y: (a: number, b: number) => void;
|
|
85
|
+
readonly fluxparser_setBlockData: (a: number, b: number) => void;
|
|
77
86
|
readonly fluxparser_setComponentTags: (a: number, b: number, c: number) => void;
|
|
78
87
|
readonly fluxparser_setDirAuto: (a: number, b: number) => void;
|
|
79
88
|
readonly fluxparser_setGfmAlerts: (a: number, b: number) => void;
|
package/src/wasm/flux_md_core.js
CHANGED
|
@@ -82,6 +82,17 @@ export class FluxParser {
|
|
|
82
82
|
setA11y(on) {
|
|
83
83
|
wasm.fluxparser_setA11y(this.__wbg_ptr, on);
|
|
84
84
|
}
|
|
85
|
+
/**
|
|
86
|
+
* Opt-in structured `kind.data` channel for Table blocks: a Table then
|
|
87
|
+
* carries `{ headers, rows, aligns }` (per-cell `{ text, html }`) so a
|
|
88
|
+
* consumer can build a sort/filter/transpose/chart/CSV toolbar from DATA
|
|
89
|
+
* without re-parsing the HTML. Off by default — when off, Table serializes
|
|
90
|
+
* as `{"type":"Table"}` (no `data` key) and output is byte-identical.
|
|
91
|
+
* @param {boolean} on
|
|
92
|
+
*/
|
|
93
|
+
setBlockData(on) {
|
|
94
|
+
wasm.fluxparser_setBlockData(this.__wbg_ptr, on);
|
|
95
|
+
}
|
|
85
96
|
/**
|
|
86
97
|
* Set the opt-in component-tag allowlist (e.g. `["Thinking", "Callout"]`).
|
|
87
98
|
* A `<Tag>…</Tag>` whose name is listed renders as a component whose inner
|
|
Binary file
|
|
@@ -8,6 +8,7 @@ export const fluxparser_finalize: (a: number, b: number) => void;
|
|
|
8
8
|
export const fluxparser_new: () => number;
|
|
9
9
|
export const fluxparser_retainedBytes: (a: number) => number;
|
|
10
10
|
export const fluxparser_setA11y: (a: number, b: number) => void;
|
|
11
|
+
export const fluxparser_setBlockData: (a: number, b: number) => void;
|
|
11
12
|
export const fluxparser_setComponentTags: (a: number, b: number, c: number) => void;
|
|
12
13
|
export const fluxparser_setDirAuto: (a: number, b: number) => void;
|
|
13
14
|
export const fluxparser_setGfmAlerts: (a: number, b: number) => void;
|
package/src/wasm/package.json
CHANGED
package/src/worker.ts
CHANGED
|
@@ -55,6 +55,7 @@ function getOrCreate(streamId: number): FluxParser {
|
|
|
55
55
|
p.setA11y(c?.a11y ?? false);
|
|
56
56
|
p.setUnsafeHtml(c?.unsafeHtml ?? false);
|
|
57
57
|
p.setComponentTags(c?.componentTags ?? []);
|
|
58
|
+
p.setBlockData(c?.blockData ?? false);
|
|
58
59
|
parsers.set(streamId, p);
|
|
59
60
|
}
|
|
60
61
|
return p;
|