@latentic/live-markdown 0.0.1 → 0.1.1

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,58 @@
1
+ # Changelog
2
+
3
+ ## [0.1.1] - 2026-08-24
4
+
5
+ ### Added
6
+
7
+ - `inlineScanRulesFacet`, `scanInline`, `escapeText`/`escapeAttr` and their
8
+ types are exported. 0.1.0 shipped the seam but left it unreachable: a
9
+ third-party extension could contribute a rule for a construct Lezer parses
10
+ (`nodeRulesFacet`, public since 0.0.1) but not for one it does not, which is
11
+ half a contract.
12
+
13
+ ## [0.1.0] - 2026-08-24
14
+
15
+ First release from the editor's own repository, and the first with CI on more
16
+ than one platform.
17
+
18
+ ### Fixed
19
+
20
+ - **A `[…]` keeps its brackets until it resolves to a link.** Lezer emits a
21
+ `Link` node for every bracketed span; CommonMark only makes `[label]` a link
22
+ when the document also carries a `[label]: url` definition. Reading the node
23
+ as the answer hid the brackets out of ordinary prose — `arr[0]` rendered as
24
+ `arr0`, `see [3]` as `see 3` — and stripped every optional argument from a
25
+ pasted LaTeX block (`\begin{tikzpicture}[scale=0.42]`). Unresolved, the
26
+ brackets are content: visible, unstyled, and no longer atomic to the caret.
27
+ This also matches what a CommonMark exporter produces for the same input.
28
+
29
+ - **A table cell renders what the paragraph above it renders.** Cells walk the
30
+ Lezer tree, and four constructs have no node in it — wikilinks,
31
+ `==highlight==`, footnote references and `$math$` are found by scanning text.
32
+ Cells showed `$x^2$` as source, and mangled two of the others outright
33
+ (`[[Some Note]]` → `[Some Note]`). Features now contribute a pattern and its
34
+ markup through `inlineScanRulesFacet`, and the cell renderer carves those
35
+ spans out before walking the tree.
36
+
37
+ - **A parse budget that holds on more than the fastest machine.** `treeAt` spent
38
+ at most 50ms driving the parse to a queried position, against a worst case
39
+ measured at 43ms — seven milliseconds of headroom. On a slower machine
40
+ `ensureSyntaxTree` returned null, the lookup fell back to the viewport-limited
41
+ tree, and a delete crossed a fence boundary the guard should have walled.
42
+ Now 150ms.
43
+
44
+ ### Added
45
+
46
+ - `inlineScanRulesFacet` and `scanInline` — the seam for a construct Lezer does
47
+ not parse, so any renderer can see it, not just the decoration plugins.
48
+ - `linkResolves` / `linkHasVisibleLabel` (`core/linkResolution`).
49
+
50
+ ### Documentation
51
+
52
+ - `styles.css` still called the package `ai-editor`, and both it and the README
53
+ promised that mathematics needs no CSS import. It does — KaTeX renders
54
+ through its own stylesheet, which no CodeMirror theme can supply.
55
+
56
+ ## [0.0.1] - 2026-08-23
57
+
58
+ 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';
@@ -161,6 +160,57 @@ declare function structural(why: string): NodeRule;
161
160
  */
162
161
  declare const nodeRulesFacet: Facet<Readonly<Record<string, NodeRule>>, Readonly<Record<string, NodeRule>>>;
163
162
 
163
+ /**
164
+ * Constructs Lezer never parses.
165
+ *
166
+ * Wikilinks, `==highlight==`, footnote references and `$math$` are not
167
+ * CommonMark, so no node exists for any of them and the editor finds each by
168
+ * scanning text (`codeContext.ts` documents the shared code guard). A renderer
169
+ * that walks the tree is therefore blind to all four — which is how a table cell
170
+ * came to show `$440 = 2 \times \frac{22}{7} \times r$` as literal source while
171
+ * the same expression rendered as mathematics in the paragraph above it.
172
+ *
173
+ * A feature contributes its pattern and its markup through this facet; the
174
+ * string renderers consult the facet and stay ignorant of what the constructs
175
+ * are. The decoration plugins keep their own viewport-scanning path — the two
176
+ * paths share the pattern, not the machinery, because one produces decorations
177
+ * over a live document and the other an HTML string.
178
+ */
179
+
180
+ interface InlineScanRule {
181
+ readonly name: string;
182
+ /** Global-flagged; the scanner drives `exec` and resets `lastIndex`. */
183
+ readonly pattern: RegExp;
184
+ /** Markup for one match. Escaping document text is the rule's job. */
185
+ render(match: RegExpExecArray): string;
186
+ /**
187
+ * Upgrade rendered markup to real DOM, after sanitisation. Only for a
188
+ * construct whose rendering is DOM rather than markup (KaTeX): the generated
189
+ * nodes bypass the sanitiser, so a hydrate reads inert text from the element
190
+ * it replaces and never trusts the document.
191
+ */
192
+ hydrate?(root: HTMLElement): void;
193
+ }
194
+ declare const inlineScanRulesFacet: Facet<InlineScanRule, readonly InlineScanRule[]>;
195
+ interface InlineScanMatch {
196
+ readonly from: number;
197
+ readonly to: number;
198
+ readonly html: string;
199
+ }
200
+ /**
201
+ * Every rule's matches in `text`, in document order, offset by `at`.
202
+ *
203
+ * Earliest wins, then longest — two constructs cannot both own one span, and
204
+ * the alternative (rule declaration order) would make the result depend on
205
+ * extension load order.
206
+ */
207
+ declare function scanInline(rules: readonly InlineScanRule[], text: string, at?: number): InlineScanMatch[];
208
+
209
+ /** Entity-escaping for the string renderers (table cells), which build HTML
210
+ * text rather than DOM and so must neutralise markup in document content. */
211
+ declare function escapeText(value: string): string;
212
+ declare function escapeAttr(value: string): string;
213
+
164
214
  /**
165
215
  * A syntax tree that reaches `pos`.
166
216
  *
@@ -406,7 +456,7 @@ interface CodeMirrorMarkdownEditorProps {
406
456
  */
