From 10c6a5ad057abbac9118342aaaa5fc450b1b476f Mon Sep 17 00:00:00 2001 From: Tosin Amuda Date: Sun, 23 Aug 2026 21:11:53 +0200 Subject: [PATCH 1/3] chore(editor): pin public access in the manifest, not the command line MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A scoped package defaults to restricted, so its visibility depended on whoever published remembering `--access public`. Forget it once and the package goes private — which fails loudly on a free account and, on a paid one, succeeds quietly and leaves consumers with a 404. `publishConfig` moves that decision into the file, where it is reviewed like anything else. Verified against a bumped version rather than assumed: with no flag, the dry run reports "public access". It sits beside `files`, which is the other field governing what a publish sends. --- packages/rich-editor/package.json | 3 +++ 1 file changed, 3 insertions(+) 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" ], From 38b9ee3003293c7c1c615c27c03d8fbc2d49b9af Mon Sep 17 00:00:00 2001 From: Tosin Amuda Date: Mon, 24 Aug 2026 09:41:28 +0200 Subject: [PATCH 2/3] =?UTF-8?q?fix(editor):=20a=20`[=E2=80=A6]`=20keeps=20?= =?UTF-8?q?its=20brackets=20until=20it=20resolves=20to=20a=20link?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lezer emits a `Link` node for every bracketed span it sees. 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" was never the same claim as "this is a link", and the registry read it as one. Every `[…]` therefore lost its brackets on screen: the value arr[0] is wrong → the value arr0 is wrong see footnote [3] → see footnote 3 matrix [1,2;3,4] rows → matrix 1,2;3,4 rows and a pasted LaTeX block lost every optional argument it had: \begin{tikzpicture}[scale=0.42] → \begin{tikzpicture}scale=0.42 \draw (0,0)++(0:0.7) arc[radius=0.7] → \draw (0,0)++(0:0.7) arcradius=0.7 The bytes were fine; only the rendering lied, which is the worse failure — nothing looked broken enough to report, and a reader copying what they saw got LaTeX that no longer compiles. `linkResolution.ts` does the lookup the parser skipped: an inline `[text](url)` resolves on its URL, a reference link on a definition found in the document (case-folded and whitespace-collapsed, per CommonMark). Unresolved, the brackets are content — visible, unstyled, and no longer atomic to the caret. This is also an export-parity fix. Verified against the export's own parser rather than assumed: comrak "the value arr[0] is wrong" →

the value arr[0] is wrong

comrak "see [ref] now" →

see [ref] now

comrak + a [ref]: definition →

see ref now

