diff --git a/packages/rich-editor/README.md b/packages/rich-editor/README.md index 6d348f5..2e275ab 100644 --- a/packages/rich-editor/README.md +++ b/packages/rich-editor/README.md @@ -102,7 +102,7 @@ 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"; @@ -110,6 +110,12 @@ 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 diff --git a/packages/rich-editor/package.json b/packages/rich-editor/package.json index 374ea83..0b7e8bb 100644 --- a/packages/rich-editor/package.json +++ b/packages/rich-editor/package.json @@ -51,6 +51,9 @@ "README.md", "LICENSE" ], + "publishConfig": { + "access": "public" + }, "sideEffects": [ "*.css" ], diff --git a/packages/rich-editor/src/codemirror/core/htmlEscape.ts b/packages/rich-editor/src/codemirror/core/htmlEscape.ts new file mode 100644 index 0000000..802f82b --- /dev/null +++ b/packages/rich-editor/src/codemirror/core/htmlEscape.ts @@ -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, "&").replace(//g, ">"); +} + +export function escapeAttr(value: string): string { + return escapeText(value).replace(/"/g, """); +} diff --git a/packages/rich-editor/src/codemirror/core/index.ts b/packages/rich-editor/src/codemirror/core/index.ts index 90289d5..1ad00c4 100644 --- a/packages/rich-editor/src/codemirror/core/index.ts +++ b/packages/rich-editor/src/codemirror/core/index.ts @@ -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"; diff --git a/packages/rich-editor/src/codemirror/core/inlineScan.ts b/packages/rich-editor/src/codemirror/core/inlineScan.ts new file mode 100644 index 0000000..84ef678 --- /dev/null +++ b/packages/rich-editor/src/codemirror/core/inlineScan.ts @@ -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({ + 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; +} diff --git a/packages/rich-editor/src/codemirror/core/linkResolution.test.ts b/packages/rich-editor/src/codemirror/core/linkResolution.test.ts new file mode 100644 index 0000000..9f890c8 --- /dev/null +++ b/packages/rich-editor/src/codemirror/core/linkResolution.test.ts @@ -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); + }); +}); diff --git a/packages/rich-editor/src/codemirror/core/linkResolution.ts b/packages/rich-editor/src/codemirror/core/linkResolution.ts new file mode 100644 index 0000000..b419a08 --- /dev/null +++ b/packages/rich-editor/src/codemirror/core/linkResolution.ts @@ -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>(); + +/** 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 { + const cached = definedLabels.get(state); + if (cached) return cached; + const labels = new Set(); + 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() !== ""; +} diff --git a/packages/rich-editor/src/codemirror/core/registry.ts b/packages/rich-editor/src/codemirror/core/registry.ts index 2d1fba3..b0b397a 100644 --- a/packages/rich-editor/src/codemirror/core/registry.ts +++ b/packages/rich-editor/src/codemirror/core/registry.ts @@ -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 = { @@ -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 `` widget // ----- Inline literal sub-nodes (rendered inside their parent) ----- @@ -105,8 +100,9 @@ export const NODE_RULES: NodeRules = { // Bare GFM autolinks and 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" }; }, @@ -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 diff --git a/packages/rich-editor/src/codemirror/core/renderedOutput.test.ts b/packages/rich-editor/src/codemirror/core/renderedOutput.test.ts index f1452c1..ad06478 100644 --- a/packages/rich-editor/src/codemirror/core/renderedOutput.test.ts +++ b/packages/rich-editor/src/codemirror/core/renderedOutput.test.ts @@ -81,6 +81,11 @@ describe("rendered output — what the user sees", () => { ["stray closing tag", "a c", ""], ["stripped script tag", "a 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 c", "SENTINEL9"], ["entity", "a &SENTINEL9 c", "SENTINEL9"], // Code is literal: an inline construct's syntax inside a code context diff --git a/packages/rich-editor/src/codemirror/extensions/footnoteExtension.ts b/packages/rich-editor/src/codemirror/extensions/footnoteExtension.ts index 333c8e9..d2a622c 100644 --- a/packages/rich-editor/src/codemirror/extensions/footnoteExtension.ts +++ b/packages/rich-editor/src/codemirror/extensions/footnoteExtension.ts @@ -1,4 +1,6 @@ import { footnotePlugin } from "../footnote"; +import { inlineScanRulesFacet } from "../core/inlineScan"; +import { footnoteScanRule } from "../footnote/footnoteScanRule"; import { type MarkdownExtension } from "./types"; @@ -6,5 +8,5 @@ export const footnoteExtension: MarkdownExtension = { name: "@compose/footnote", version: "0.1.0", description: "Renders `[^id]` references and `[^id]:` definitions with tooltip jump.", - extensions: [footnotePlugin], + extensions: [footnotePlugin, inlineScanRulesFacet.of(footnoteScanRule)], }; diff --git a/packages/rich-editor/src/codemirror/extensions/highlightExtension.ts b/packages/rich-editor/src/codemirror/extensions/highlightExtension.ts index 46f4cd8..a19a015 100644 --- a/packages/rich-editor/src/codemirror/extensions/highlightExtension.ts +++ b/packages/rich-editor/src/codemirror/extensions/highlightExtension.ts @@ -1,4 +1,6 @@ import { highlightPlugin } from "../highlight"; +import { inlineScanRulesFacet } from "../core/inlineScan"; +import { highlightScanRule } from "../highlight/highlightScanRule"; import { type MarkdownExtension } from "./types"; @@ -6,5 +8,5 @@ export const highlightExtension: MarkdownExtension = { name: "@compose/highlight", version: "0.1.0", description: "Renders `==text==` with a yellow highlight background.", - extensions: [highlightPlugin], + extensions: [highlightPlugin, inlineScanRulesFacet.of(highlightScanRule)], }; diff --git a/packages/rich-editor/src/codemirror/extensions/mathExtension.ts b/packages/rich-editor/src/codemirror/extensions/mathExtension.ts index b7756f3..dc608a0 100644 --- a/packages/rich-editor/src/codemirror/extensions/mathExtension.ts +++ b/packages/rich-editor/src/codemirror/extensions/mathExtension.ts @@ -1,4 +1,6 @@ import { mathPlugin } from "../math"; +import { inlineScanRulesFacet } from "../core/inlineScan"; +import { mathScanRule } from "../math/mathScanRule"; import { type MarkdownExtension } from "./types"; @@ -6,5 +8,5 @@ export const mathExtension: MarkdownExtension = { name: "@compose/math", version: "0.1.0", description: "Renders `$x$` inline and `$$x$$` block math via KaTeX.", - extensions: [mathPlugin], + extensions: [mathPlugin, inlineScanRulesFacet.of(mathScanRule)], }; diff --git a/packages/rich-editor/src/codemirror/extensions/wikilinkExtension.ts b/packages/rich-editor/src/codemirror/extensions/wikilinkExtension.ts index fca2b98..a9151a1 100644 --- a/packages/rich-editor/src/codemirror/extensions/wikilinkExtension.ts +++ b/packages/rich-editor/src/codemirror/extensions/wikilinkExtension.ts @@ -1,4 +1,6 @@ import { wikilinkPlugin } from "../wikilink"; +import { inlineScanRulesFacet } from "../core/inlineScan"; +import { wikilinkScanRule } from "../wikilink/wikilinkScanRule"; import { type MarkdownExtension } from "./types"; @@ -6,5 +8,5 @@ export const wikilinkExtension: MarkdownExtension = { name: "@compose/wikilink", version: "0.1.0", description: "Renders `[[target]]` / `[[target|alias]]` as clickable links.", - extensions: [wikilinkPlugin], + extensions: [wikilinkPlugin, inlineScanRulesFacet.of(wikilinkScanRule)], }; diff --git a/packages/rich-editor/src/codemirror/footnote/footnotePlugin.ts b/packages/rich-editor/src/codemirror/footnote/footnotePlugin.ts index 0c5ef8a..c605b09 100644 --- a/packages/rich-editor/src/codemirror/footnote/footnotePlugin.ts +++ b/packages/rich-editor/src/codemirror/footnote/footnotePlugin.ts @@ -10,7 +10,7 @@ import { import { inCode, viewportTree } from "../core/codeContext"; -const FOOTNOTE_REF_RE = /(? `${escapeText(match[1] ?? "")}`, +}; diff --git a/packages/rich-editor/src/codemirror/highlight/highlightPlugin.ts b/packages/rich-editor/src/codemirror/highlight/highlightPlugin.ts index 5d88f02..b7f3453 100644 --- a/packages/rich-editor/src/codemirror/highlight/highlightPlugin.ts +++ b/packages/rich-editor/src/codemirror/highlight/highlightPlugin.ts @@ -10,7 +10,7 @@ import { import { inCode, viewportTree } from "../core/codeContext"; -const HIGHLIGHT_RE = /==([^=\n]+?)==/g; +export const HIGHLIGHT_RE = /==([^=\n]+?)==/g; const HIDE = Decoration.replace({}); const highlightMark = Decoration.mark({ class: "cm-highlight" }); diff --git a/packages/rich-editor/src/codemirror/highlight/highlightScanRule.ts b/packages/rich-editor/src/codemirror/highlight/highlightScanRule.ts new file mode 100644 index 0000000..592867d --- /dev/null +++ b/packages/rich-editor/src/codemirror/highlight/highlightScanRule.ts @@ -0,0 +1,11 @@ +/** `==marked==` for the string renderers. */ + +import { escapeText } from "../core/htmlEscape"; +import { type InlineScanRule } from "../core/inlineScan"; +import { HIGHLIGHT_RE } from "./highlightPlugin"; + +export const highlightScanRule: InlineScanRule = { + name: "highlight", + pattern: HIGHLIGHT_RE, + render: (match) => `${escapeText(match[1] ?? "")}`, +}; diff --git a/packages/rich-editor/src/codemirror/math/mathPlugin.ts b/packages/rich-editor/src/codemirror/math/mathPlugin.ts index e3fdb36..46846a5 100644 --- a/packages/rich-editor/src/codemirror/math/mathPlugin.ts +++ b/packages/rich-editor/src/codemirror/math/mathPlugin.ts @@ -4,7 +4,7 @@ import { Decoration, type DecorationSet, EditorView } from "@codemirror/view"; import { docTree, inCode } from "../core/codeContext"; import { MathWidget } from "./mathWidget"; -const INLINE_MATH_RE = /(?[] = []; diff --git a/packages/rich-editor/src/codemirror/math/mathScanRule.ts b/packages/rich-editor/src/codemirror/math/mathScanRule.ts new file mode 100644 index 0000000..99b57b5 --- /dev/null +++ b/packages/rich-editor/src/codemirror/math/mathScanRule.ts @@ -0,0 +1,25 @@ +/** + * `$…$` for the string renderers. + * + * The only rule that hydrates: KaTeX builds DOM, and its markup carries inline + * styles a cell's sanitiser allow-list deliberately drops. So the render pass + * emits the TeX as inert text, the sanitiser sees only that, and KaTeX — trusted + * code, reading the element's own `textContent` — replaces it afterwards. The + * allow-list stays as tight as it is for prose. + */ + +import { escapeText } from "../core/htmlEscape"; +import { type InlineScanRule } from "../core/inlineScan"; +import { INLINE_MATH_RE } from "./mathPlugin"; +import { renderMathInto } from "./mathWidget"; + +export const mathScanRule: InlineScanRule = { + name: "math", + pattern: INLINE_MATH_RE, + render: (match) => `${escapeText(match[1] ?? "")}`, + hydrate: (root) => { + for (const el of root.querySelectorAll(".cm-math-inline")) { + renderMathInto(el as HTMLElement, el.textContent ?? "", false); + } + }, +}; diff --git a/packages/rich-editor/src/codemirror/math/mathWidget.ts b/packages/rich-editor/src/codemirror/math/mathWidget.ts index c76bf62..9130dd3 100644 --- a/packages/rich-editor/src/codemirror/math/mathWidget.ts +++ b/packages/rich-editor/src/codemirror/math/mathWidget.ts @@ -1,6 +1,16 @@ import katex from "katex"; import { EditorView, WidgetType } from "@codemirror/view"; +/** Typeset `tex` into `el`, falling back to the source on a KaTeX failure so a + * malformed expression shows what the author wrote rather than nothing. */ +export function renderMathInto(el: HTMLElement, tex: string, displayMode: boolean): void { + try { + katex.render(tex, el, { displayMode, throwOnError: false, output: "html" }); + } catch { + el.textContent = tex; + } +} + export class MathWidget extends WidgetType { constructor( readonly tex: string, @@ -16,15 +26,7 @@ export class MathWidget extends WidgetType { override toDOM(_view: EditorView): HTMLElement { const span = document.createElement(this.displayMode ? "div" : "span"); span.className = this.displayMode ? "cm-math-block" : "cm-math-inline"; - try { - katex.render(this.tex, span, { - displayMode: this.displayMode, - throwOnError: false, - output: "html", - }); - } catch { - span.textContent = this.tex; - } + renderMathInto(span, this.tex, this.displayMode); return span; } diff --git a/packages/rich-editor/src/codemirror/table/tableCell.ts b/packages/rich-editor/src/codemirror/table/tableCell.ts index 240b220..9d26f5f 100644 --- a/packages/rich-editor/src/codemirror/table/tableCell.ts +++ b/packages/rich-editor/src/codemirror/table/tableCell.ts @@ -13,12 +13,21 @@ import DOMPurify from "dompurify"; +import { type InlineScanRule } from "../core/inlineScan"; + const CELL_SANITIZE_CONFIG = { ALLOWED_TAGS: ["br", "b", "strong", "i", "em", "code", "sub", "sup", "del", "s", "u", "span", "a"], ALLOWED_ATTR: ["href", "class"], RETURN_TRUSTED_TYPE: false, }; -export function renderCellInto(el: HTMLElement, source: string): void { +export function renderCellInto( + el: HTMLElement, + source: string, + rules: readonly InlineScanRule[], +): void { el.innerHTML = DOMPurify.sanitize(source, CELL_SANITIZE_CONFIG) as unknown as string; + // Only after sanitising: a hydrate generates trusted DOM (KaTeX) from the + // inert text left in the element it replaces. + for (const rule of rules) rule.hydrate?.(el); } diff --git a/packages/rich-editor/src/codemirror/table/tableInline.browser.test.ts b/packages/rich-editor/src/codemirror/table/tableInline.browser.test.ts new file mode 100644 index 0000000..1a5849b --- /dev/null +++ b/packages/rich-editor/src/codemirror/table/tableInline.browser.test.ts @@ -0,0 +1,56 @@ +/** + * @browser: a cell's mathematics is LAID OUT, not just present in the DOM. + * + * jsdom can report a `.katex` element and tell you nothing about whether it + * occupies space — and a KaTeX fraction is exactly the case where the markup + * can be right while the layout is empty, since its rule and numerator are + * positioned by CSS the cell's sanitiser could have stripped. + */ + +// KaTeX ships its own stylesheet; without it a fraction is in the DOM but does +// not stack, which is the very thing this tier exists to tell apart. +import "katex/dist/katex.min.css"; +import { afterEach, describe, expect, it } from "vitest"; + +import { destroyEditors, makeFullEditor } from "../core/editorTestHarness"; + +const FRACTION = "$440 = 2 \\times \\frac{22}{7} \\times r$"; + +function cellOf(source: string): HTMLElement { + const doc = ["| h |", "| --- |", `| ${source} |`].join("\n"); + const td = makeFullEditor(doc, 0).contentDOM.querySelector("td"); + if (!td) throw new Error("no cell rendered"); + return td as HTMLElement; +} + +describe("@browser table cell rendering", () => { + afterEach(destroyEditors); + + it("lays out a fraction inside a cell", () => { + const katex = cellOf(FRACTION).querySelector(".katex") as HTMLElement | null; + expect(katex).not.toBeNull(); + const box = katex!.getBoundingClientRect(); + expect(box.width).toBeGreaterThan(0); + // `\frac` really parsed — `$r$` has no `.mfrac` to find. + expect(katex!.querySelector(".mfrac")).not.toBeNull(); + expect(cellOf("$r$").querySelector(".mfrac")).toBeNull(); + + // …and its stylesheet survived the cell's sanitiser. Measured on the CELL: + // `.katex` is an inline span, so its own rect is just the line box and + // reads the same for a fraction as for a single letter. Unstyled KaTeX + // renders SHORTER than prose (15px against 20px here), so a math cell that + // is taller than a prose cell is the assertion that catches stripped CSS. + const withMath = cellOf(FRACTION).getBoundingClientRect().height; + expect(withMath).toBeGreaterThan(cellOf("plain").getBoundingClientRect().height); + }); + + it("keeps the source visible when the cell is plain prose", () => { + expect(cellOf("just words").textContent).toBe("just words"); + }); + + it("keeps every bracket of a LaTeX line in the body", () => { + const line = "\\draw (0,0)++(0:0.7) arc[start angle=0, radius=0.7];"; + const view = makeFullEditor(line, 0); + expect(view.contentDOM.textContent).toBe(line); + }); +}); diff --git a/packages/rich-editor/src/codemirror/table/tableInline.scanned.test.ts b/packages/rich-editor/src/codemirror/table/tableInline.scanned.test.ts new file mode 100644 index 0000000..3d7c499 --- /dev/null +++ b/packages/rich-editor/src/codemirror/table/tableInline.scanned.test.ts @@ -0,0 +1,87 @@ +// @vitest-environment jsdom +import { afterEach, describe, expect, it } from "vitest"; + +import { destroyEditors, makeFullEditor } from "../core/editorTestHarness"; + +/** + * A cell renders what the body renders. + * + * Cells walk the Lezer tree, and four of the editor's constructs have no node + * in it — they are found by scanning text. The tree walk was blind to all four, + * so a cell showed markdown source where the paragraph above it showed + * mathematics, a wikilink, a highlight, or a footnote marker. + */ + +function cell(source: string): HTMLElement | null { + const doc = ["| h |", "| --- |", `| ${source} |`].join("\n"); + return makeFullEditor(doc, 0).contentDOM.querySelector("td"); +} + +function body(source: string): string { + const view = makeFullEditor(`z ${source} z`, 0); + return [...view.contentDOM.querySelectorAll(".cm-line")].map((l) => l.textContent).join(""); +} + +describe("a table cell renders the constructs Lezer does not parse", () => { + afterEach(destroyEditors); + + it("typesets inline math with KaTeX, as the body does", () => { + const td = cell("$440 = 2 \\times \\frac{22}{7} \\times r$"); + expect(td?.querySelectorAll(".katex")).toHaveLength(1); + expect(td?.textContent).not.toContain("\\times"); + expect(body("$440 = 2 \\times \\frac{22}{7} \\times r$")).toContain("440"); + }); + + it("shows a wikilink's label, not its brackets", () => { + const td = cell("[[Some Note]]"); + expect(td?.textContent).toBe("Some Note"); + expect(td?.querySelector(".cm-wikilink")?.textContent).toBe("Some Note"); + }); + + it("uses a wikilink's alias when it has one", () => { + // `\\|` is the only literal pipe in a GFM cell — a bare one starts a column, + // so the alias form only exists in a cell in its escaped spelling. + expect(cell("[[Some Note\\|the alias]]")?.textContent).toBe("the alias"); + }); + + it("marks ==highlight== instead of showing the equals signs", () => { + const td = cell("==marked=="); + expect(td?.textContent).toBe("marked"); + expect(td?.querySelector(".cm-highlight")).not.toBeNull(); + }); + + it("draws a footnote reference as its marker", () => { + const td = cell("text[^1]"); + expect(td?.textContent).toBe("text1"); + expect(td?.querySelector(".cm-footnote-ref")?.textContent).toBe("1"); + }); + + it("keeps a construct literal inside a code span, as the body does", () => { + const td = cell("`costs $5 x$ y`"); + expect(td?.textContent).toBe("costs $5 x$ y"); + expect(td?.querySelectorAll(".katex")).toHaveLength(0); + }); + + it("still renders the constructs Lezer DOES parse", () => { + expect(cell("**bold** and `code`")?.innerHTML).toBe( + 'bold and code', + ); + }); + + it("renders math beside tree-parsed markup in one cell", () => { + const td = cell("**r** is $r$ here"); + expect(td?.querySelector(".cm-strong")?.textContent).toBe("r"); + expect(td?.querySelectorAll(".katex")).toHaveLength(1); + }); + + it("leaves a span that cuts a parsed node in half alone", () => { + // `$a **b** c$` spans whole children and renders; a `$` that opens outside a + // node and closes inside it must not make the node render twice. + const td = cell("$a **b** c$"); + expect(td?.textContent?.match(/b/g) ?? []).toHaveLength(1); + }); + + it("escapes document text rather than letting it become markup", () => { + expect(cell("====")?.querySelector("img")).toBeNull(); + }); +}); diff --git a/packages/rich-editor/src/codemirror/table/tableInline.ts b/packages/rich-editor/src/codemirror/table/tableInline.ts index c2c4a9f..cbc50c5 100644 --- a/packages/rich-editor/src/codemirror/table/tableInline.ts +++ b/packages/rich-editor/src/codemirror/table/tableInline.ts @@ -17,6 +17,9 @@ import { type EditorState } from "@codemirror/state"; +import { docTree, inCode } from "../core/codeContext"; +import { escapeAttr, escapeText } from "../core/htmlEscape"; +import { inlineScanRulesFacet, scanInline } from "../core/inlineScan"; import { type NodeLike } from "../core/paint"; import { NODE_RULES } from "../core/registry"; @@ -28,14 +31,6 @@ type SyntaxNodeLike = { readonly nextSibling: SyntaxNodeLike | null; }; -function escapeText(value: string): string { - return value.replace(/&/g, "&").replace(//g, ">"); -} - -function escapeAttr(value: string): string { - return escapeText(value).replace(/"/g, """); -} - /** * `InlineCode` content sits between its two `CodeMark` backtick children and is * literal — no nested markdown — so it's entity-escaped rather than recursed. @@ -143,7 +138,39 @@ function renderRange( return html; } -/** Inline-render one `TableCell` node to a (still-to-be-sanitised) HTML string. */ +/** + * A scanned span may stand in for whole child nodes, but must not cut one in + * half: {@link renderRange} emits a straddling child in full at both ends of the + * span, which would render it twice. + */ +function straddlesChild(cell: SyntaxNodeLike, from: number, to: number): boolean { + for (let child = cell.firstChild; child; child = child.nextSibling) { + if (child.from < from && child.to > from) return true; + if (child.from < to && child.to > to) return true; + } + return false; +} + +/** + * Inline-render one `TableCell` node to a (still-to-be-sanitised) HTML string. + * + * The constructs Lezer parses come from the tree; the four it does not + * (wikilink, highlight, footnote, math) are carved out of the cell's text first + * and take precedence over the tree walk — the same order the body renders in, + * where a scanning plugin's replace decoration covers the marks beneath it. + */ export function renderInlineCell(state: EditorState, cell: SyntaxNodeLike): string { - return renderRange(state, cell, cell.from, cell.to).trim(); + const rules = state.facet(inlineScanRulesFacet); + const tree = docTree(state); + let html = ""; + let pos = cell.from; + for (const span of scanInline(rules, state.sliceDoc(cell.from, cell.to), cell.from)) { + // Code is literal, exactly as in the body: `$x$` inside a code span is text. + if (span.from < pos) continue; + if (inCode(tree, span.from) || inCode(tree, span.to - 1)) continue; + if (straddlesChild(cell, span.from, span.to)) continue; + html += renderRange(state, cell, pos, span.from) + span.html; + pos = span.to; + } + return (html + renderRange(state, cell, pos, cell.to)).trim(); } diff --git a/packages/rich-editor/src/codemirror/tablev2/inlineCellSurface.ts b/packages/rich-editor/src/codemirror/tablev2/inlineCellSurface.ts index def635d..1c3eee8 100644 --- a/packages/rich-editor/src/codemirror/tablev2/inlineCellSurface.ts +++ b/packages/rich-editor/src/codemirror/tablev2/inlineCellSurface.ts @@ -13,6 +13,7 @@ import { type ChangeDesc } from "@codemirror/state"; import { type EditorView } from "@codemirror/view"; +import { inlineScanRulesFacet } from "../core/inlineScan"; import { renderCellInto } from "../table/tableCell"; import { modelAt } from "../table/tableGeometry"; import { cellAt } from "../table/tableCellNav"; @@ -160,7 +161,7 @@ export class InlineCellSurface implements CellEditingSurface { // rendered content ourselves. const model = modelAt(view.state, s.tableFrom); const cell = model ? cellAt(model, s.ref.row, s.ref.col) : null; - if (cell) renderCellInto(s.el, cell.html); + if (cell) renderCellInto(s.el, cell.html, view.state.facet(inlineScanRulesFacet)); } cancel(): void { diff --git a/packages/rich-editor/src/codemirror/tablev2/tableWidgetV2.ts b/packages/rich-editor/src/codemirror/tablev2/tableWidgetV2.ts index b65d3ad..cc7d5ec 100644 --- a/packages/rich-editor/src/codemirror/tablev2/tableWidgetV2.ts +++ b/packages/rich-editor/src/codemirror/tablev2/tableWidgetV2.ts @@ -18,13 +18,20 @@ import { syntaxTree } from "@codemirror/language"; import { type EditorState, StateField, type Extension, type Range } from "@codemirror/state"; import { Decoration, type DecorationSet, EditorView, WidgetType } from "@codemirror/view"; +import { inlineScanRulesFacet, type InlineScanRule } from "../core/inlineScan"; import { renderCellInto } from "../table/tableCell"; import { parseTableNode, type TableCellData, type TableData } from "../table/tableModel"; import { type CellEditingSurface } from "./cellEditingSurface"; import { tableV2Sync } from "./tableV2Sync"; -function fillCell(el: HTMLElement, cell: TableCellData, row: number, col: number): void { - renderCellInto(el, cell.html); +function fillCell( + el: HTMLElement, + cell: TableCellData, + row: number, + col: number, + rules: readonly InlineScanRule[], +): void { + renderCellInto(el, cell.html, rules); el.dataset.row = String(row); el.dataset.col = String(col); // The structure menu's row/column hover-tint locates cells by source pos. @@ -77,7 +84,8 @@ export class TableWidgetV2 extends WidgetType { ); } - override toDOM(): HTMLElement { + override toDOM(view: EditorView): HTMLElement { + const rules = view.state.facet(inlineScanRulesFacet); const wrap = document.createElement("div"); // Both generations of class/stamp: cm-table-wrap carries the existing // theme + armed-delete styling; the v2 markers are what the surface reads. @@ -91,7 +99,7 @@ export class TableWidgetV2 extends WidgetType { const headRow = document.createElement("tr"); this.data.header.forEach((cell, col) => { const th = document.createElement("th"); - fillCell(th, cell, 0, col); + fillCell(th, cell, 0, col, rules); const align = this.data.alignments[col]; if (align) th.style.textAlign = align; headRow.appendChild(th); @@ -104,7 +112,7 @@ export class TableWidgetV2 extends WidgetType { const tr = document.createElement("tr"); cells.forEach((cell, col) => { const td = document.createElement("td"); - fillCell(td, cell, r + 1, col); + fillCell(td, cell, r + 1, col, rules); const align = this.data.alignments[col]; if (align) td.style.textAlign = align; tr.appendChild(td); @@ -116,7 +124,8 @@ export class TableWidgetV2 extends WidgetType { return wrap; } - override updateDOM(dom: HTMLElement): boolean { + override updateDOM(dom: HTMLElement, view: EditorView): boolean { + const rules = view.state.facet(inlineScanRulesFacet); if (dom.dataset.tablev2From === undefined) return false; const rows = dom.querySelectorAll("tr"); if (rows.length !== 1 + this.data.rows.length) return false; @@ -130,7 +139,7 @@ export class TableWidgetV2 extends WidgetType { for (let r = 0; r < grid.length; r++) { const cells = rows[r].children; grid[r].forEach((cell, col) => { - fillCell(cells[col] as HTMLElement, cell, r, col); + fillCell(cells[col] as HTMLElement, cell, r, col, rules); }); } return true; diff --git a/packages/rich-editor/src/codemirror/wikilink/wikilinkPlugin.ts b/packages/rich-editor/src/codemirror/wikilink/wikilinkPlugin.ts index a5720e6..e354475 100644 --- a/packages/rich-editor/src/codemirror/wikilink/wikilinkPlugin.ts +++ b/packages/rich-editor/src/codemirror/wikilink/wikilinkPlugin.ts @@ -28,7 +28,7 @@ import { import { parseWikilinkBody } from "../../links/wikilink"; import { inCode, viewportTree } from "../core/codeContext"; -const WIKILINK_RE = /\[\[([^\]\n]+?)\]\]/g; +export const WIKILINK_RE = /\[\[([^\]\n]+?)\]\]/g; /** Hides the `[[ … | ]]` syntax characters. */ const HIDE = Decoration.replace({}); diff --git a/packages/rich-editor/src/codemirror/wikilink/wikilinkScanRule.ts b/packages/rich-editor/src/codemirror/wikilink/wikilinkScanRule.ts new file mode 100644 index 0000000..ea77266 --- /dev/null +++ b/packages/rich-editor/src/codemirror/wikilink/wikilinkScanRule.ts @@ -0,0 +1,14 @@ +/** `[[target]]` / `[[target|alias]]` for the string renderers — the label the + * editor shows, styled by the same `.cm-wikilink` rule as body text. */ + +import { parseWikilinkBody } from "../../links/wikilink"; +import { escapeText } from "../core/htmlEscape"; +import { type InlineScanRule } from "../core/inlineScan"; +import { WIKILINK_RE } from "./wikilinkPlugin"; + +export const wikilinkScanRule: InlineScanRule = { + name: "wikilink", + pattern: WIKILINK_RE, + render: (match) => + `${escapeText(parseWikilinkBody(match[1] ?? "").label)}`, +}; diff --git a/packages/rich-editor/src/styles.css b/packages/rich-editor/src/styles.css index ca4067b..d0c2619 100644 --- a/packages/rich-editor/src/styles.css +++ b/packages/rich-editor/src/styles.css @@ -1,15 +1,20 @@ /** - * ai-editor — container layout. + * @latentic/live-markdown — container layout. * * All *in-editor* styling (headings, bold, code, lists, image widgets, tables, * math, links, footnotes, the image context menu) ships with the editor as a * CodeMirror theme (`editorBaseTheme`) and applies automatically — you do not * need this file for those. * + * The one exception is mathematics: KaTeX renders through its own stylesheet, + * which no theme can supply. An app that wants `$x$` to render imports it: + * + * import "katex/dist/katex.min.css"; + * * This stylesheet covers only the outer flex shell that makes the editor fill * its parent and scroll. Import it once in a standalone app: * - * import "ai-editor/styles.css"; + * import "@latentic/live-markdown/styles.css"; * * Hosts with their own layout (e.g. a flex column with a sibling sidebar) can * skip it and supply equivalent rules — the editor needs its scroll area to be