@latentic/live-markdown 0.0.1 → 0.1.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 ADDED
@@ -0,0 +1,48 @@
1
+ # Changelog
2
+
3
+ ## [0.1.0] - 2026-08-24
4
+
5
+ First release from the editor's own repository, and the first with CI on more
6
+ than one platform.
7
+
8
+ ### Fixed
9
+
10
+ - **A `[…]` keeps its brackets until it resolves to a link.** Lezer emits a
11
+ `Link` node for every bracketed span; CommonMark only makes `[label]` a link
12
+ when the document also carries a `[label]: url` definition. Reading the node
13
+ as the answer hid the brackets out of ordinary prose — `arr[0]` rendered as
14
+ `arr0`, `see [3]` as `see 3` — and stripped every optional argument from a
15
+ pasted LaTeX block (`\begin{tikzpicture}[scale=0.42]`). Unresolved, the
16
+ brackets are content: visible, unstyled, and no longer atomic to the caret.
17
+ This also matches what a CommonMark exporter produces for the same input.
18
+
19
+ - **A table cell renders what the paragraph above it renders.** Cells walk the
20
+ Lezer tree, and four constructs have no node in it — wikilinks,
21
+ `==highlight==`, footnote references and `$math$` are found by scanning text.
22
+ Cells showed `$x^2$` as source, and mangled two of the others outright
23
+ (`[[Some Note]]` → `[Some Note]`). Features now contribute a pattern and its
24
+ markup through `inlineScanRulesFacet`, and the cell renderer carves those
25
+ spans out before walking the tree.
26
+
27
+ - **A parse budget that holds on more than the fastest machine.** `treeAt` spent
28
+ at most 50ms driving the parse to a queried position, against a worst case
29
+ measured at 43ms — seven milliseconds of headroom. On a slower machine
30
+ `ensureSyntaxTree` returned null, the lookup fell back to the viewport-limited
31
+ tree, and a delete crossed a fence boundary the guard should have walled.
32
+ Now 150ms.
33
+
34
+ ### Added
35
+
36
+ - `inlineScanRulesFacet` and `scanInline` — the seam for a construct Lezer does
37
+ not parse, so any renderer can see it, not just the decoration plugins.
38
+ - `linkResolves` / `linkHasVisibleLabel` (`core/linkResolution`).
39
+
40
+ ### Documentation
41
+
42
+ - `styles.css` still called the package `ai-editor`, and both it and the README
43
+ promised that mathematics needs no CSS import. It does — KaTeX renders
44
+ through its own stylesheet, which no CodeMirror theme can supply.
45
+
46
+ ## [0.0.1] - 2026-08-23
47
+
48
+ Initial publish, extracted from the Compose monorepo.
package/README.md CHANGED
@@ -1,8 +1,14 @@
1
1
  # @latentic/live-markdown
2
2
 
