Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion packages/rich-editor/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -102,14 +102,20 @@ pnpm add @latentic/live-markdown

Peer dependencies: `react` and `react-dom` (18+).

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:
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:

```ts
import "@latentic/live-markdown/styles.css";
```

Skip it if your app already lays the editor out as a flex child.

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:

```ts
import "katex/dist/katex.min.css";
```

---

## Usage
Expand Down
3 changes: 3 additions & 0 deletions packages/rich-editor/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,9 @@
"README.md",
"LICENSE"
],
"publishConfig": {
"access": "public"
},
"sideEffects": [
"*.css"
],
Expand Down
10 changes: 10 additions & 0 deletions packages/rich-editor/src/codemirror/core/htmlEscape.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
/** Entity-escaping for the string renderers (table cells), which build HTML
* text rather than DOM and so must neutralise markup in document content. */

export function escapeText(value: string): string {
return value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
}

export function escapeAttr(value: string): string {
return escapeText(value).replace(/"/g, "&quot;");
}
2 changes: 2 additions & 0 deletions packages/rich-editor/src/codemirror/core/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@
* `editorTestHarness` (test-only — it pulls the whole extension set).
*/
export * from "./paint";
export * from "./inlineScan";
export * from "./htmlEscape";
export * from "./parseToEnd";
export * from "./treeAt";
export * from "./plugin";
Expand Down
76 changes: 76 additions & 0 deletions packages/rich-editor/src/codemirror/core/inlineScan.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
/**
* Constructs Lezer never parses.
*
* Wikilinks, `==highlight==`, footnote references and `$math$` are not
* CommonMark, so no node exists for any of them and the editor finds each by
* scanning text (`codeContext.ts` documents the shared code guard). A renderer
* that walks the tree is therefore blind to all four — which is how a table cell
* came to show `$440 = 2 \times \frac{22}{7} \times r$` as literal source while
* the same expression rendered as mathematics in the paragraph above it.
*
* A feature contributes its pattern and its markup through this facet; the
* string renderers consult the facet and stay ignorant of what the constructs
* are. The decoration plugins keep their own viewport-scanning path — the two
* paths share the pattern, not the machinery, because one produces decorations
* over a live document and the other an HTML string.
*/

import { Facet } from "@codemirror/state";

export interface InlineScanRule {
readonly name: string;
/** Global-flagged; the scanner drives `exec` and resets `lastIndex`. */
readonly pattern: RegExp;
/** Markup for one match. Escaping document text is the rule's job. */
render(match: RegExpExecArray): string;
/**
* Upgrade rendered markup to real DOM, after sanitisation. Only for a
* construct whose rendering is DOM rather than markup (KaTeX): the generated
* nodes bypass the sanitiser, so a hydrate reads inert text from the element
* it replaces and never trusts the document.
*/
hydrate?(root: HTMLElement): void;
}

export const inlineScanRulesFacet = Facet.define<InlineScanRule, readonly InlineScanRule[]>({
combine: (values) => values,
});

export interface InlineScanMatch {
readonly from: number;
readonly to: number;
readonly html: string;
}

/**
* Every rule's matches in `text`, in document order, offset by `at`.
*
* Earliest wins, then longest — two constructs cannot both own one span, and
* the alternative (rule declaration order) would make the result depend on
* extension load order.
*/
export function scanInline(
rules: readonly InlineScanRule[],
text: string,
at = 0,
): InlineScanMatch[] {
const found: InlineScanMatch[] = [];
for (const rule of rules) {
rule.pattern.lastIndex = 0;
let match: RegExpExecArray | null;
while ((match = rule.pattern.exec(text)) !== null) {
found.push({
from: at + match.index,
to: at + match.index + match[0].length,
html: rule.render(match),
});
}
}
found.sort((a, b) => a.from - b.from || b.to - a.to);
const kept: InlineScanMatch[] = [];
for (const span of found) {
const last = kept[kept.length - 1];
if (!last || span.from >= last.to) kept.push(span);
}
return kept;
}
68 changes: 68 additions & 0 deletions packages/rich-editor/src/codemirror/core/linkResolution.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
// @vitest-environment jsdom
import { afterEach, describe, expect, it } from "vitest";

import { destroyEditors, makeFullEditor } from "./editorTestHarness";

/**
* A `[…]` is a link only when it resolves. Lezer reports the bracket syntax;
* the reference lookup CommonMark specifies is ours to do, and skipping it is
* what ate brackets out of prose and LaTeX.
*/

function render(doc: string): { text: string; links: string[] } {
const view = makeFullEditor(doc, 0);
return {
text: [...view.contentDOM.querySelectorAll(".cm-line")].map((l) => l.textContent).join("\n"),
links: [...view.contentDOM.querySelectorAll(".cm-link")].map((l) => l.textContent ?? ""),
};
}

const DEFINITION = "\n\n[ref]: https://x.dev";

describe("a bracketed span renders as a link only when it resolves", () => {
afterEach(destroyEditors);

it("inline [text](url): brackets are chrome", () => {
const { text, links } = render("see [docs](https://x.dev) now");
expect(text).toBe("see docs now");
expect(links).toContain("docs");
});

it("shortcut [ref] WITH a definition: brackets are chrome", () => {
const { text, links } = render(`see [ref] now${DEFINITION}`);
expect(text.split("\n")[0]).toBe("see ref now");
expect(links).toContain("ref");
});

it("shortcut [ref] with NO definition: brackets are the author's text", () => {
const { text, links } = render("see [ref] now");
expect(text).toBe("see [ref] now");
expect(links).toEqual([]);
});

it("collapsed [ref][] resolves on its own label", () => {
expect(render(`see [ref][] now${DEFINITION}`).text.split("\n")[0]).toBe("see ref[] now");
});

it("matches labels case-insensitively, collapsing whitespace", () => {
expect(render("see [My Ref] now\n\n[my ref]: https://x.dev").text.split("\n")[0]).toBe(
"see My Ref now",
);
});

it("finds a definition nested in a blockquote", () => {
expect(render("see [ref] now\n\n> [ref]: https://x.dev").text.split("\n")[0]).toBe(
"see ref now",
);
});

it("leaves an image's own brackets alone", () => {
// `![alt](src)` is an Image, not a Link — its marks stay chrome regardless.
expect(render("![alt](x.png)").text).not.toContain("![alt]");
});

it("keeps every bracket of a pasted LaTeX block", () => {
const tikz = "\\begin{tikzpicture}[scale=0.42]\n\\draw (0,0)++(0:0.7) arc[radius=0.7];";
expect(render(tikz).text).toBe(tikz);
});
});
100 changes: 100 additions & 0 deletions packages/rich-editor/src/codemirror/core/linkResolution.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
/**
* Does a bracketed span actually link anywhere?
*
* Lezer emits a `Link` node for EVERY `[…]` it sees, resolved or not. CommonMark
* makes `[label]` a link only when the document also carries a matching
* `[label]: url` definition, and the parser leaves that lookup to its consumer —
* so "there is a Link node here" is not the same claim as "this is a link".
*
* Treating the two as one hid the brackets out of ordinary prose: `arr[0]` drew
* as `arr0`, `see [3]` as `see 3`, and every optional argument in a pasted LaTeX
* block vanished (`\begin{tikzpicture}[scale=0.42]` → `\begin{tikzpicture}scale=0.42`).
* The document was intact; only the rendering lied, which is the worse failure —
* nothing looked broken enough to report.
*/

import { type EditorState } from "@codemirror/state";

import { docTree } from "./codeContext";
import { type NodeLike } from "./paint";

/** Block containers a `[label]: url` definition can sit inside. Inline nodes are
* not descended into — a definition is a block construct, so stopping at the
* paragraph level turns the scan below from O(nodes) into O(blocks). */
const DEFINITION_CONTAINERS = new Set([
"Document",
"Blockquote",
"BulletList",
"OrderedList",
"ListItem",
]);

/** CommonMark label matching: case-folded, whitespace-collapsed, trimmed. */
function normalizeLabel(label: string): string {
return label.trim().replace(/\s+/g, " ").toLowerCase();
}

/** A `LinkLabel` node's text without its enclosing brackets. */
function labelInner(state: EditorState, label: NodeLike): string {
return state.sliceDoc(label.from + 1, label.to - 1);
}

const definedLabels = new WeakMap<EditorState, ReadonlySet<string>>();

/** Every label the document defines. Cached per state — a rule runs once per
* painted node, and each state is scanned at most once. */
function definitions(state: EditorState): ReadonlySet<string> {
const cached = definedLabels.get(state);
if (cached) return cached;
const labels = new Set<string>();
docTree(state).iterate({
enter: (node) => {
if (node.name !== "LinkReference") return DEFINITION_CONTAINERS.has(node.name);
const label = node.node.getChild("LinkLabel");
if (label) labels.add(normalizeLabel(labelInner(state, label)));
return false;
},
});
definedLabels.set(state, labels);
return labels;
}

/** The text between a link's own `[` and `]` marks, or null when it has none. */
function ownLabel(state: EditorState, link: NodeLike): string | null {
let open: NodeLike | null = null;
let close: NodeLike | null = null;
for (let child = link.firstChild; child; child = child.nextSibling) {
if (child.name !== "LinkMark") continue;
const mark = state.sliceDoc(child.from, child.to);
if (mark === "[" && !open) open = child;
else if (mark === "]" && !close) close = child;
}
if (!open || !close || close.from <= open.to) return null;
return state.sliceDoc(open.to, close.from);
}

/**
* Does this `Link` render as a link — or as the literal brackets the author typed?
*
* True for an inline `[text](url)`, and for a reference link whose label the
* document defines. False for the shortcut form with nothing to resolve against,
* which is most `[…]` in prose.
*/
export function linkResolves(link: NodeLike, state: EditorState): boolean {
if (link.getChild("URL")) return true;
const reference = link.getChild("LinkLabel");
const explicit = reference ? labelInner(state, reference) : "";
// Collapsed `[text][]` and shortcut `[text]` both resolve on their own label.
const key = explicit.trim() === "" ? (ownLabel(state, link) ?? "") : explicit;
return key.trim() !== "" && definitions(state).has(normalizeLabel(key));
}

/**
* Does the link render any visible label text? Whitespace-only counts as
* invisible — for `[](url)` / `[ ](url)` the URL is the link's entire visible
* content, and hiding it (with the marks already hidden) left an invisible,
* unclickable dead zone.
*/
export function linkHasVisibleLabel(link: NodeLike, state: EditorState): boolean {
return (ownLabel(state, link) ?? "").trim() !== "";
}
41 changes: 21 additions & 20 deletions packages/rich-editor/src/codemirror/core/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,35 +32,25 @@
import { horizontalRuleRule } from "./hrWidget";
import { htmlBlockRule, htmlInlineRule } from "../html/htmlWidget";
import { imageRule } from "../image/imageWidget";
import { linkHasVisibleLabel, linkResolves } from "./linkResolution";
import { listMarkRule } from "../list/bulletWidget";
import {
headingLine,
hideAlways,
line,
mark,
none,
raw,
structural,
type NodeContext,
type NodeLike,
type NodeRules,
} from "./paint";
import { taskMarkerRule } from "../list/taskCheckboxWidget";

/** Does this URL's parent Link render any visible label text? The label is
* everything between the opening `[` and closing `]` marks; whitespace-only
* counts as invisible (a lone space is a dead zone in practice). */
function linkHasVisibleLabel(ctx: NodeContext): boolean {
const link = ctx.node.parent;
if (!link) return false;
let bracketOpen: { readonly to: number } | null = null;
let bracketClose: { readonly from: number } | null = null;
for (let child = link.firstChild; child; child = child.nextSibling) {
if (child.name !== "LinkMark") continue;
const mark = ctx.state.sliceDoc(child.from, child.to);
if (mark === "[" && !bracketOpen) bracketOpen = child;
else if (mark === "]" && !bracketClose) bracketClose = child;
}
if (!bracketOpen || !bracketClose || bracketClose.from <= bracketOpen.to) return false;
return ctx.state.sliceDoc(bracketOpen.to, bracketClose.from).trim() !== "";
/** The enclosing `Link`, for a rule running on one of its parts. */
function parentLink(ctx: NodeContext): NodeLike | null {
return ctx.parentName === "Link" ? ctx.node.parent : null;
}

export const NODE_RULES: NodeRules = {
Expand Down Expand Up @@ -95,7 +85,12 @@ export const NODE_RULES: NodeRules = {
Emphasis: mark("cm-emphasis"),
StrongEmphasis: mark("cm-strong"),
InlineCode: mark("cm-inline-code"),
Link: mark("cm-link"),
// A `Link` node is not yet a link: Lezer emits one for every `[…]`, and the
// reference lookup CommonMark requires is left to us. Unresolved, it is the
// literal brackets the author typed — most `[…]` in prose, and every optional
// argument in a LaTeX block.
Link: (ctx) =>
linkResolves(ctx.node, ctx.state) ? { paint: "mark", className: "cm-link" } : none,
Image: imageRule, // `![alt](src)` → inline `<img>` widget

// ----- Inline literal sub-nodes (rendered inside their parent) -----
Expand All @@ -105,8 +100,9 @@ export const NODE_RULES: NodeRules = {
// Bare GFM autolinks and <angle> autolinks emit the SAME node name, and
// there the URL IS the content — same class of bug when hidden.
URL: (ctx) => {
if (ctx.parentName !== "Link") return { paint: "mark", className: "cm-link" };
return linkHasVisibleLabel(ctx)
const link = parentLink(ctx);
if (!link) return { paint: "mark", className: "cm-link" };
return linkHasVisibleLabel(link, ctx.state)
? { paint: "hide" }
: { paint: "mark", className: "cm-link" };
},
Expand Down Expand Up @@ -137,7 +133,12 @@ export const NODE_RULES: NodeRules = {
ctx.parentName?.startsWith("SetextHeading") ? { paint: "none" } : { paint: "hide" },
EmphasisMark: hideAlways(), // `*` / `_`
CodeMark: hideAlways(), // backticks for inline / fence pairs for blocks
LinkMark: hideAlways(), // `[`/`]`/`(`/`)`
// `[`/`]`/`(`/`)` — chrome only where they really are chrome. An unresolved
// link's brackets are content, and hiding them rewrote the document on screen.
LinkMark: (ctx) => {
const link = parentLink(ctx);
return link && !linkResolves(link, ctx.state) ? none : { paint: "hide" };
},
QuoteMark: hideAlways(), // `>`
ListMark: listMarkRule, // `-`/`1.` → bullet / number / nothing beside a checkbox
TaskMarker: taskMarkerRule, // `[ ]` / `[x]` → real checkbox
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,11 @@ describe("rendered output — what the user sees", () => {
["stray closing tag", "a </b> c", "</b>"],
["stripped script tag", "a <script>SENTINEL9</script> c", "SENTINEL9"],
["reference link label", "see [SENTINEL9][1] end\n\n[1]: https://x.dev", "SENTINEL9"],
// Lezer emits a `Link` for every `[…]`; without a definition to resolve
// against it is literal text, and its brackets are the author's.
["bracketed prose", "the value arr[SENTINEL9] is wrong", "arr[SENTINEL9]"],
["undefined reference label", "see [SENTINEL9] end", "[SENTINEL9]"],
["LaTeX optional argument", "\\draw (0,0) arc[SENTINEL9];", "arc[SENTINEL9]"],
["html comment", "a <!-- SENTINEL9 --> c", "SENTINEL9"],
["entity", "a &amp;SENTINEL9 c", "SENTINEL9"],
// Code is literal: an inline construct's syntax inside a code context
Expand Down
Loading
Loading