The editor was the only renderer in the app getting this wrong. The definition scan is cached per `EditorState` and descends only into block containers — a definition is a block construct, so a paragraph's inline children are never walked. --- .../codemirror/core/linkResolution.test.ts | 68 ++++++++++++ .../src/codemirror/core/linkResolution.ts | 100 ++++++++++++++++++ .../src/codemirror/core/registry.ts | 41 +++---- .../codemirror/core/renderedOutput.test.ts | 5 + 4 files changed, 194 insertions(+), 20 deletions(-) create mode 100644 packages/rich-editor/src/codemirror/core/linkResolution.test.ts create mode 100644 packages/rich-editor/src/codemirror/core/linkResolution.ts 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 From 3e61ad5f5429ec2e232a19f6c19c0ffed38b1eeb Mon Sep 17 00:00:00 2001 From: Tosin Amuda Date: Mon, 24 Aug 2026 09:52:01 +0200 Subject: [PATCH 3/3] fix(editor): a table cell renders what the paragraph above it renders MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reported as "there is valid LaTeX here — should it not render?". It is valid, it renders in body text, and the cell showed the source: body | 440 = 2 × 22/7 × r cell | $440 = 2 \times \frac{22}{7} \times r$ Math was not the only one. Cells walk the Lezer tree, and FOUR of the editor's constructs have no node in it — wikilink, `==highlight==`, footnote reference and `$math$` are not CommonMark, so the editor finds each by scanning text. A tree walk is blind to all four, and two came out worse than untouched, because the parser's own reading of their brackets is wrong: [[Some Note]] → [Some Note] inner brackets eaten, styled a link text[^1] → text^1 caret marker styled a link `inlineScan.ts` is the seam the string renderers were missing: a feature contributes its pattern and its markup, and the cell renderer consults the facet knowing nothing about what the constructs are — the same shape as `nodeRulesFacet`, which the tree-parsed constructs already use. The decoration plugins keep their own viewport-scanning path; the two share the pattern, not the machinery, since one produces decorations over a live document and the other an HTML string. Cells now carve scanned spans out of the text before walking the tree, so a scanned construct wins over the markup beneath it — the order the body already renders in, where a replace decoration covers the marks it spans. Code stays literal by the same `inCode` guard the body uses, and a span that would cut a parsed node in half is left alone rather than rendering that node twice. Math is the one rule that hydrates. KaTeX builds DOM, and its markup carries inline styles the cell's sanitiser allow-list drops on purpose, so the render pass emits the TeX as inert text and KaTeX replaces it after sanitisation, reading the element's own textContent. The allow-list stays exactly as tight as it is for prose. Verified in real WebKit, not just jsdom: the `.katex` span is inline, so its own rect reads the same for a fraction as for a single letter — the CELL's height is what moves (26.8px with a fraction, 25.4px flat math, 20.2px prose). Unstyled KaTeX renders SHORTER than prose, so measuring the cell is what would catch a stripped stylesheet. Two documentation defects found on the way, both shipped in @latentic/live-markdown@0.0.1: `styles.css` still called the package `ai-editor`, and both it and the README promised math needs no CSS import. KaTeX renders through its own stylesheet, which no CodeMirror theme can supply — Compose imports it, a reader following the README would not have. Cost, measured on a 91k document with a bracket, math, a wikilink and a table on EVERY line: p50 keystroke 9.87ms on main → 12.19ms with this and the bracket fix. The definition index is 0.51ms of that (1602 block nodes); forcing the whole-document parse is 0.03ms, because parseToEnd has already covered it. --- packages/rich-editor/README.md | 8 +- .../src/codemirror/core/htmlEscape.ts | 10 +++ .../rich-editor/src/codemirror/core/index.ts | 2 + .../src/codemirror/core/inlineScan.ts | 76 ++++++++++++++++ .../extensions/footnoteExtension.ts | 4 +- .../extensions/highlightExtension.ts | 4 +- .../codemirror/extensions/mathExtension.ts | 4 +- .../extensions/wikilinkExtension.ts | 4 +- .../src/codemirror/footnote/footnotePlugin.ts | 2 +- .../codemirror/footnote/footnoteScanRule.ts | 12 +++ .../codemirror/highlight/highlightPlugin.ts | 2 +- .../codemirror/highlight/highlightScanRule.ts | 11 +++ .../src/codemirror/math/mathPlugin.ts | 2 +- .../src/codemirror/math/mathScanRule.ts | 25 ++++++ .../src/codemirror/math/mathWidget.ts | 20 +++-- .../src/codemirror/table/tableCell.ts | 11 ++- .../table/tableInline.browser.test.ts | 56 ++++++++++++ .../table/tableInline.scanned.test.ts | 87 +++++++++++++++++++ .../src/codemirror/table/tableInline.ts | 47 +++++++--- .../codemirror/tablev2/inlineCellSurface.ts | 3 +- .../src/codemirror/tablev2/tableWidgetV2.ts | 23 +++-- .../src/codemirror/wikilink/wikilinkPlugin.ts | 2 +- .../codemirror/wikilink/wikilinkScanRule.ts | 14 +++ packages/rich-editor/src/styles.css | 9 +- 24 files changed, 399 insertions(+), 39 deletions(-) create mode 100644 packages/rich-editor/src/codemirror/core/htmlEscape.ts create mode 100644 packages/rich-editor/src/codemirror/core/inlineScan.ts create mode 100644 packages/rich-editor/src/codemirror/footnote/footnoteScanRule.ts create mode 100644 packages/rich-editor/src/codemirror/highlight/highlightScanRule.ts create mode 100644 packages/rich-editor/src/codemirror/math/mathScanRule.ts create mode 100644 packages/rich-editor/src/codemirror/table/tableInline.browser.test.ts create mode 100644 packages/rich-editor/src/codemirror/table/tableInline.scanned.test.ts create mode 100644 packages/rich-editor/src/codemirror/wikilink/wikilinkScanRule.ts 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/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/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