Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0a53be9e81 | |||
| 95c0d813b0 | |||
| 9004de60e3 |
@@ -1,7 +1,7 @@
|
|||||||
import { EditorContent, ReactNodeViewRenderer, useEditor } from "@tiptap/react";
|
import { EditorContent, ReactNodeViewRenderer, useEditor } from "@tiptap/react";
|
||||||
import { Placeholder } from "@tiptap/extension-placeholder";
|
import { Placeholder } from "@tiptap/extension-placeholder";
|
||||||
import { StarterKit } from "@tiptap/starter-kit";
|
import { StarterKit } from "@tiptap/starter-kit";
|
||||||
import { Mention, LinkExtension } from "@docmost/editor-ext";
|
import { Mention, LinkExtension, Code } from "@docmost/editor-ext";
|
||||||
import classes from "./comment.module.css";
|
import classes from "./comment.module.css";
|
||||||
import { useFocusWithin } from "@mantine/hooks";
|
import { useFocusWithin } from "@mantine/hooks";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
@@ -44,7 +44,12 @@ const CommentEditor = forwardRef(
|
|||||||
gapcursor: false,
|
gapcursor: false,
|
||||||
dropcursor: false,
|
dropcursor: false,
|
||||||
link: false,
|
link: false,
|
||||||
|
// #515: use the shared editor-ext `Code` (excludes: "") instead of
|
||||||
|
// StarterKit's excluding one, so inline code in a comment can carry
|
||||||
|
// other marks and does not drop them when the comment is edited.
|
||||||
|
code: false,
|
||||||
}),
|
}),
|
||||||
|
Code,
|
||||||
Placeholder.configure({
|
Placeholder.configure({
|
||||||
placeholder: placeholder || t("Reply..."),
|
placeholder: placeholder || t("Reply..."),
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { markInputRule } from "@tiptap/core";
|
import { markInputRule } from "@tiptap/core";
|
||||||
import { StarterKit } from "@tiptap/starter-kit";
|
import { StarterKit } from "@tiptap/starter-kit";
|
||||||
import { Code } from "@tiptap/extension-code";
|
|
||||||
import { TextAlign } from "@tiptap/extension-text-align";
|
import { TextAlign } from "@tiptap/extension-text-align";
|
||||||
import { TaskList, TaskItem } from "@tiptap/extension-list";
|
import { TaskList, TaskItem } from "@tiptap/extension-list";
|
||||||
import { Placeholder, CharacterCount, UndoRedo } from "@tiptap/extensions";
|
import { Placeholder, CharacterCount, UndoRedo } from "@tiptap/extensions";
|
||||||
@@ -67,6 +66,7 @@ import {
|
|||||||
FootnoteReference,
|
FootnoteReference,
|
||||||
FootnotesList,
|
FootnotesList,
|
||||||
FootnoteDefinition,
|
FootnoteDefinition,
|
||||||
|
Code,
|
||||||
} from "@docmost/editor-ext";
|
} from "@docmost/editor-ext";
|
||||||
import {
|
import {
|
||||||
randomElement,
|
randomElement,
|
||||||
@@ -153,6 +153,10 @@ export const mainExtensions = [
|
|||||||
codeBlock: false,
|
codeBlock: false,
|
||||||
code: false,
|
code: false,
|
||||||
}),
|
}),
|
||||||
|
// Base `Code` comes from @docmost/editor-ext, which overrides `excludes: ""`
|
||||||
|
// (#515) so inline code can co-occur with bold/italic/… — the SINGLE shared
|
||||||
|
// source also used by the collab server and comment editor. Here we keep the
|
||||||
|
// existing client-only behavior on top of it:
|
||||||
// Override TipTap's Code extension to fix the inline code input rule.
|
// Override TipTap's Code extension to fix the inline code input rule.
|
||||||
// The upstream regex /(^|[^`])`([^`]+)`(?!`)$/ captures the character
|
// The upstream regex /(^|[^`])`([^`]+)`(?!`)$/ captures the character
|
||||||
// before the opening backtick as part of the match, causing markInputRule
|
// before the opening backtick as part of the match, causing markInputRule
|
||||||
|
|||||||
@@ -41,6 +41,7 @@
|
|||||||
"@aws-sdk/s3-request-presigner": "3.1050.0",
|
"@aws-sdk/s3-request-presigner": "3.1050.0",
|
||||||
"@azure/storage-blob": "12.31.0",
|
"@azure/storage-blob": "12.31.0",
|
||||||
"@clickhouse/client": "^1.18.2",
|
"@clickhouse/client": "^1.18.2",
|
||||||
|
"@docmost/editor-ext": "workspace:*",
|
||||||
"@docmost/mcp": "workspace:*",
|
"@docmost/mcp": "workspace:*",
|
||||||
"@docmost/pdf-inspector": "1.9.6",
|
"@docmost/pdf-inspector": "1.9.6",
|
||||||
"@docmost/prosemirror-markdown": "workspace:*",
|
"@docmost/prosemirror-markdown": "workspace:*",
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ import {
|
|||||||
FootnotesList,
|
FootnotesList,
|
||||||
FootnoteDefinition,
|
FootnoteDefinition,
|
||||||
PageEmbed,
|
PageEmbed,
|
||||||
|
Code,
|
||||||
} from '@docmost/editor-ext';
|
} from '@docmost/editor-ext';
|
||||||
import { convertProseMirrorToMarkdown } from '@docmost/prosemirror-markdown';
|
import { convertProseMirrorToMarkdown } from '@docmost/prosemirror-markdown';
|
||||||
import { generateText, getSchema, JSONContent } from '@tiptap/core';
|
import { generateText, getSchema, JSONContent } from '@tiptap/core';
|
||||||
@@ -67,7 +68,12 @@ export const tiptapExtensions = [
|
|||||||
link: false,
|
link: false,
|
||||||
trailingNode: false,
|
trailingNode: false,
|
||||||
heading: false,
|
heading: false,
|
||||||
|
// #515: replace StarterKit's bundled inline `code` (which inherits tiptap's
|
||||||
|
// `excludes: "_"`) with the shared editor-ext `Code` below, so the server's
|
||||||
|
// HTML->PM parse/export keeps code co-occurring with other marks.
|
||||||
|
code: false,
|
||||||
}),
|
}),
|
||||||
|
Code,
|
||||||
Heading,
|
Heading,
|
||||||
UniqueID.configure({
|
UniqueID.configure({
|
||||||
types: ['heading', 'paragraph', 'transclusionSource'],
|
types: ['heading', 'paragraph', 'transclusionSource'],
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
export * from "./lib/trailing-node";
|
export * from "./lib/trailing-node";
|
||||||
|
export * from "./lib/code";
|
||||||
export * from "./lib/comment/comment";
|
export * from "./lib/comment/comment";
|
||||||
export * from "./lib/utils";
|
export * from "./lib/utils";
|
||||||
export * from "./lib/math";
|
export * from "./lib/math";
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { Code as TiptapCode } from "@tiptap/extension-code";
|
||||||
|
|
||||||
|
// #515: canonical inline `code` mark for Docmost.
|
||||||
|
//
|
||||||
|
// Tiptap's stock Code mark (via StarterKit) declares `excludes: "_"`, which
|
||||||
|
// makes it exclude EVERY other inline mark: applying `code` drops any co-
|
||||||
|
// occurring bold/italic/… on both the HTML->PM import and editor transactions.
|
||||||
|
// That silently stripped emphasis adjacent to inline code (`` **`--flag`** ``
|
||||||
|
// lost its bold on markdown import). CommonMark nests them (`<strong><code>`),
|
||||||
|
// so Docmost lets `code` combine with all marks by overriding `excludes` to the
|
||||||
|
// empty string (excludes nothing).
|
||||||
|
//
|
||||||
|
// This is the SINGLE shared source imported by the live editor, the collab
|
||||||
|
// server and the comment editor schemas. The markdown-import mirror in
|
||||||
|
// @docmost/prosemirror-markdown re-declares the same override locally (it must
|
||||||
|
// not pull this React-aware package into its node runtime) and a parity test
|
||||||
|
// keeps the two in lockstep.
|
||||||
|
export const Code = TiptapCode.extend({
|
||||||
|
excludes: "",
|
||||||
|
});
|
||||||
@@ -108,6 +108,17 @@ async function spawnCollabStack(seedDoc) {
|
|||||||
return { state, baseURL };
|
return { state, baseURL };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// y-prosemirror stores an OVERLAPPING mark (one whose type does not exclude
|
||||||
|
// itself — e.g. `comment`, and since #515 `code` with `excludes: ""`) under a
|
||||||
|
// HASHED Yjs attribute key `name--<8-char hash>` so several may coexist on a
|
||||||
|
// range. The real read path (yDocToProsemirrorJSON) strips that suffix back to
|
||||||
|
// the bare mark name via this exact regex; mirror it here so this minimal decoder
|
||||||
|
// reports the same mark names Docmost actually returns (without it an overlapping
|
||||||
|
// `code` would leak as `code--<hash>`).
|
||||||
|
const hashedMarkNameRegex = /(.*)(--[a-zA-Z0-9+/=]{8})$/;
|
||||||
|
const yattr2markname = (attrName) =>
|
||||||
|
hashedMarkNameRegex.exec(attrName)?.[1] ?? attrName;
|
||||||
|
|
||||||
// Minimal XmlFragment -> ProseMirror JSON decode, mirroring the shape Docmost
|
// Minimal XmlFragment -> ProseMirror JSON decode, mirroring the shape Docmost
|
||||||
// stores. Reads element name as node type, attributes as attrs, and recurses into
|
// stores. Reads element name as node type, attributes as attrs, and recurses into
|
||||||
// children; text nodes carry their string.
|
// children; text nodes carry their string.
|
||||||
@@ -121,8 +132,8 @@ function fragmentToJson(frag) {
|
|||||||
if (d.attributes && Object.keys(d.attributes).length) {
|
if (d.attributes && Object.keys(d.attributes).length) {
|
||||||
node.marks = Object.entries(d.attributes).map(([type, attrs]) =>
|
node.marks = Object.entries(d.attributes).map(([type, attrs]) =>
|
||||||
attrs && typeof attrs === "object" && Object.keys(attrs).length
|
attrs && typeof attrs === "object" && Object.keys(attrs).length
|
||||||
? { type, attrs }
|
? { type: yattr2markname(type), attrs }
|
||||||
: { type },
|
: { type: yattr2markname(type) },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return node;
|
return node;
|
||||||
|
|||||||
@@ -16,6 +16,7 @@
|
|||||||
* `@docmost/editor-ext` before updating the snapshot.
|
* `@docmost/editor-ext` before updating the snapshot.
|
||||||
*/
|
*/
|
||||||
import StarterKit from "@tiptap/starter-kit";
|
import StarterKit from "@tiptap/starter-kit";
|
||||||
|
import { Code } from "@tiptap/extension-code";
|
||||||
import Image from "@tiptap/extension-image";
|
import Image from "@tiptap/extension-image";
|
||||||
import TaskList from "@tiptap/extension-task-list";
|
import TaskList from "@tiptap/extension-task-list";
|
||||||
import TaskItem from "@tiptap/extension-task-item";
|
import TaskItem from "@tiptap/extension-task-item";
|
||||||
@@ -1481,7 +1482,20 @@ export const docmostExtensions = [
|
|||||||
codeBlock: {},
|
codeBlock: {},
|
||||||
heading: {},
|
heading: {},
|
||||||
link: { openOnClick: false },
|
link: { openOnClick: false },
|
||||||
|
// #515: disable StarterKit's bundled inline `code` mark so it can be replaced
|
||||||
|
// by the local override below. StarterKit's `code` inherits tiptap's
|
||||||
|
// `excludes: "_"`, which strips every co-occurring mark on HTML->PM import
|
||||||
|
// (`generateJSON`) — so `` **`--flag`** `` lost its bold. This mirror is a
|
||||||
|
// DELIBERATE standalone copy (it must not pull @docmost/editor-ext into the
|
||||||
|
// node import runtime — that would drag in React/node-views; see #293), so
|
||||||
|
// the `excludes: ""` override is declared LOCALLY here and kept in lockstep
|
||||||
|
// with the canonical `Code` in @docmost/editor-ext by a parity test.
|
||||||
|
code: false,
|
||||||
}),
|
}),
|
||||||
|
// #515: inline code that COMBINES with other marks (CommonMark-consistent).
|
||||||
|
// `excludes: ""` means the mark excludes nothing, so bold/italic/strike/… may
|
||||||
|
// co-occur with `code` and survive import.
|
||||||
|
Code.extend({ excludes: "" }),
|
||||||
// Preserve image width/height as the AUTHORED string. Without an explicit
|
// Preserve image width/height as the AUTHORED string. Without an explicit
|
||||||
// parseHTML the stock Image node attribute falls back to tiptap core's
|
// parseHTML the stock Image node attribute falls back to tiptap core's
|
||||||
// `fromString`, which coerces a numeric width like "320" into the number 320
|
// `fromString`, which coerces a numeric width like "320" into the number 320
|
||||||
|
|||||||
@@ -483,6 +483,99 @@ export function convertProseMirrorToMarkdown(
|
|||||||
return `<table><tbody>${htmlRows}</tbody></table>`;
|
return `<table><tbody>${htmlRows}</tbody></table>`;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Layer the intentional inline escapes onto a NON-code text run BEFORE its
|
||||||
|
// marks are applied. Extracted so both `case "text"` and the #515 code-emphasis
|
||||||
|
// run factoring (renderInlineChildren) escape the inner text identically. NEVER
|
||||||
|
// called on code content (a code span is literal — see the gating in the text
|
||||||
|
// case and the run helper). Order is load-bearing: the footnote raw-backslash
|
||||||
|
// doubling MUST precede the `==`/`$`/`^[` escapes (see inFootnoteBody).
|
||||||
|
const escapeInlineText = (text: string): string => {
|
||||||
|
let t = text;
|
||||||
|
if (inFootnoteBody) t = t.replace(/\\/g, "\\\\");
|
||||||
|
t = t.replace(/==/g, "\\=\\=");
|
||||||
|
t = escapeProseMath(t);
|
||||||
|
t = t.replace(/\^\[/g, "^\\[");
|
||||||
|
return t;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Wrap `text` with the markdown/HTML form of a SINGLE inline mark. Extracted
|
||||||
|
// from `case "text"` so the same per-mark emission is reused when the #515
|
||||||
|
// run factoring layers a shared outer mark over a code-emphasis run. `code` is
|
||||||
|
// handled by the callers (wrapped innermost, before this runs), so this branch
|
||||||
|
// is defensive only. For any non-code mark the output is byte-identical to the
|
||||||
|
// pre-#515 inline switch.
|
||||||
|
const applyInlineMark = (text: string, mark: any): string => {
|
||||||
|
switch (mark.type) {
|
||||||
|
case "bold":
|
||||||
|
return `**${text}**`;
|
||||||
|
case "italic":
|
||||||
|
return `*${text}*`;
|
||||||
|
case "code":
|
||||||
|
// Callers wrap the code span innermost themselves; reached only if a
|
||||||
|
// mark list is applied through here directly. Emit the backtick span.
|
||||||
|
return `\`${text}\``;
|
||||||
|
case "link": {
|
||||||
|
const href = mark.attrs?.href || "";
|
||||||
|
const title = mark.attrs?.title;
|
||||||
|
if (title) {
|
||||||
|
// Emit the optional markdown link title; escape an embedded double-
|
||||||
|
// quote so it cannot terminate the title string early.
|
||||||
|
const safeTitle = String(title).replace(/"/g, '\\"');
|
||||||
|
return `[${text}](${href} "${safeTitle}")`;
|
||||||
|
}
|
||||||
|
return `[${text}](${href})`;
|
||||||
|
}
|
||||||
|
case "strike":
|
||||||
|
return `~~${text}~~`;
|
||||||
|
case "underline":
|
||||||
|
return `<u>${text}</u>`;
|
||||||
|
case "subscript":
|
||||||
|
return `<sub>${text}</sub>`;
|
||||||
|
case "superscript":
|
||||||
|
return `<sup>${text}</sup>`;
|
||||||
|
case "highlight": {
|
||||||
|
// #293 canon #7: a highlight WITHOUT a color serializes as the
|
||||||
|
// Obsidian/GFM `==text==` syntax; a colored highlight keeps the `<mark
|
||||||
|
// style>` HTML form. The inner text already had any literal `==`
|
||||||
|
// backslash-escaped upstream.
|
||||||
|
const color = mark.attrs?.color;
|
||||||
|
return color
|
||||||
|
? `<mark style="background-color: ${escapeAttr(color)}">${text}</mark>`
|
||||||
|
: `==${text}==`;
|
||||||
|
}
|
||||||
|
case "textStyle":
|
||||||
|
if (mark.attrs?.color) {
|
||||||
|
return `<span style="color: ${escapeAttr(mark.attrs.color)}">${text}</span>`;
|
||||||
|
}
|
||||||
|
return text;
|
||||||
|
case "spoiler":
|
||||||
|
// Markdown has no native spoiler syntax, so emit the same raw inline HTML
|
||||||
|
// the editor-ext/MCP stack uses (span[data-spoiler] round-trips).
|
||||||
|
return `<span data-spoiler="true">${text}</span>`;
|
||||||
|
case "comment": {
|
||||||
|
// Inline comment anchor (span[data-comment-id]); resolved anchors are
|
||||||
|
// optionally dropped for agent reads, keeping only the bare text.
|
||||||
|
const cid = mark.attrs?.commentId;
|
||||||
|
if (cid) {
|
||||||
|
if (mark.attrs?.resolved && dropResolvedCommentAnchors) {
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
const resolvedAttr = mark.attrs?.resolved
|
||||||
|
? ` data-resolved="true"`
|
||||||
|
: "";
|
||||||
|
return `<span data-comment-id="${escapeAttr(cid)}"${resolvedAttr}>${text}</span>`;
|
||||||
|
}
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
// Unknown mark: no dedicated case, so it has no markdown form and is
|
||||||
|
// dropped from the run. Report the loss (throws in strict mode) then
|
||||||
|
// leave the text unwrapped — the historical behavior.
|
||||||
|
warnLoss("mark", String(mark.type));
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const processNode = (node: any): string => {
|
const processNode = (node: any): string => {
|
||||||
if (nodeDepth >= MAX_NODE_DEPTH) {
|
if (nodeDepth >= MAX_NODE_DEPTH) {
|
||||||
// Bail out of deeper recursion without throwing. A text node still has
|
// Bail out of deeper recursion without throwing. A text node still has
|
||||||
@@ -582,160 +675,38 @@ export function convertProseMirrorToMarkdown(
|
|||||||
return headingLine;
|
return headingLine;
|
||||||
}
|
}
|
||||||
|
|
||||||
case "text":
|
case "text": {
|
||||||
let textContent = node.text || "";
|
let textContent = node.text || "";
|
||||||
// #293 canon #7: `==` is now a LIVE inline highlight syntax on import (a
|
// #515: `code` is no longer exclusive (`excludes: ""`), so a run may
|
||||||
// marked inline extension turns `==text==` into a color-less highlight
|
// carry `code` TOGETHER with other marks. The inner escapes below apply
|
||||||
// mark). A LITERAL `==` in a text run would therefore be misparsed as a
|
// ONLY to a NON-code run (a code span's content is literal — `==`, `$…$`,
|
||||||
// highlight on the next import, so backslash-escape each `=` of a `==`
|
// `^[` must stay verbatim, matching `` `a == b` `` staying code). See
|
||||||
// pair; marked's escape tokenizer decodes `\=` back to a literal `=`, so
|
// #293 canon #2/#6/#7 for why each escape exists (extracted into
|
||||||
// a literal `==` round-trips as text (never materializes a phantom mark).
|
// escapeInlineText). A code run's `==`/`$`/`^[` are protected by the
|
||||||
// This runs for BOTH unmarked text and marked non-code runs, but NOT for
|
// backticks, so they are never misparsed on re-import.
|
||||||
// an inline code span (a run carrying the `code` mark returns a backtick
|
const hasCode = (node.marks || []).some((m: any) => m.type === "code");
|
||||||
// span below with `==` verbatim, matching `` `a == b` `` staying code).
|
if (!hasCode) {
|
||||||
// A highlight run's own `==` delimiters are appended AFTER this in the
|
textContent = escapeInlineText(textContent);
|
||||||
// marks loop, so they are never escaped; only the run's inner text is.
|
|
||||||
if (!(node.marks || []).some((m: any) => m.type === "code")) {
|
|
||||||
// #293 canon #2 (F2): inside a footnote body, DOUBLE every RAW user
|
|
||||||
// backslash FIRST, so it survives `^[…]` (the import tokenizer treats
|
|
||||||
// `\<char>` as an escape when balancing brackets, and `parseInline`
|
|
||||||
// decodes escapes). Doing it before the intentional escapes below keeps
|
|
||||||
// the serializer's own single escapes (`\=` `\$` `^\[`, and the `\[`/
|
|
||||||
// `\]` balanceBrackets adds) single; only genuine user backslashes are
|
|
||||||
// doubled. Skipped for code runs (a code span's content is NOT decoded
|
|
||||||
// by parseInline, so its backslashes must stay verbatim).
|
|
||||||
if (inFootnoteBody) {
|
|
||||||
textContent = textContent.replace(/\\/g, "\\\\");
|
|
||||||
}
|
|
||||||
textContent = textContent.replace(/==/g, "\\=\\=");
|
|
||||||
// #293 canon #6: escape a would-be inline-math `$…$` span so it stays
|
|
||||||
// literal text on re-import (currency `$5` is left clean — see
|
|
||||||
// escapeProseMath). Runs on the SAME non-code runs as the `==` escape
|
|
||||||
// above; an inline `code` run returns verbatim below, matching the
|
|
||||||
// codeBlock path (a `$…$` inside code must stay code, never math).
|
|
||||||
textContent = escapeProseMath(textContent);
|
|
||||||
// #293 canon #2: `^[` opens a LIVE inline-footnote span on import
|
|
||||||
// (`^[text]` -> a footnote reference). A LITERAL `^[` in prose text
|
|
||||||
// would therefore materialize a phantom footnote on the next import, so
|
|
||||||
// backslash-escape the bracket (`^[` -> `^\[`); marked's escape
|
|
||||||
// tokenizer decodes `\[` back to `[`, so a literal `^[…]` round-trips
|
|
||||||
// as text and never opens a footnote. Only the OPENING `^[` needs
|
|
||||||
// breaking (the tokenizer requires it), so this is a minimal, idempotent
|
|
||||||
// escape. A real footnoteReference node emits `^[body]` from its own
|
|
||||||
// case, never through here.
|
|
||||||
textContent = textContent.replace(/\^\[/g, "^\\[");
|
|
||||||
}
|
}
|
||||||
// Apply marks (bold, italic, code, etc.)
|
|
||||||
if (node.marks) {
|
if (node.marks) {
|
||||||
// The schema's `code` mark declares `excludes: "_"` — it excludes every
|
// #515: wrap the backtick code span FIRST (innermost mark), then layer
|
||||||
// other inline mark — so the editor can NEVER produce a text run that
|
// the REMAINING marks in array order. For a run WITHOUT a code mark the
|
||||||
// carries `code` together with another mark, and on import any
|
// loop applies every mark exactly as the pre-#515 switch did, so the
|
||||||
// co-occurring mark is always dropped (the run comes back as code-only).
|
// output is byte-identical. For a code+emphasis run the code span sits
|
||||||
// The lossless, byte-stable behavior is therefore: when a run has the
|
// inside the emphasis delimiters (`` **`code`** ``), matching CommonMark.
|
||||||
// `code` mark, emit ONLY the backtick code span and ignore every other
|
// The shared-mark grouping across ADJACENT nodes (`` **`a` + `b`** ``)
|
||||||
// mark, so md1 is already code-only and md2 === md1. Runs WITHOUT a code
|
// lives in renderInlineChildren; this direct path handles a lone run
|
||||||
// mark are rendered exactly as before.
|
// and the table/`default` callers that invoke processNode per node.
|
||||||
const markTypes = node.marks.map((m: any) => m.type);
|
|
||||||
const hasCode = markTypes.includes("code");
|
|
||||||
if (hasCode) {
|
if (hasCode) {
|
||||||
textContent = `\`${textContent}\``;
|
textContent = `\`${textContent}\``;
|
||||||
return textContent;
|
|
||||||
}
|
}
|
||||||
for (const mark of node.marks) {
|
for (const mark of node.marks) {
|
||||||
switch (mark.type) {
|
if (mark.type === "code") continue; // wrapped innermost above
|
||||||
case "bold":
|
textContent = applyInlineMark(textContent, mark);
|
||||||
textContent = `**${textContent}**`;
|
|
||||||
break;
|
|
||||||
case "italic":
|
|
||||||
textContent = `*${textContent}*`;
|
|
||||||
break;
|
|
||||||
case "code":
|
|
||||||
// A `code` run already returned above (hasCode early return), so
|
|
||||||
// this branch is only reached for a non-code run that somehow
|
|
||||||
// still lists `code`; emit the plain backtick span.
|
|
||||||
textContent = `\`${textContent}\``;
|
|
||||||
break;
|
|
||||||
case "link": {
|
|
||||||
const href = mark.attrs?.href || "";
|
|
||||||
const title = mark.attrs?.title;
|
|
||||||
if (title) {
|
|
||||||
// Emit the optional markdown link title; escape an embedded
|
|
||||||
// double-quote so it cannot terminate the title string early.
|
|
||||||
const safeTitle = String(title).replace(/"/g, '\\"');
|
|
||||||
textContent = `[${textContent}](${href} "${safeTitle}")`;
|
|
||||||
} else {
|
|
||||||
textContent = `[${textContent}](${href})`;
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case "strike":
|
|
||||||
textContent = `~~${textContent}~~`;
|
|
||||||
break;
|
|
||||||
case "underline":
|
|
||||||
textContent = `<u>${textContent}</u>`;
|
|
||||||
break;
|
|
||||||
case "subscript":
|
|
||||||
textContent = `<sub>${textContent}</sub>`;
|
|
||||||
break;
|
|
||||||
case "superscript":
|
|
||||||
textContent = `<sup>${textContent}</sup>`;
|
|
||||||
break;
|
|
||||||
case "highlight": {
|
|
||||||
// #293 canon #7: a highlight WITHOUT a color serializes as the
|
|
||||||
// Obsidian/GFM `==text==` syntax (the importer's marked inline
|
|
||||||
// `==` extension parses it back to a color-less highlight mark).
|
|
||||||
// A highlight WITH a color keeps the `<mark style="background-
|
|
||||||
// color: …">` HTML form (the condition is deterministic on the
|
|
||||||
// `color` attr), so a colored highlight is not flattened. The
|
|
||||||
// inner textContent already had any literal `==` backslash-
|
|
||||||
// escaped above, so a highlight over text containing `==` still
|
|
||||||
// round-trips.
|
|
||||||
const color = mark.attrs?.color;
|
|
||||||
textContent = color
|
|
||||||
? `<mark style="background-color: ${escapeAttr(color)}">${textContent}</mark>`
|
|
||||||
: `==${textContent}==`;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case "textStyle":
|
|
||||||
if (mark.attrs?.color) {
|
|
||||||
textContent = `<span style="color: ${escapeAttr(mark.attrs.color)}">${textContent}</span>`;
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
case "spoiler":
|
|
||||||
// Markdown has no native spoiler syntax, so emit the same raw
|
|
||||||
// inline HTML the editor-ext/MCP stack uses. The schema's Spoiler
|
|
||||||
// mark parses span[data-spoiler] back on import, so the mark
|
|
||||||
// survives the PM -> MD -> PM round-trip.
|
|
||||||
textContent = `<span data-spoiler="true">${textContent}</span>`;
|
|
||||||
break;
|
|
||||||
case "comment": {
|
|
||||||
// Emit the inline comment anchor so highlights round-trip. The
|
|
||||||
// schema's Comment mark parses span[data-comment-id] (attrs
|
|
||||||
// commentId/resolved).
|
|
||||||
const cid = mark.attrs?.commentId;
|
|
||||||
if (cid) {
|
|
||||||
// Hide resolved anchors from agent reads: drop the wrapper and
|
|
||||||
// keep only the bare text. Active anchors keep their wrapper.
|
|
||||||
if (mark.attrs?.resolved && dropResolvedCommentAnchors) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
const resolvedAttr = mark.attrs?.resolved
|
|
||||||
? ` data-resolved="true"`
|
|
||||||
: "";
|
|
||||||
textContent = `<span data-comment-id="${escapeAttr(cid)}"${resolvedAttr}>${textContent}</span>`;
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
default:
|
|
||||||
// Unknown mark: no dedicated case, so it has no markdown form and
|
|
||||||
// is dropped from the run. Report the loss (throws in strict
|
|
||||||
// mode) then leave the text unwrapped — the historical behavior.
|
|
||||||
warnLoss("mark", String(mark.type));
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return textContent;
|
return textContent;
|
||||||
|
}
|
||||||
|
|
||||||
case "codeBlock":
|
case "codeBlock":
|
||||||
const language = node.attrs?.language || "";
|
const language = node.attrs?.language || "";
|
||||||
@@ -1327,18 +1298,165 @@ export function convertProseMirrorToMarkdown(
|
|||||||
// For that node ONLY we fall back to the lossless schema-HTML `<span>` form.
|
// For that node ONLY we fall back to the lossless schema-HTML `<span>` form.
|
||||||
// Every other inline node is rendered exactly as processNode would, so output
|
// Every other inline node is rendered exactly as processNode would, so output
|
||||||
// is unchanged whenever no math sits directly before a digit.
|
// is unchanged whenever no math sits directly before a digit.
|
||||||
|
// #515: a "bare-delimiter" emphasis mark is one that serializes as a naked
|
||||||
|
// markdown delimiter run (`**` `*` `~~` `==`) — bold / italic / strike /
|
||||||
|
// UNCOLORED highlight. These delimiters COLLIDE with the backtick-flanking
|
||||||
|
// delimiters emitted around a code+emphasis run: rendering `[code,bold]` next
|
||||||
|
// to `[italic]` node-by-node would produce `` **`a`***b* `` (a `***` run that
|
||||||
|
// re-imports wrong). Every OTHER mark (underline/sub/sup/spoiler/comment/
|
||||||
|
// textStyle/colored-highlight/link) emits an HTML/bracket form whose boundaries
|
||||||
|
// do NOT collapse, so those neighbors never join a run.
|
||||||
|
const isBareEmphasisMark = (mark: any): boolean => {
|
||||||
|
switch (mark?.type) {
|
||||||
|
case "bold":
|
||||||
|
case "italic":
|
||||||
|
case "strike":
|
||||||
|
return true;
|
||||||
|
case "highlight":
|
||||||
|
return !mark.attrs?.color; // colored highlight emits <mark>, not `==`
|
||||||
|
default:
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// A text node participates in a code-emphasis run iff it carries at least one
|
||||||
|
// bare-delimiter emphasis mark. A code-ONLY node (no emphasis) does NOT — so a
|
||||||
|
// plain `` `code` `` next to `**bold**` keeps its clean, byte-identical
|
||||||
|
// markdown (they share no colliding delimiter). Existing pages, where a code
|
||||||
|
// node could never carry emphasis, therefore serialize exactly as before.
|
||||||
|
const isEmphasisMember = (node: any): boolean =>
|
||||||
|
node?.type === "text" &&
|
||||||
|
(node.marks || []).some((m: any) => isBareEmphasisMark(m));
|
||||||
|
|
||||||
|
// The run's non-code marks (order preserved) — the candidate marks to factor.
|
||||||
|
const nonCodeMarks = (node: any): any[] =>
|
||||||
|
(node.marks || []).filter((m: any) => m.type !== "code");
|
||||||
|
|
||||||
|
// Deep structural equality of two marks (type + full attrs). Two `link` marks
|
||||||
|
// are equal only when EVERY attr matches (class/href/internal/rel/target/title
|
||||||
|
// — not just href), so a homogeneous run never merges links that differ.
|
||||||
|
const marksEqual = (a: any, b: any): boolean =>
|
||||||
|
a.type === b.type &&
|
||||||
|
JSON.stringify(a.attrs ?? null) === JSON.stringify(b.attrs ?? null);
|
||||||
|
|
||||||
|
// Two non-code mark lists are equal AS SETS (a run is homogeneous when every
|
||||||
|
// node shares the identical non-code mark set — order-independent).
|
||||||
|
const markSetsEqual = (a: any[], b: any[]): boolean =>
|
||||||
|
a.length === b.length &&
|
||||||
|
a.every((ma) => b.some((mb) => marksEqual(ma, mb))) &&
|
||||||
|
b.every((mb) => a.some((ma) => marksEqual(mb, ma)));
|
||||||
|
|
||||||
|
// Serialize one node's INNER form for a homogeneous run: the factored marks are
|
||||||
|
// applied by the caller, so here a code node emits only its literal backtick
|
||||||
|
// span and a non-code node emits only its (escaped) text.
|
||||||
|
const renderRunInner = (node: any): string => {
|
||||||
|
const text = node.text || "";
|
||||||
|
if ((node.marks || []).some((m: any) => m.type === "code")) {
|
||||||
|
return `\`${text}\``; // code content is literal
|
||||||
|
}
|
||||||
|
return escapeInlineText(text);
|
||||||
|
};
|
||||||
|
|
||||||
|
// A markdown emphasis delimiter (`**`/`*`/`~~`/`==`) wrapping a code span opens
|
||||||
|
// with the delimiter immediately followed by a backtick and closes immediately
|
||||||
|
// preceded by one. A backtick is CommonMark punctuation, so such a delimiter is
|
||||||
|
// only left/right-flanking — able to open/close emphasis — when the character
|
||||||
|
// on its OUTER side is start/end, whitespace or punctuation. If a run boundary
|
||||||
|
// abuts a word character, the delimiter would NOT flank (`a**` `code` `**`
|
||||||
|
// never opens) and the emphasis silently degrades on re-import. This checks the
|
||||||
|
// outer boundary char conservatively: ASCII whitespace or ASCII punctuation (or
|
||||||
|
// the string edge) is safe; anything else (a letter/number, unicode letter or
|
||||||
|
// emoji) is treated as unsafe so the run takes the lossless HTML fallback.
|
||||||
|
const SAFE_BOUNDARY = /[\s!-/:-@[-`{-~]/;
|
||||||
|
const isSafeBoundary = (c: string): boolean => c === "" || SAFE_BOUNDARY.test(c);
|
||||||
|
|
||||||
|
// Serialize a maximal run of adjacent emphasis-member text nodes that contains
|
||||||
|
// at least one `code` node (#515). HOMOGENEOUS (all share the identical
|
||||||
|
// non-code mark set) AND flank-safe on both boundaries: factor the common marks
|
||||||
|
// ONCE around the concatenated inner spans — `` **`aaa` + `bbb`** ``, code
|
||||||
|
// innermost. Otherwise — HETEROGENEOUS (non-code sets differ, e.g. `[code,bold]`
|
||||||
|
// next to `[italic]`) OR a boundary abuts a word char — emit the whole run as
|
||||||
|
// schema-HTML via the lossless inlineToHtml fallback, avoiding a colliding
|
||||||
|
// `***` delimiter run or a non-flanking `a**` that would drop the emphasis.
|
||||||
|
const renderCodeEmphasisRun = (
|
||||||
|
run: any[],
|
||||||
|
prevChar: string,
|
||||||
|
nextChar: string,
|
||||||
|
): string => {
|
||||||
|
const firstNonCode = nonCodeMarks(run[0]);
|
||||||
|
const homogeneous = run.every((n) =>
|
||||||
|
markSetsEqual(nonCodeMarks(n), firstNonCode),
|
||||||
|
);
|
||||||
|
if (!homogeneous || !isSafeBoundary(prevChar) || !isSafeBoundary(nextChar)) {
|
||||||
|
return inlineToHtml(run);
|
||||||
|
}
|
||||||
|
let out = run.map(renderRunInner).join("");
|
||||||
|
// Apply the common non-code marks in the FIRST node's array order (code is
|
||||||
|
// already innermost inside each span).
|
||||||
|
for (const mark of firstNonCode) out = applyInlineMark(out, mark);
|
||||||
|
return out;
|
||||||
|
};
|
||||||
|
|
||||||
const renderInlineChildren = (nodes: any[]): string => {
|
const renderInlineChildren = (nodes: any[]): string => {
|
||||||
const parts = nodes.map(processNode);
|
// Pass 1: segment the nodes. Each segment is either an already-rendered
|
||||||
for (let i = 0; i < nodes.length - 1; i++) {
|
// non-run node / pure-emphasis node (byte-identical to the pre-#515 output),
|
||||||
if (
|
// or a DEFERRED code-emphasis run (a maximal block of consecutive
|
||||||
nodes[i]?.type === "mathInline" &&
|
// emphasis-member text nodes containing a code node) — its markdown-vs-HTML
|
||||||
parts[i].startsWith("$") &&
|
// choice needs the neighbor boundary chars, resolved in pass 2.
|
||||||
/^[0-9]/.test(parts[i + 1] || "")
|
type Seg = { firstNode: any; text?: string; run?: any[] };
|
||||||
) {
|
const segs: Seg[] = [];
|
||||||
parts[i] = mathInlineHtml(nodes[i].attrs?.text || "");
|
let i = 0;
|
||||||
|
while (i < nodes.length) {
|
||||||
|
const node = nodes[i];
|
||||||
|
if (isEmphasisMember(node)) {
|
||||||
|
let j = i;
|
||||||
|
while (j < nodes.length && isEmphasisMember(nodes[j])) j++;
|
||||||
|
const run = nodes.slice(i, j);
|
||||||
|
const hasCode = run.some((n: any) =>
|
||||||
|
(n.marks || []).some((m: any) => m.type === "code"),
|
||||||
|
);
|
||||||
|
if (hasCode) {
|
||||||
|
segs.push({ firstNode: run[0], run });
|
||||||
|
} else {
|
||||||
|
// Pure-emphasis run (no code): render each node as before.
|
||||||
|
for (const n of run) segs.push({ firstNode: n, text: processNode(n) });
|
||||||
|
}
|
||||||
|
i = j;
|
||||||
|
} else {
|
||||||
|
segs.push({ firstNode: node, text: processNode(node) });
|
||||||
|
i++;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return parts.join("");
|
// A deferred run always emits either a delimiter/backtick (markdown) or `<`
|
||||||
|
// (HTML) first — both punctuation — so a following run counts as a safe
|
||||||
|
// boundary for the current one without resolving it first.
|
||||||
|
const firstCharOf = (seg: Seg): string =>
|
||||||
|
seg.text !== undefined ? seg.text[0] || "" : "*";
|
||||||
|
// Pass 2: resolve deferred runs left-to-right, tracking the previous emitted
|
||||||
|
// char (for the opening boundary) and peeking the next segment (for closing).
|
||||||
|
let prevChar = "";
|
||||||
|
for (let k = 0; k < segs.length; k++) {
|
||||||
|
const seg = segs[k];
|
||||||
|
if (seg.text === undefined) {
|
||||||
|
const nextChar = k + 1 < segs.length ? firstCharOf(segs[k + 1]) : "";
|
||||||
|
seg.text = renderCodeEmphasisRun(seg.run!, prevChar, nextChar);
|
||||||
|
}
|
||||||
|
if (seg.text.length > 0) prevChar = seg.text[seg.text.length - 1];
|
||||||
|
}
|
||||||
|
// Preserve the mathInline-before-digit guard: a `$…$` immediately followed by
|
||||||
|
// a digit-leading segment would re-tokenize as a longer math span, so emit
|
||||||
|
// that math node as HTML instead. A code-emphasis run never starts with a
|
||||||
|
// digit (it opens with a delimiter or `<`), so segment granularity is safe.
|
||||||
|
for (let k = 0; k < segs.length - 1; k++) {
|
||||||
|
if (
|
||||||
|
segs[k].firstNode?.type === "mathInline" &&
|
||||||
|
(segs[k].text || "").startsWith("$") &&
|
||||||
|
/^[0-9]/.test(segs[k + 1].text || "")
|
||||||
|
) {
|
||||||
|
segs[k].text = mathInlineHtml(segs[k].firstNode.attrs?.text || "");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return segs.map((s) => s.text).join("");
|
||||||
};
|
};
|
||||||
|
|
||||||
// Render inline content (text runs + their marks) to HTML. Used by the raw
|
// Render inline content (text runs + their marks) to HTML. Used by the raw
|
||||||
@@ -1373,7 +1491,22 @@ export function convertProseMirrorToMarkdown(
|
|||||||
return processNode(n);
|
return processNode(n);
|
||||||
}
|
}
|
||||||
let t = escapeHtmlText(n.text || "");
|
let t = escapeHtmlText(n.text || "");
|
||||||
|
// #515: wrap `<code>` INNERMOST first (before the array-order mark loop),
|
||||||
|
// then skip `code` in the loop. The imported mark order is NOT fixed — it
|
||||||
|
// DEPENDS on the emphasis extension: import (`generateJSON`) yields code
|
||||||
|
// LAST for bold/italic/strike (`[emphasis, code]`) but code FIRST for the
|
||||||
|
// `==`-highlight extension (`[code, highlight]`). So we cannot rely on a
|
||||||
|
// fixed array position; the invariant is instead "wrap `<code>` innermost
|
||||||
|
// regardless of the imported order". That keeps `<code>` nested inside the
|
||||||
|
// emphasis tag both directions (preserving the byte fixpoint — an order-
|
||||||
|
// sensitive loop would flip `<strong><code>`↔`<code><strong>` depending on
|
||||||
|
// which order it happened to see) and matches the markdown path (case
|
||||||
|
// "text" / run factoring).
|
||||||
|
if ((n.marks || []).some((m: any) => m.type === "code")) {
|
||||||
|
t = `<code>${t}</code>`;
|
||||||
|
}
|
||||||
for (const mark of n.marks || []) {
|
for (const mark of n.marks || []) {
|
||||||
|
if (mark.type === "code") continue; // wrapped innermost above
|
||||||
switch (mark.type) {
|
switch (mark.type) {
|
||||||
case "bold":
|
case "bold":
|
||||||
t = `<strong>${t}</strong>`;
|
t = `<strong>${t}</strong>`;
|
||||||
|
|||||||
@@ -11,9 +11,11 @@
|
|||||||
*
|
*
|
||||||
* The corpus deliberately spans the CommonMark / canon hostile alphabet
|
* The corpus deliberately spans the CommonMark / canon hostile alphabet
|
||||||
* (`* _ [ ] ( ) { } | < > & # ! ~ = + -`), unicode / emoji / RTL, and the legal
|
* (`* _ [ ] ( ) { } | < > & # ! ~ = + -`), unicode / emoji / RTL, and the legal
|
||||||
* mark combinations on runs (including the `code` mark, which the schema's
|
* mark combinations on runs. As of #515 the `code` mark no longer excludes other
|
||||||
* `excludes: "_"` makes suppress every co-occurring mark — so it is never
|
* marks (`excludes: ""`), so the corpus ALSO combines `code` with bold / italic /
|
||||||
* combined with another mark in the byte-stable space).
|
* strike / highlight — exercising both the HOMOGENEOUS run factoring (adjacent
|
||||||
|
* code+bold spans -> `` **`a` `b`** ``) and the HETEROGENEOUS anti-collision
|
||||||
|
* fallback (`[code,bold]` next to `[italic]` -> schema-HTML, never `` `a`***b* ``).
|
||||||
*/
|
*/
|
||||||
import fc from 'fast-check';
|
import fc from 'fast-check';
|
||||||
|
|
||||||
@@ -106,16 +108,16 @@ export const urlArb: fc.Arbitrary<string> = fc
|
|||||||
/**
|
/**
|
||||||
* A text run with an OPTIONAL single non-code formatting mark (bold/italic/
|
* A text run with an OPTIONAL single non-code formatting mark (bold/italic/
|
||||||
* strike/underline/superscript/subscript/spoiler), or a SOLE `code` mark, or a
|
* strike/underline/superscript/subscript/spoiler), or a SOLE `code` mark, or a
|
||||||
* link, or an inline comment anchor. `code` is NEVER combined with another mark
|
* `code` mark COMBINED with a bare-delimiter emphasis mark (#515), or a link, or
|
||||||
* in the byte-stable space (that combination is a documented converter
|
* an inline comment anchor. Marks wrap `safeTextArb`, which stays stable even
|
||||||
* limitation — the schema's `code` mark declares `excludes: "_"`). Marks wrap
|
* when it contains isolated specials.
|
||||||
* `safeTextArb`, which stays stable even when it contains isolated specials.
|
|
||||||
*
|
*
|
||||||
* The mark set here is broadened past the sibling test's {bold,italic,strike}
|
* The mark set here is broadened past the sibling test's {bold,italic,strike} to
|
||||||
* to also cover underline / superscript / subscript / spoiler / textStyle /
|
* also cover underline / superscript / subscript / spoiler / textStyle /
|
||||||
* highlight (all single, non-code marks), so the marks-on-text generator
|
* highlight (all single, non-code marks). As of #515 it ALSO emits `code`
|
||||||
* exercises every mark the schema declares except the deliberately-excluded
|
* combined with bold/italic/strike, so the assembled inline content exercises the
|
||||||
* `code`+other combination.
|
* converter's code-emphasis run detection (adjacent combos -> homogeneous
|
||||||
|
* factoring or heterogeneous HTML fallback, both lossless).
|
||||||
*/
|
*/
|
||||||
export const markedTextRunArb: fc.Arbitrary<any> = fc.oneof(
|
export const markedTextRunArb: fc.Arbitrary<any> = fc.oneof(
|
||||||
// Plain text.
|
// Plain text.
|
||||||
@@ -138,6 +140,25 @@ export const markedTextRunArb: fc.Arbitrary<any> = fc.oneof(
|
|||||||
// Sole code mark (backtick span). safeTextArb is backtick-free, so the span
|
// Sole code mark (backtick span). safeTextArb is backtick-free, so the span
|
||||||
// content cannot contain an inner backtick.
|
// content cannot contain an inner backtick.
|
||||||
safeTextArb.map((t) => ({ type: 'text', text: t, marks: [{ type: 'code' }] })),
|
safeTextArb.map((t) => ({ type: 'text', text: t, marks: [{ type: 'code' }] })),
|
||||||
|
// #515: code COMBINED with a bare-delimiter emphasis mark. The converter nests
|
||||||
|
// the backtick span inside the emphasis delimiters (`` **`x`** ``) and, when
|
||||||
|
// such runs sit adjacent, factors a shared mark or falls back to schema-HTML.
|
||||||
|
// Mark order here is `[emphasis, code]` — the order the HTML->PM import yields
|
||||||
|
// for bold/italic/strike specifically (code last). This is NOT universal: the
|
||||||
|
// `==`-highlight case below imports code FIRST — so match each case to its own
|
||||||
|
// imported order for the order-exact P1 round-trip (do not assume a fixed order).
|
||||||
|
fc
|
||||||
|
.tuple(safeTextArb, fc.constantFrom('bold', 'italic', 'strike'))
|
||||||
|
.map(([t, m]) => ({ type: 'text', text: t, marks: [{ type: m }, { type: 'code' }] })),
|
||||||
|
// #515: code combined with an UNCOLORED highlight (also a bare-delimiter mark,
|
||||||
|
// `==…==`), so the highlight+code delimiter interaction is covered too. Import
|
||||||
|
// yields `[code, highlight]` here (the `==` inline extension nests code first),
|
||||||
|
// so the generator matches that order for the order-exact P1 round-trip.
|
||||||
|
safeTextArb.map((t) => ({
|
||||||
|
type: 'text',
|
||||||
|
text: t,
|
||||||
|
marks: [{ type: 'code' }, { type: 'highlight' }],
|
||||||
|
})),
|
||||||
// Link with safe text, a paren/space-free href, optionally a letter-bearing
|
// Link with safe text, a paren/space-free href, optionally a letter-bearing
|
||||||
// title (a purely numeric title is coerced to a number and dropped).
|
// title (a purely numeric title is coerced to a number and dropped).
|
||||||
fc
|
fc
|
||||||
|
|||||||
@@ -294,10 +294,11 @@ describe('converter gap coverage — emission branches (specs 1–11)', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
// 5. code + link co-occur: the schema's `code` mark excludes all other marks
|
// 5. code + link co-occur (#515): `code` no longer excludes other marks, so a
|
||||||
// (including link), so the link cannot survive import. The lossless,
|
// link can wrap inline code. The code span is emitted innermost and the link
|
||||||
// byte-stable behavior is to emit ONLY the backtick code span (code wins).
|
// wraps it — CommonMark allows inline code inside link text, so it survives
|
||||||
it('a code+link run emits the backtick code form (code wins, link dropped)', () => {
|
// the round trip.
|
||||||
|
it('a code+link run nests the backtick span inside the link (#515)', () => {
|
||||||
const out = convertProseMirrorToMarkdown(
|
const out = convertProseMirrorToMarkdown(
|
||||||
doc(
|
doc(
|
||||||
para({
|
para({
|
||||||
@@ -310,7 +311,7 @@ describe('converter gap coverage — emission branches (specs 1–11)', () => {
|
|||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
expect(out).toBe('`x`');
|
expect(out).toBe('[`x`](http://a?b&c"d)');
|
||||||
});
|
});
|
||||||
|
|
||||||
// 6. hardBreak inside a heading: prefix applied once, " \n" between a and b.
|
// 6. hardBreak inside a heading: prefix applied once, " \n" between a and b.
|
||||||
|
|||||||
@@ -59,22 +59,21 @@ describe('convertProseMirrorToMarkdown', () => {
|
|||||||
).toBe('`x`');
|
).toBe('`x`');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('code + another mark emits the backtick code form (code wins)', () => {
|
it('code + bold nests the backtick span inside the emphasis (#515)', () => {
|
||||||
// The schema's `code` mark excludes all other marks, so the editor can
|
// #515: the `code` mark no longer excludes other marks (`excludes: ""`), so
|
||||||
// never produce code+bold on one run and import always drops the co-mark.
|
// a run can carry code+bold. CommonMark nests them (`<strong><code>`), so
|
||||||
// The lossless, byte-stable behavior is to emit ONLY the backtick code
|
// the code span is emitted innermost and the bold delimiters wrap it.
|
||||||
// span and ignore the co-occurring mark.
|
|
||||||
const out = convertProseMirrorToMarkdown(
|
const out = convertProseMirrorToMarkdown(
|
||||||
doc(para(text('x', [{ type: 'bold' }, { type: 'code' }]))),
|
doc(para(text('x', [{ type: 'bold' }, { type: 'code' }]))),
|
||||||
);
|
);
|
||||||
expect(out).toBe('`x`');
|
expect(out).toBe('**`x`**');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('code + strike combo emits the backtick code form (code wins)', () => {
|
it('code + strike nests the backtick span inside the emphasis (#515)', () => {
|
||||||
const out = convertProseMirrorToMarkdown(
|
const out = convertProseMirrorToMarkdown(
|
||||||
doc(para(text('x', [{ type: 'strike' }, { type: 'code' }]))),
|
doc(para(text('x', [{ type: 'strike' }, { type: 'code' }]))),
|
||||||
);
|
);
|
||||||
expect(out).toBe('`x`');
|
expect(out).toBe('~~`x`~~');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -80,13 +80,7 @@ import { stripBlockIds } from './roundtrip-helpers.js';
|
|||||||
// `it.fails` blocks below (so the suite stays green only because they are marked
|
// `it.fails` blocks below (so the suite stays green only because they are marked
|
||||||
// expected-to-fail, never by hiding them):
|
// expected-to-fail, never by hiding them):
|
||||||
//
|
//
|
||||||
// 1. The `code` mark COMBINED with any other mark. The converter emits nested
|
// 1. A BLOCK-level `image` placed BETWEEN other blocks. The Docmost image node
|
||||||
// HTML (`<strong><code>x</code></strong>`), but the schema's `code` mark
|
|
||||||
// declares `excludes: "_"`, so on import every co-occurring mark is dropped
|
|
||||||
// and the run comes back as `code` only -> md2 == "`x`". Acknowledged in
|
|
||||||
// markdown-converter.ts (the long comment above the marks switch);
|
|
||||||
// impossible to round-trip both while `code` excludes them.
|
|
||||||
// 2. A BLOCK-level `image` placed BETWEEN other blocks. The Docmost image node
|
|
||||||
// is block-level but `` is inline; marked wraps it in a <p>, the
|
// is block-level but `` is inline; marked wraps it in a <p>, the
|
||||||
// schema hoists the <img> out and leaves an empty paragraph sibling, which
|
// schema hoists the <img> out and leaves an empty paragraph sibling, which
|
||||||
// injects an extra blank gap on the second export. An image IS byte-stable
|
// injects an extra blank gap on the second export. An image IS byte-stable
|
||||||
@@ -625,7 +619,7 @@ describe('markdown <-> ProseMirror round-trip (property-based)', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
// KNOWN, DOCUMENTED non-roundtrip bug #2 (kept honest as it.fails).
|
// KNOWN, DOCUMENTED non-roundtrip bug #1 (kept honest as it.fails).
|
||||||
//
|
//
|
||||||
// BUG: a block-level `image` placed BETWEEN other blocks is not byte-stable.
|
// BUG: a block-level `image` placed BETWEEN other blocks is not byte-stable.
|
||||||
// The Docmost image node is BLOCK-level but its markdown form `` is
|
// The Docmost image node is BLOCK-level but its markdown form `` is
|
||||||
@@ -655,23 +649,18 @@ describe('markdown <-> ProseMirror round-trip (property-based)', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
// KNOWN, DOCUMENTED non-roundtrip bug #1 (kept honest as it.fails).
|
// #515 ROUND-TRIP PIN: `code` combined with another mark.
|
||||||
//
|
//
|
||||||
// BUG: the `code` mark combined with ANY other mark does NOT round-trip.
|
// Before #515 the `code` mark declared `excludes: "_"`, dropping every co-
|
||||||
// The converter emits nested HTML so the output is well-formed, e.g.
|
// occurring mark on import so `` **`x`** `` came back as code-only. Now
|
||||||
// marks [code, bold] -> md1 = "<strong><code>x</code></strong>"
|
// `excludes: ""` lets code combine with all marks (CommonMark nests them,
|
||||||
// but the schema's `code` mark declares `excludes: "_"`, so on import the
|
// `<strong><code>x</code></strong>`), so the run BOTH round-trips byte-stably
|
||||||
// co-occurring mark is dropped and the run comes back as code-only:
|
// AND preserves the co-occurring mark. This asserts the observable property in
|
||||||
// md2 = "`x`" (=> md2 !== md1).
|
// both directions: md2 === md1 (idempotent export) and the imported doc still
|
||||||
// Minimal repro doc:
|
// carries [code, other].
|
||||||
// { type:'doc', content:[ { type:'paragraph', content:[
|
|
||||||
// { type:'text', text:'x', marks:[{type:'code'},{type:'bold'}] } ] } ] }
|
|
||||||
// This is acknowledged in markdown-converter.ts (the long comment above the
|
|
||||||
// marks switch): preserving both marks is impossible while `code` excludes
|
|
||||||
// them. Documented here, not "fixed", because the source must not change.
|
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
it(
|
it(
|
||||||
'code mark combined with another mark is byte-stable',
|
'code combined with another mark round-trips and keeps both marks (#515)',
|
||||||
async () => {
|
async () => {
|
||||||
const codeComboArb = fc
|
const codeComboArb = fc
|
||||||
.tuple(safeTextArb, fc.constantFrom('bold', 'italic', 'strike'))
|
.tuple(safeTextArb, fc.constantFrom('bold', 'italic', 'strike'))
|
||||||
@@ -688,11 +677,90 @@ describe('markdown <-> ProseMirror round-trip (property-based)', () => {
|
|||||||
}));
|
}));
|
||||||
await fc.assert(
|
await fc.assert(
|
||||||
fc.asyncProperty(codeComboArb, async (doc) => {
|
fc.asyncProperty(codeComboArb, async (doc) => {
|
||||||
const { md1, md2 } = await roundTrip(doc);
|
const { md1, md2, doc2 } = await roundTrip(doc);
|
||||||
expect(md2).toBe(md1);
|
expect(md2).toBe(md1);
|
||||||
|
// The re-imported run carries BOTH code and the co-occurring mark.
|
||||||
|
const run = doc2?.content?.[0]?.content?.[0];
|
||||||
|
const markTypes = (run?.marks || []).map((m: any) => m.type).sort();
|
||||||
|
expect(markTypes).toContain('code');
|
||||||
|
expect(markTypes.length).toBe(2);
|
||||||
}),
|
}),
|
||||||
{ numRuns: 20, seed: SEED },
|
{ numRuns: 20, seed: SEED },
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// #515 REPRO CASES: the five markdown inputs from the issue must import to a
|
||||||
|
// code+bold node (import correctness) AND re-export byte-stably with no
|
||||||
|
// dangling `**` (export correctness). Import direction is checked against the
|
||||||
|
// real markdown->PM bridge; export direction via the md->pm->md fixpoint.
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
it('the five #515 repro cases import to [code,bold] and round-trip clean', async () => {
|
||||||
|
// Collect every inline text run in a doc with its mark type set.
|
||||||
|
const runs = (node: any): { text: string; marks: string[] }[] => {
|
||||||
|
if (node?.type === 'text') {
|
||||||
|
return [{ text: node.text || '', marks: (node.marks || []).map((m: any) => m.type) }];
|
||||||
|
}
|
||||||
|
return (node?.content || []).flatMap(runs);
|
||||||
|
};
|
||||||
|
const findRun = (doc: any, text: string) =>
|
||||||
|
runs(doc).find((r) => r.text === text);
|
||||||
|
|
||||||
|
// Case 1: **`code1`** -> code1 = [code, bold].
|
||||||
|
{
|
||||||
|
const md = '**`code1`**';
|
||||||
|
const pm = await markdownToProseMirror(md);
|
||||||
|
const r = findRun(pm, 'code1');
|
||||||
|
expect(r?.marks.sort()).toEqual(['bold', 'code']);
|
||||||
|
const md2 = convertProseMirrorToMarkdown(pm);
|
||||||
|
expect(md2).toBe('**`code1`**');
|
||||||
|
// md -> pm -> md fixpoint.
|
||||||
|
expect(convertProseMirrorToMarkdown(await markdownToProseMirror(md2))).toBe(md2);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Case 2: **`aaa` + `bbb`** -> aaa,bbb = [code,bold], "+" carries bold; no
|
||||||
|
// dangling `**` on export.
|
||||||
|
{
|
||||||
|
const md = '**`aaa` + `bbb`**';
|
||||||
|
const pm = await markdownToProseMirror(md);
|
||||||
|
expect(findRun(pm, 'aaa')?.marks.sort()).toEqual(['bold', 'code']);
|
||||||
|
expect(findRun(pm, 'bbb')?.marks.sort()).toEqual(['bold', 'code']);
|
||||||
|
const md2 = convertProseMirrorToMarkdown(pm);
|
||||||
|
expect(md2).toBe('**`aaa` + `bbb`**');
|
||||||
|
// NOT the old broken export with the bold delimiters split onto each span.
|
||||||
|
expect(md2).not.toBe('`aaa`** + **`bbb`');
|
||||||
|
expect(convertProseMirrorToMarkdown(await markdownToProseMirror(md2))).toBe(md2);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Case 3 (control): **bold3** and `code3` -> bold and code stay SEPARATE.
|
||||||
|
{
|
||||||
|
const md = '**bold3** and `code3`';
|
||||||
|
const pm = await markdownToProseMirror(md);
|
||||||
|
expect(findRun(pm, 'bold3')?.marks).toEqual(['bold']);
|
||||||
|
expect(findRun(pm, 'code3')?.marks).toEqual(['code']);
|
||||||
|
const md2 = convertProseMirrorToMarkdown(pm);
|
||||||
|
expect(md2).toBe('**bold3** and `code3`');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Case 4: **`code4` tail** -> code4 = [code,bold], " tail" = [bold].
|
||||||
|
{
|
||||||
|
const md = '**`code4` tail**';
|
||||||
|
const pm = await markdownToProseMirror(md);
|
||||||
|
expect(findRun(pm, 'code4')?.marks.sort()).toEqual(['bold', 'code']);
|
||||||
|
const md2 = convertProseMirrorToMarkdown(pm);
|
||||||
|
expect(md2).toBe('**`code4` tail**');
|
||||||
|
expect(convertProseMirrorToMarkdown(await markdownToProseMirror(md2))).toBe(md2);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Case 5: pre **`code5`** post -> code5 = [code,bold], surroundings plain.
|
||||||
|
{
|
||||||
|
const md = 'pre **`code5`** post';
|
||||||
|
const pm = await markdownToProseMirror(md);
|
||||||
|
expect(findRun(pm, 'code5')?.marks.sort()).toEqual(['bold', 'code']);
|
||||||
|
const md2 = convertProseMirrorToMarkdown(pm);
|
||||||
|
expect(md2).toBe('pre **`code5`** post');
|
||||||
|
expect(convertProseMirrorToMarkdown(await markdownToProseMirror(md2))).toBe(md2);
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -88,6 +88,39 @@ describe("docmost schema vs @docmost/editor-ext (name-level contract)", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── #515 CODE-MARK `excludes` PARITY (data-loss-sensitive) ──────────────────
|
||||||
|
//
|
||||||
|
// The `code` mark's `excludes` field decides whether inline code can co-occur
|
||||||
|
// with other marks. #515 sets it to "" (excludes nothing) in the canonical
|
||||||
|
// `Code` exported by @docmost/editor-ext AND, because the vendored markdown
|
||||||
|
// mirror must NOT pull that React-aware package into its node runtime, RE-DECLARES
|
||||||
|
// the same override locally in docmost-schema.ts. If the two drift, markdown
|
||||||
|
// import would silently strip bold/italic adjacent to inline code again. Guard it
|
||||||
|
// mechanically: the mirror's built `code` mark and the canonical editor-ext
|
||||||
|
// `Code` must agree on `excludes` (both ""). getSchema surfaces the resolved
|
||||||
|
// value on the mark spec.
|
||||||
|
describe("docmost schema vs @docmost/editor-ext (#515 code excludes parity)", () => {
|
||||||
|
it("keeps the vendored `code` mark's excludes in lockstep with editor-ext Code", () => {
|
||||||
|
// Mirror side: the value the mirror's BUILT schema resolves for `code`.
|
||||||
|
const mirrorExcludes = getSchema(docmostExtensions as never).marks.code.spec
|
||||||
|
.excludes;
|
||||||
|
// Canonical side: the `excludes` DECLARED on the editor-ext `Code` extension
|
||||||
|
// (read from its config — getSchema needs a full node set, so a lone mark
|
||||||
|
// can't be built into a schema here).
|
||||||
|
const canonicalCode = (
|
||||||
|
editorExt as unknown as { Code?: { config?: { excludes?: unknown } } }
|
||||||
|
).Code;
|
||||||
|
const canonicalExcludes = canonicalCode?.config?.excludes;
|
||||||
|
// Both must be the empty string: `code` excludes NOTHING, so bold/italic/…
|
||||||
|
// survive alongside inline code (#515). A drift here would silently strip
|
||||||
|
// marks adjacent to code on markdown import again.
|
||||||
|
expect(canonicalCode).toBeDefined();
|
||||||
|
expect(mirrorExcludes).toBe("");
|
||||||
|
expect(canonicalExcludes).toBe("");
|
||||||
|
expect(mirrorExcludes).toBe(canonicalExcludes);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
// ── ATTRIBUTE-LEVEL CONTRACT (#493 commit 2) ────────────────────────────────
|
// ── ATTRIBUTE-LEVEL CONTRACT (#493 commit 2) ────────────────────────────────
|
||||||
//
|
//
|
||||||
// The name-level contract above catches a WHOLE node/mark type going unmirrored,
|
// The name-level contract above catches a WHOLE node/mark type going unmirrored,
|
||||||
|
|||||||
@@ -9,11 +9,18 @@ import { defineConfig } from 'vitest/config';
|
|||||||
// envelope, markdownToProseMirror) is re-exported there.
|
// envelope, markdownToProseMirror) is re-exported there.
|
||||||
const here = path.dirname(fileURLToPath(import.meta.url));
|
const here = path.dirname(fileURLToPath(import.meta.url));
|
||||||
const libBarrel = path.resolve(here, 'src/lib/index.ts');
|
const libBarrel = path.resolve(here, 'src/lib/index.ts');
|
||||||
|
// Resolve the cross-package `@docmost/editor-ext` specifier to the SIBLING
|
||||||
|
// workspace SOURCE. In a normal checkout this is what pnpm's workspace link +
|
||||||
|
// the package's `module` field already yield; pinning it here makes the schema
|
||||||
|
// contract tests (incl. the #515 code-excludes parity) hermetic and independent
|
||||||
|
// of node_modules layout (e.g. a shared/hoisted store in a git worktree).
|
||||||
|
const editorExtBarrel = path.resolve(here, '../editor-ext/src/index.ts');
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
resolve: {
|
resolve: {
|
||||||
alias: {
|
alias: {
|
||||||
'docmost-client': libBarrel,
|
'docmost-client': libBarrel,
|
||||||
|
'@docmost/editor-ext': editorExtBarrel,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
test: {
|
test: {
|
||||||
|
|||||||
Reference in New Issue
Block a user