@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 +58 -0
- package/README.md +71 -5
- package/dist/index.d.ts +53 -3
- package/dist/index.js +193 -63
- package/dist/index.js.map +1 -1
- package/dist/styles.css +7 -2
- package/package.json +43 -17
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
|
-
|
|
3
|
+
[](https://www.npmjs.com/package/@latentic/live-markdown)
|
|
4
|
+
[](https://github.com/getlatentic/live-markdown/actions/workflows/ci.yml)
|
|
5
|
+
[](LICENSE)
|
|
4
6
|
|
|
5
|
-
|
|
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, `` becomes an inline image. You
|
|
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, `` 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 |
|
|
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,
|
|
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):
|
|
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 };
|