407
457
  onFlushReady?: (flush: (() => void) | null) => void;
408
458
  }
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;
459
+ declare function CodeMirrorMarkdownEditorInner({ mode, onChange, value, workspaceRoot, filePath, linkTargets, onNavigateToLink, toolbar, selectionActions, resolveImageSrc, saveImageBytes, onOpenExternalUrl, onCommentOnExcerpt, renderClipboardHtml, onAfterContentSwap, onFlushReady, }: CodeMirrorMarkdownEditorProps): react.JSX.Element;
410
460
  /**
411
461
  * Memoised export — same reason as the Tiptap editor. AppShell
412
462
  * re-renders on every chat-thread token; without memoisation each
@@ -802,4 +852,4 @@ interface ResolveWorkspaceLinkOptions {
802
852
  }
803
853
  declare function resolveWorkspaceLink(href: string, options: ResolveWorkspaceLinkOptions): ResolvedWorkspaceLink | null;
804
854
 
805
- export { type CaretContextSnapshot, type CodeMirrorEditorMode, CodeMirrorMarkdownEditor, type CodeMirrorMarkdownEditorProps, type ComposedExtension, type DocumentTextChange, type EditorSelectionSnapshot, type Frontmatter, type FrontmatterValue, type HighlightedSpan, IMAGE_EDIT_ALT_EVENT, type ImageEditAltEventDetail, type ImageInsertOptions, type ImageInsertResult, type ImageResolveContext, type MarkdownDocument, type MarkdownExtension, type MermaidRenderResult, type NodeContext, type NodeRule, type NodeRules, type OpenExternalUrl, type Paint, type ResolveImageSrc, type ResolveWorkspaceLinkOptions, type ResolvedWorkspaceLink, type SaveImageBytes, type SourceRange, type ToolbarContribution, blockCommands, buildImageMarkdown, composeExtensions, computeFileDir, defaultResolveImageSrc, dirnamePath, editorBaseTheme, extractImageBlobs, extractImageFiles, footnoteExtension, formatCommands, getCachedMermaidPng, hasUriScheme, headingLine, hideAlways, highlightExtension, highlightFenceSpans, imageInsertHandlers, insertImageBlob, isAbsolutePath, isMermaidFenceInfo, joinPath, line, mark, markdownDecorationsPlugin, mathExtension, mermaidExtension, nodeRulesFacet, onEditorUpdate, parseFrontmatter, parseWikilinkBody, pickImageFileForCaret, raw, renderMermaidToSvg, resolveWikilinkTarget, resolveWorkspaceLink, serializeMarkdown, setFrontmatterField, showImageActionMenu, structural, tableExtension, treeAt, warmMermaidPng, wikilinkExtension };
855
+ export { type CaretContextSnapshot, type CodeMirrorEditorMode, CodeMirrorMarkdownEditor, type CodeMirrorMarkdownEditorProps, type ComposedExtension, type DocumentTextChange, type EditorSelectionSnapshot, type Frontmatter, type FrontmatterValue, type HighlightedSpan, IMAGE_EDIT_ALT_EVENT, type ImageEditAltEventDetail, type ImageInsertOptions, type ImageInsertResult, type ImageResolveContext, type InlineScanMatch, type InlineScanRule, type MarkdownDocument, type MarkdownExtension, type MermaidRenderResult, type NodeContext, type NodeRule, type NodeRules, type OpenExternalUrl, type Paint, type ResolveImageSrc, type ResolveWorkspaceLinkOptions, type ResolvedWorkspaceLink, type SaveImageBytes, type SourceRange, type ToolbarContribution, blockCommands, buildImageMarkdown, composeExtensions, computeFileDir, defaultResolveImageSrc, dirnamePath, editorBaseTheme, escapeAttr, escapeText, extractImageBlobs, extractImageFiles, footnoteExtension, formatCommands, getCachedMermaidPng, hasUriScheme, headingLine, hideAlways, highlightExtension, highlightFenceSpans, imageInsertHandlers, inlineScanRulesFacet, insertImageBlob, isAbsolutePath, isMermaidFenceInfo, joinPath, line, mark, markdownDecorationsPlugin, mathExtension, mermaidExtension, nodeRulesFacet, onEditorUpdate, parseFrontmatter, parseWikilinkBody, pickImageFileForCaret, raw, renderMermaidToSvg, resolveWikilinkTarget, resolveWorkspaceLink, scanInline, serializeMarkdown, setFrontmatterField, showImageActionMenu, structural, tableExtension, treeAt, warmMermaidPng, wikilinkExtension };