3
- A rich markdown editor for React — write like Notion, store as plain `.md`.
3
+ [![npm](https://img.shields.io/npm/v/@latentic/live-markdown)](https://www.npmjs.com/package/@latentic/live-markdown)
4
+ [![CI](https://github.com/getlatentic/live-markdown/actions/workflows/ci.yml/badge.svg)](https://github.com/getlatentic/live-markdown/actions/workflows/ci.yml)
5
+ [![license](https://img.shields.io/npm/l/@latentic/live-markdown)](LICENSE)
4
6
 
5
- Built on [CodeMirror 6](https://codemirror.net/). Headings, bold, italic, lists, tables, images, math, footnotes, and code blocks render inline as you type. The file on disk is always standard markdown — no proprietary format, no AST translation layer, no lock-in.
7
+ **WYSIWYG markdown editing for React where the markdown is still the document.**
8
+
9
+ A markdown editor built on [CodeMirror 6](https://codemirror.net/). Headings, tables, LaTeX math, Mermaid diagrams, images, footnotes and code blocks render inline as you type — there is no split-screen preview pane to sync, and no syntax left on screen to read past.
10
+
11
+ And unlike rich-text editors built on ProseMirror, Tiptap, Slate or Lexical, there is no intermediate document model to translate through. `value` in and `onChange` out are the same markdown string, byte for byte, YAML frontmatter included.
6
12
 
7
13
  ```tsx
8
14
  import { CodeMirrorMarkdownEditor } from "@latentic/live-markdown";
@@ -25,7 +31,7 @@ Most markdown editors fall into two camps:
25
31
  | **Raw editors** | CodeMirror, Monaco, Ace | Fast, plain text — but users see `## Heading`, not a heading |
26
32
  | **Rich editors** | Tiptap, ProseMirror, Slate, Lexical | WYSIWYG — but the internal model is a custom AST, not markdown. Round-tripping to `.md` is lossy or fragile |
27
33
 
28
- **@latentic/live-markdown sits in between.** The source of truth is the raw markdown string. CodeMirror parses it with Lezer, and a decoration engine replaces syntax tokens with rendered widgets in real time — `## Heading` becomes a styled heading, `- item` becomes a bullet, `![alt](src)` becomes an inline image. You get the editing experience of Notion or Google Docs, but `value` in and `onChange` out is always a plain markdown string. No AST translation, no serialization bugs, no format lock-in.
34
+ **@latentic/live-markdown sits in between.** The source of truth is the raw markdown string. CodeMirror parses it with Lezer, and a decoration engine replaces syntax tokens with rendered widgets in real time — `## Heading` becomes a styled heading, `- item` becomes a bullet, `![alt](src)` becomes an inline image. You edit the rendered form directly no syntax on screen, no preview pane to keep in sync — while `value` in and `onChange` out stays a plain markdown string. No AST translation, no serialization bugs, no format lock-in.
29
35
 
30
36
  The boundary semantics this demands — what every keystroke does at every construct edge — are specified in [docs/interaction-spec.md](docs/interaction-spec.md) and enforced by its conformance matrix (`interactionMatrix.test.ts`); block-level behaviors are specified executably in `src/codemirror/features/*.feature`.
31
37
 
@@ -38,11 +44,14 @@ The boundary semantics this demands — what every keystroke does at every const
38
44
  | Round-trip fidelity | Byte-for-byte | Lossy (serializer) | Byte-for-byte | MDX subset | Lossy |
39
45
  | YAML frontmatter | Preserved, never stripped | Plugin (varies) | No | Plugin | No |
40
46
  | LaTeX math | Inline KaTeX | Plugin | No | Plugin | No |
47
+ | Mermaid diagrams | Rendered inline | Plugin | No | Plugin | No |
41
48
  | Large files (1 MB+) | Fast (CodeMirror) | Slow (DOM-per-node) | Fast | Slow | Slow |
42
49
  | Tables | GFM, cell navigation | Plugin | Basic | Plugin | Slash command |
43
50
  | Image paste/drop | Built-in | Plugin | No | Plugin | Plugin |
44
51
  | Framework | React | React / Vue / vanilla | Vanilla / adapters | React | React |
45
- | Bundle size | ~80 KB (gzip, editor core) | ~120 KB+ | ~40 KB | ~150 KB+ | ~200 KB+ |
52
+ | Bundle size | 57 KB gzipped* | ~120 KB+ | ~40 KB | ~150 KB+ | ~200 KB+ |
53
+
54
+ \* Measured on the published bundle. CodeMirror, KaTeX and Mermaid are external, so you pay for a renderer only where your documents use one — the other figures are the projects' own published numbers and are not all drawn on the same basis.
46
55
 
47
56
  ### Key differentiators
48
57
 
@@ -72,6 +81,7 @@ The boundary semantics this demands — what every keystroke does at every const
72
81
  - **Horizontal rules**
73
82
  - **Footnotes** — inline marker with hover preview
74
83
  - **LaTeX math** — inline `$...$` and display `$$...$$` via KaTeX
84
+ - **Mermaid diagrams** — a ```` ```mermaid ```` fence renders as a live diagram; click to select, double-click for the source
75
85
  - **Wikilinks** — `[[Page]]` and `[[Page|alias]]` with Cmd/Ctrl-click navigation
76
86
  - **Links** — Cmd/Ctrl-click to open, auto-detection
77
87
 
@@ -102,7 +112,7 @@ pnpm add @latentic/live-markdown
102
112
 
103
113
  Peer dependencies: `react` and `react-dom` (18+).
104
114
 
105
- All *in-editor* styling (headings, code, lists, tables, image widgets, math, links) ships with the editor as a CodeMirror theme and applies automatically — no CSS import needed. For the outer container layout (so the editor fills its parent and scrolls), import the small stylesheet once:
115
+ All *in-editor* styling (headings, code, lists, tables, image widgets, links) ships with the editor as a CodeMirror theme and applies automatically — no CSS import needed. For the outer container layout (so the editor fills its parent and scrolls), import the small stylesheet once:
106
116
 
107
117
  ```ts
108
118
  import "@latentic/live-markdown/styles.css";
@@ -110,6 +120,12 @@ import "@latentic/live-markdown/styles.css";
110
120
 
111
121
  Skip it if your app already lays the editor out as a flex child.
112
122
 
123
+ Mathematics is the exception. KaTeX renders through its own stylesheet, which no CodeMirror theme can supply — without it `$x$` still typesets, just unstyled (a fraction stops stacking). If your documents use math:
124
+
125
+ ```ts
126
+ import "katex/dist/katex.min.css";
127
+ ```
128
+
113
129
  ---
114
130
 
115
131
  ## Usage
@@ -254,12 +270,62 @@ The decoration engine walks the Lezer syntax tree on every document change, asks
254
270
  - [x] Host-environment seams (image storage/resolution, link opening) with browser defaults
255
271
  - [x] Self-themed editor surface (CodeMirror theme ships with the package)
256
272
  - [x] Public extension API for contributing node rules (`MarkdownExtension.rules` → `nodeRulesFacet`)
273
+ - [ ] [Ship the engine without React](https://github.com/getlatentic/live-markdown/issues/1) — the editor is React only at its edge: of 195 source files, one component and one `type ReactNode` import name it. Splitting `/react` into a subpath entry frees the engine for any framework, or none
257
274
  - [ ] Frontmatter as a toggleable extension (default on, disable via prop)
258
275
  - [ ] Collaborative editing (CM6 collab extension)
259
276
  - [ ] Slash commands (`/` menu for inserting blocks)
260
277
 
261
278
  ---
262
279
 
280
+ ## Development
281
+
282
+ ```sh
283
+ pnpm install
284
+ pnpm exec playwright install webkit # once — for the browser tier below
285
+ pnpm check # typecheck + build + both test tiers
286
+ ```
287
+
288
+ Tests run in two tiers, and the split is deliberate:
289
+
290
+ | tier | command | what it is for |
291
+ |---|---|---|
292
+ | default | `pnpm test` | everything that does not need layout — jsdom and node |
293
+ | browser | `pnpm test:browser` | real WebKit, for what jsdom cannot judge |
294
+
295
+ jsdom has no layout engine, so caret geometry, click placement, drawn selection
296
+ and KaTeX metrics all pass in it whatever they do on screen. Anything that
297
+ depends on where a box actually lands belongs in a `*.browser.test.ts`.
298
+
299
+ `pnpm build` is part of the gate rather than a release step: `tsc --noEmit`
300
+ never emits, so the errors that appear only when rolling up declarations — an
301
+ exported anonymous class, an inferred type naming a transitive dependency by
302
+ its package-manager path — stay invisible until a publish fails.
303
+
304
+ ### Releasing
305
+
306
+ Publishing is caused by green CI, not preceded by it. `cargo`-style local
307
+ publishing is not wired up on purpose: a published version cannot be taken
308
+ back.
309
+
310
+ ```sh
311
+ # 1. bump the version in package.json, land it on main
312
+ # 2. tag the merged commit
313
+ git tag v0.1.0 && git push origin v0.1.0
314
+ ```
315
+
316
+ The tag triggers `publish.yml`, which refuses to build at all if the tag is not
317
+ an ancestor of `main` or disagrees with `package.json`, runs the full CI
318
+ workflow against the tagged commit as a job it `needs`, and only then publishes.
319
+
320
+ There is no npm token anywhere — not in the repository, not on a laptop.
321
+ Publishing uses [trusted publishing](https://docs.npmjs.com/trusted-publishers):
322
+ npm accepts a short-lived OIDC token minted for this workflow in this
323
+ repository, so the credential cannot be leaked, reused, or forgotten about, and
324
+ [provenance](https://docs.npmjs.com/generating-provenance-statements) is
325
+ generated automatically from it.
326
+
327
+ ---
328
+
263
329
  ## License
264
330
 
265
331
  [MIT](LICENSE)
package/dist/index.d.ts CHANGED
@@ -1,6 +1,5 @@
1
1
  import * as react from 'react';
2
2
  import { ReactNode } from 'react';
3
- import * as react_jsx_runtime from 'react/jsx-runtime';
4
3
  import * as _codemirror_view from '@codemirror/view';
5
4
  import { Decoration, ViewPlugin, DecorationSet, ViewUpdate, EditorView, Command, KeyBinding } from '@codemirror/view';
6
5
  import * as _codemirror_state from '@codemirror/state';
@@ -406,7 +405,7 @@ interface CodeMirrorMarkdownEditorProps {
406
405
  */
407
406
  onFlushReady?: (flush: (() => void) | null) => void;
408
407
  }
409
- declare function CodeMirrorMarkdownEditorInner({ mode, onChange, value, workspaceRoot, filePath, linkTargets, onNavigateToLink, toolbar, selectionActions, resolveImageSrc, saveImageBytes, onOpenExternalUrl, onCommentOnExcerpt, renderClipboardHtml, onAfterContentSwap, onFlushReady, }: CodeMirrorMarkdownEditorProps): react_jsx_runtime.JSX.Element;
408
+ declare function CodeMirrorMarkdownEditorInner({ mode, onChange, value, workspaceRoot, filePath, linkTargets, onNavigateToLink, toolbar, selectionActions, resolveImageSrc, saveImageBytes, onOpenExternalUrl, onCommentOnExcerpt, renderClipboardHtml, onAfterContentSwap, onFlushReady, }: CodeMirrorMarkdownEditorProps): react.JSX.Element;
410
409
  /**
411
410
  * Memoised export — same reason as the Tiptap editor. AppShell
412
411
  * re-renders on every chat-thread token; without memoisation each
package/dist/index.js CHANGED
@@ -356,7 +356,7 @@ var verbatimPasteKeymap = keymap.of([
356
356
  }
357
357
  ]);
358
358
  var markdownPaste = [pasteHandler, verbatimPasteKeymap];
359
- var PARSE_BUDGET_MS = 50;
359
+ var PARSE_BUDGET_MS = 150;
360
360
  function treeAt(state, pos) {
361
361
  return ensureSyntaxTree(state, pos + 1, PARSE_BUDGET_MS) ?? syntaxTree(state);
362
362
  }
@@ -1514,6 +1514,38 @@ var nodeRulesFacet = Facet.define({
1514
1514
  return Object.assign({}, ...values);
1515
1515
  }
1516
1516
  });
1517
+ var inlineScanRulesFacet = Facet.define({
1518
+ combine: (values) => values
1519
+ });
1520
+ function scanInline(rules, text, at = 0) {
1521
+ const found = [];
1522
+ for (const rule of rules) {
1523
+ rule.pattern.lastIndex = 0;
1524
+ let match;
1525
+ while ((match = rule.pattern.exec(text)) !== null) {
1526
+ found.push({
1527
+ from: at + match.index,
1528
+ to: at + match.index + match[0].length,
1529
+ html: rule.render(match)
1530
+ });
1531
+ }
1532
+ }
1533
+ found.sort((a, b) => a.from - b.from || b.to - a.to);
1534
+ const kept = [];
1535
+ for (const span of found) {
1536
+ const last = kept[kept.length - 1];
1537
+ if (!last || span.from >= last.to) kept.push(span);
1538
+ }
1539
+ return kept;
1540
+ }
1541
+
1542
+ // src/codemirror/core/htmlEscape.ts
1543
+ function escapeText(value) {
1544
+ return value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
1545
+ }
1546
+ function escapeAttr(value) {
1547
+ return escapeText(value).replace(/"/g, "&quot;");
1548
+ }
1517
1549
  var SLICE_MS = 10;
1518
1550
  var GAP_MS = 25;
1519
1551
  var parseToEnd = ViewPlugin.fromClass(
@@ -1955,6 +1987,73 @@ var imageRule = (ctx) => {
1955
1987
  })
1956
1988
  };
1957
1989
  };
1990
+ var CODE_NODES = /* @__PURE__ */ new Set(["FencedCode", "CodeBlock", "InlineCode"]);
1991
+ function viewportTree(view) {
1992
+ return ensureSyntaxTree(view.state, view.viewport.to, 100) ?? syntaxTree(view.state);
1993
+ }
1994
+ function docTree(state) {
1995
+ return ensureSyntaxTree(state, state.doc.length, 20) ?? syntaxTree(state);
1996
+ }
1997
+ function inCode(tree, pos) {
1998
+ let node = tree.resolveInner(pos, 1);
1999
+ for (; node; node = node.parent) {
2000
+ if (CODE_NODES.has(node.name)) return true;
2001
+ }
2002
+ return false;
2003
+ }
2004
+
2005
+ // src/codemirror/core/linkResolution.ts
2006
+ var DEFINITION_CONTAINERS = /* @__PURE__ */ new Set([
2007
+ "Document",
2008
+ "Blockquote",
2009
+ "BulletList",
2010
+ "OrderedList",
2011
+ "ListItem"
2012
+ ]);
2013
+ function normalizeLabel(label) {
2014
+ return label.trim().replace(/\s+/g, " ").toLowerCase();
2015
+ }
2016
+ function labelInner(state, label) {
2017
+ return state.sliceDoc(label.from + 1, label.to - 1);
2018
+ }
2019
+ var definedLabels = /* @__PURE__ */ new WeakMap();
2020
+ function definitions(state) {
2021
+ const cached = definedLabels.get(state);
2022
+ if (cached) return cached;
2023
+ const labels = /* @__PURE__ */ new Set();
2024
+ docTree(state).iterate({
2025
+ enter: (node) => {
2026
+ if (node.name !== "LinkReference") return DEFINITION_CONTAINERS.has(node.name);
2027
+ const label = node.node.getChild("LinkLabel");
2028
+ if (label) labels.add(normalizeLabel(labelInner(state, label)));
2029
+ return false;
2030
+ }
2031
+ });
2032
+ definedLabels.set(state, labels);
2033
+ return labels;
2034
+ }
2035
+ function ownLabel(state, link) {
2036
+ let open = null;
2037
+ let close = null;
2038
+ for (let child = link.firstChild; child; child = child.nextSibling) {
2039
+ if (child.name !== "LinkMark") continue;
2040
+ const mark2 = state.sliceDoc(child.from, child.to);
2041
+ if (mark2 === "[" && !open) open = child;
2042
+ else if (mark2 === "]" && !close) close = child;
2043
+ }
2044
+ if (!open || !close || close.from <= open.to) return null;
2045
+ return state.sliceDoc(open.to, close.from);
2046
+ }
2047
+ function linkResolves(link, state) {
2048
+ if (link.getChild("URL")) return true;
2049
+ const reference = link.getChild("LinkLabel");
2050
+ const explicit = reference ? labelInner(state, reference) : "";
2051
+ const key = explicit.trim() === "" ? ownLabel(state, link) ?? "" : explicit;
2052
+ return key.trim() !== "" && definitions(state).has(normalizeLabel(key));
2053
+ }
2054
+ function linkHasVisibleLabel(link, state) {
2055
+ return (ownLabel(state, link) ?? "").trim() !== "";
2056
+ }
1958
2057
  var BulletWidget = class extends WidgetType {
1959
2058
  /**
1960
2059
  * Eq returns true if two widgets are interchangeable — when CM6
@@ -2068,19 +2167,8 @@ var taskMarkerRule = (ctx) => {
2068
2167
  };
2069
2168
 
2070
2169
  // src/codemirror/core/registry.ts
2071
- function linkHasVisibleLabel(ctx) {
2072
- const link = ctx.node.parent;
2073
- if (!link) return false;
2074
- let bracketOpen = null;
2075
- let bracketClose = null;
2076
- for (let child = link.firstChild; child; child = child.nextSibling) {
2077
- if (child.name !== "LinkMark") continue;
2078
- const mark2 = ctx.state.sliceDoc(child.from, child.to);
2079
- if (mark2 === "[" && !bracketOpen) bracketOpen = child;
2080
- else if (mark2 === "]" && !bracketClose) bracketClose = child;
2081
- }
2082
- if (!bracketOpen || !bracketClose || bracketClose.from <= bracketOpen.to) return false;
2083
- return ctx.state.sliceDoc(bracketOpen.to, bracketClose.from).trim() !== "";
2170
+ function parentLink(ctx) {
2171
+ return ctx.parentName === "Link" ? ctx.node.parent : null;
2084
2172
  }
2085
2173
  var NODE_RULES = {
2086
2174
  // ----- Structural wrappers (never directly styled) -----
@@ -2111,7 +2199,11 @@ var NODE_RULES = {
2111
2199
  Emphasis: mark("cm-emphasis"),
2112
2200
  StrongEmphasis: mark("cm-strong"),
2113
2201
  InlineCode: mark("cm-inline-code"),
2114
- Link: mark("cm-link"),
2202
+ // A `Link` node is not yet a link: Lezer emits one for every `[…]`, and the
2203
+ // reference lookup CommonMark requires is left to us. Unresolved, it is the
2204
+ // literal brackets the author typed — most `[…]` in prose, and every optional
2205
+ // argument in a LaTeX block.
2206
+ Link: (ctx) => linkResolves(ctx.node, ctx.state) ? { paint: "mark", className: "cm-link" } : none,
2115
2207
  Image: imageRule,
2116
2208
  // `![alt](src)` → inline `<img>` widget
2117
2209
  // ----- Inline literal sub-nodes (rendered inside their parent) -----
@@ -2121,8 +2213,9 @@ var NODE_RULES = {
2121
2213
  // Bare GFM autolinks and <angle> autolinks emit the SAME node name, and
2122
2214
  // there the URL IS the content — same class of bug when hidden.
2123
2215
  URL: (ctx) => {
2124
- if (ctx.parentName !== "Link") return { paint: "mark", className: "cm-link" };
2125
- return linkHasVisibleLabel(ctx) ? { paint: "hide" } : { paint: "mark", className: "cm-link" };
2216
+ const link = parentLink(ctx);
2217
+ if (!link) return { paint: "mark", className: "cm-link" };
2218
+ return linkHasVisibleLabel(link, ctx.state) ? { paint: "hide" } : { paint: "mark", className: "cm-link" };
2126
2219
  },
2127
2220
  LinkLabel: raw("visible inside Link; parent mark styles it"),
2128
2221
  LinkTitle: hideAlways(),
@@ -2153,8 +2246,12 @@ var NODE_RULES = {
2153
2246
  // `*` / `_`
2154
2247
  CodeMark: hideAlways(),
2155
2248
  // backticks for inline / fence pairs for blocks
2156
- LinkMark: hideAlways(),
2157
- // `[`/`]`/`(`/`)`
2249
+ // `[`/`]`/`(`/`)` — chrome only where they really are chrome. An unresolved
2250
+ // link's brackets are content, and hiding them rewrote the document on screen.
2251
+ LinkMark: (ctx) => {
2252
+ const link = parentLink(ctx);
2253
+ return link && !linkResolves(link, ctx.state) ? none : { paint: "hide" };
2254
+ },
2158
2255
  QuoteMark: hideAlways(),
2159
2256
  // `>`
2160
2257
  ListMark: listMarkRule,
@@ -2902,20 +2999,6 @@ function slugKey(value) {
2902
2999
  }
2903
3000
  return raw2.split("-").filter(Boolean).join("-");
2904
3001
  }
2905
- var CODE_NODES = /* @__PURE__ */ new Set(["FencedCode", "CodeBlock", "InlineCode"]);
2906
- function viewportTree(view) {
2907
- return ensureSyntaxTree(view.state, view.viewport.to, 100) ?? syntaxTree(view.state);
2908
- }
2909
- function docTree(state) {
2910
- return ensureSyntaxTree(state, state.doc.length, 20) ?? syntaxTree(state);
2911
- }
2912
- function inCode(tree, pos) {
2913
- let node = tree.resolveInner(pos, 1);
2914
- for (; node; node = node.parent) {
2915
- if (CODE_NODES.has(node.name)) return true;
2916
- }
2917
- return false;
2918
- }
2919
3002
  var WIKILINK_RE = /\[\[([^\]\n]+?)\]\]/g;
2920
3003
  var HIDE = Decoration.replace({});
2921
3004
  var linkMark = Decoration.mark({ class: "cm-wikilink" });
@@ -3073,12 +3156,6 @@ var clickModel = EditorView.domEventHandlers({
3073
3156
  });
3074
3157
 
3075
3158
  // src/codemirror/table/tableInline.ts
3076
- function escapeText(value) {
3077
- return value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
3078
- }
3079
- function escapeAttr(value) {
3080
- return escapeText(value).replace(/"/g, "&quot;");
3081
- }
3082
3159
  function codeText(state, node) {
3083
3160
  let innerFrom = node.from;
3084
3161
  let innerTo = node.to;
@@ -3154,8 +3231,26 @@ function renderRange(state, parent, from, to) {
3154
3231
  if (pos < to) html += state.sliceDoc(pos, to);
3155
3232
  return html;
3156
3233
  }
3234
+ function straddlesChild(cell, from, to) {
3235
+ for (let child = cell.firstChild; child; child = child.nextSibling) {
3236
+ if (child.from < from && child.to > from) return true;
3237
+ if (child.from < to && child.to > to) return true;
3238
+ }
3239
+ return false;
3240
+ }
3157
3241
  function renderInlineCell(state, cell) {
3158
- return renderRange(state, cell, cell.from, cell.to).trim();
3242
+ const rules = state.facet(inlineScanRulesFacet);
3243
+ const tree = docTree(state);
3244
+ let html = "";
3245
+ let pos = cell.from;
3246
+ for (const span of scanInline(rules, state.sliceDoc(cell.from, cell.to), cell.from)) {
3247
+ if (span.from < pos) continue;
3248
+ if (inCode(tree, span.from) || inCode(tree, span.to - 1)) continue;
3249
+ if (straddlesChild(cell, span.from, span.to)) continue;
3250
+ html += renderRange(state, cell, pos, span.from) + span.html;
3251
+ pos = span.to;
3252
+ }
3253
+ return (html + renderRange(state, cell, pos, cell.to)).trim();
3159
3254
  }
3160
3255
 
3161
3256
  // src/codemirror/table/tableModel.ts
@@ -3256,8 +3351,9 @@ var CELL_SANITIZE_CONFIG = {
3256
3351
  ALLOWED_ATTR: ["href", "class"],
3257
3352
  RETURN_TRUSTED_TYPE: false
3258
3353
  };
3259
- function renderCellInto(el, source) {
3354
+ function renderCellInto(el, source, rules) {
3260
3355
  el.innerHTML = DOMPurify.sanitize(source, CELL_SANITIZE_CONFIG);
3356
+ for (const rule of rules) rule.hydrate?.(el);
3261
3357
  }
3262
3358
  function tableV2Sync(surface) {
3263
3359
  return EditorView.updateListener.of((update) => {
@@ -3268,8 +3364,8 @@ function tableV2Sync(surface) {
3268
3364
  }
3269
3365
 
3270
3366
  // src/codemirror/tablev2/tableWidgetV2.ts
3271
- function fillCell(el, cell, row, col) {
3272
- renderCellInto(el, cell.html);
3367
+ function fillCell(el, cell, row, col, rules) {
3368
+ renderCellInto(el, cell.html, rules);
3273
3369
  el.dataset.row = String(row);
3274
3370
  el.dataset.col = String(col);
3275
3371
  el.dataset.cellFrom = String(cell.from);
@@ -3297,7 +3393,8 @@ var TableWidgetV2 = class extends WidgetType {
3297
3393
  eq(other) {
3298
3394
  return other.sourceFrom === this.sourceFrom && other.sourceTo === this.sourceTo && JSON.stringify(other.data) === JSON.stringify(this.data);
3299
3395
  }
3300
- toDOM() {
3396
+ toDOM(view) {
3397
+ const rules = view.state.facet(inlineScanRulesFacet);
3301
3398
  const wrap = document.createElement("div");
3302
3399
  wrap.className = "cm-tablev2-wrap cm-table-wrap";
3303
3400
  wrap.dataset.tablev2From = String(this.sourceFrom);
@@ -3308,7 +3405,7 @@ var TableWidgetV2 = class extends WidgetType {
3308
3405
  const headRow = document.createElement("tr");
3309
3406
  this.data.header.forEach((cell, col) => {
3310
3407
  const th = document.createElement("th");
3311
- fillCell(th, cell, 0, col);
3408
+ fillCell(th, cell, 0, col, rules);
3312
3409
  const align = this.data.alignments[col];
3313
3410
  if (align) th.style.textAlign = align;
3314
3411
  headRow.appendChild(th);
@@ -3320,7 +3417,7 @@ var TableWidgetV2 = class extends WidgetType {
3320
3417
  const tr = document.createElement("tr");
3321
3418
  cells2.forEach((cell, col) => {
3322
3419
  const td = document.createElement("td");
3323
- fillCell(td, cell, r + 1, col);
3420
+ fillCell(td, cell, r + 1, col, rules);
3324
3421
  const align = this.data.alignments[col];
3325
3422
  if (align) td.style.textAlign = align;
3326
3423
  tr.appendChild(td);
@@ -3331,7 +3428,8 @@ var TableWidgetV2 = class extends WidgetType {
3331
3428
  wrap.appendChild(table);
3332
3429
  return wrap;
3333
3430
  }
3334
- updateDOM(dom) {
3431
+ updateDOM(dom, view) {
3432
+ const rules = view.state.facet(inlineScanRulesFacet);
3335
3433
  if (dom.dataset.tablev2From === void 0) return false;
3336
3434
  const rows = dom.querySelectorAll("tr");
3337
3435
  if (rows.length !== 1 + this.data.rows.length) return false;
@@ -3345,7 +3443,7 @@ var TableWidgetV2 = class extends WidgetType {
3345
3443
  for (let r = 0; r < grid.length; r++) {
3346
3444
  const cells2 = rows[r].children;
3347
3445
  grid[r].forEach((cell, col) => {
3348
- fillCell(cells2[col], cell, r, col);
3446
+ fillCell(cells2[col], cell, r, col, rules);
3349
3447
  });
3350
3448
  }
3351
3449
  return true;
@@ -4293,12 +4391,19 @@ var highlightPlugin = ViewPlugin.fromClass(
4293
4391
  }
4294
4392
  );
4295
4393
 
4394
+ // src/codemirror/highlight/highlightScanRule.ts
4395
+ var highlightScanRule = {
4396
+ name: "highlight",
4397
+ pattern: HIGHLIGHT_RE,
4398
+ render: (match) => `<span class="cm-highlight">${escapeText(match[1] ?? "")}</span>`
4399
+ };
4400
+
4296
4401
  // src/codemirror/extensions/highlightExtension.ts
4297
4402
  var highlightExtension = {
4298
4403
  name: "@compose/highlight",
4299
4404
  version: "0.1.0",
4300
4405
  description: "Renders `==text==` with a yellow highlight background.",
4301
- extensions: [highlightPlugin]
4406
+ extensions: [highlightPlugin, inlineScanRulesFacet.of(highlightScanRule)]
4302
4407
  };
4303
4408
  var FOOTNOTE_REF_RE = /(?<!\])\[\^([^\]\s]+)\](?!:)/g;
4304
4409
  var FOOTNOTE_DEF_LINE_RE = /^\[\^([^\]\s]+)\]:\s/;
@@ -4365,13 +4470,27 @@ var footnotePlugin = ViewPlugin.fromClass(
4365
4470
  }
4366
4471
  );
4367
4472
 
4473
+ // src/codemirror/footnote/footnoteScanRule.ts
4474
+ var footnoteScanRule = {
4475
+ name: "footnote",
4476
+ pattern: FOOTNOTE_REF_RE,
4477
+ render: (match) => `<span class="cm-footnote-ref">${escapeText(match[1] ?? "")}</span>`
4478
+ };
4479
+
4368
4480
  // src/codemirror/extensions/footnoteExtension.ts
4369
4481
  var footnoteExtension = {
4370
4482
  name: "@compose/footnote",
4371
4483
  version: "0.1.0",
4372
4484
  description: "Renders `[^id]` references and `[^id]:` definitions with tooltip jump.",
4373
- extensions: [footnotePlugin]
4485
+ extensions: [footnotePlugin, inlineScanRulesFacet.of(footnoteScanRule)]
4374
4486
  };
4487
+ function renderMathInto(el, tex, displayMode) {
4488
+ try {
4489
+ katex.render(tex, el, { displayMode, throwOnError: false, output: "html" });
4490
+ } catch {
4491
+ el.textContent = tex;
4492
+ }
4493
+ }
4375
4494
  var MathWidget = class extends WidgetType {
4376
4495
  constructor(tex, displayMode) {
4377
4496
  super();
@@ -4384,15 +4503,7 @@ var MathWidget = class extends WidgetType {
4384
4503
  toDOM(_view) {
4385
4504
  const span = document.createElement(this.displayMode ? "div" : "span");
4386
4505
  span.className = this.displayMode ? "cm-math-block" : "cm-math-inline";
4387
- try {
4388
- katex.render(this.tex, span, {
4389
- displayMode: this.displayMode,
4390
- throwOnError: false,
4391
- output: "html"
4392
- });
4393
- } catch {
4394
- span.textContent = this.tex;
4395
- }
4506
+ renderMathInto(span, this.tex, this.displayMode);
4396
4507
  return span;
4397
4508
  }
4398
4509
  ignoreEvent() {
@@ -4465,12 +4576,24 @@ var mathPlugin = StateField.define({
4465
4576
  ]
4466
4577
  });
4467
4578
 
4579
+ // src/codemirror/math/mathScanRule.ts
4580
+ var mathScanRule = {
4581
+ name: "math",
4582
+ pattern: INLINE_MATH_RE,
4583
+ render: (match) => `<span class="cm-math-inline">${escapeText(match[1] ?? "")}</span>`,
4584
+ hydrate: (root) => {
4585
+ for (const el of root.querySelectorAll(".cm-math-inline")) {
4586
+ renderMathInto(el, el.textContent ?? "", false);
4587
+ }
4588
+ }
4589
+ };
4590
+
4468
4591
  // src/codemirror/extensions/mathExtension.ts
4469
4592
  var mathExtension = {
4470
4593
  name: "@compose/math",
4471
4594
  version: "0.1.0",
4472
4595
  description: "Renders `$x$` inline and `$$x$$` block math via KaTeX.",
4473
- extensions: [mathPlugin]
4596
+ extensions: [mathPlugin, inlineScanRulesFacet.of(mathScanRule)]
4474
4597
  };
4475
4598
 
4476
4599
  // src/codemirror/extensions/mermaidExtension.ts
@@ -4778,7 +4901,7 @@ var InlineCellSurface = class {
4778
4901
  }
4779
4902
  const model = modelAt(view.state, s.tableFrom);
4780
4903
  const cell = model ? cellAt(model, s.ref.row, s.ref.col) : null;
4781
- if (cell) renderCellInto(s.el, cell.html);
4904
+ if (cell) renderCellInto(s.el, cell.html, view.state.facet(inlineScanRulesFacet));
4782
4905
  }
4783
4906
  cancel() {
4784
4907
  const s = this.edit;
@@ -5385,12 +5508,19 @@ function tableExtension() {
5385
5508
  };
5386
5509
  }
5387
5510
 
5511
+ // src/codemirror/wikilink/wikilinkScanRule.ts
5512
+ var wikilinkScanRule = {
5513
+ name: "wikilink",
5514
+ pattern: WIKILINK_RE,
5515
+ render: (match) => `<span class="cm-wikilink">${escapeText(parseWikilinkBody(match[1] ?? "").label)}</span>`
5516
+ };
5517
+
5388
5518
  // src/codemirror/extensions/wikilinkExtension.ts
5389
5519
  var wikilinkExtension = {
5390
5520
  name: "@compose/wikilink",
5391
5521
  version: "0.1.0",
5392
5522
  description: "Renders `[[target]]` / `[[target|alias]]` as clickable links.",
5393
- extensions: [wikilinkPlugin]
5523
+ extensions: [wikilinkPlugin, inlineScanRulesFacet.of(wikilinkScanRule)]
5394
5524
  };
5395
5525
  var programmaticSwap = Annotation.define();
5396
5526
  var AUTOSAVE_DEBOUNCE_MS = 500;