@tensor_1/react 2.18.0 → 2.19.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +102 -1
- package/dist/components/Comments.d.ts +18 -2
- package/dist/components/Comments.js +37 -12
- package/dist/components/EditorIcons.d.ts +39 -0
- package/dist/components/EditorIcons.js +51 -0
- package/dist/components/EmojiGlyph.d.ts +10 -0
- package/dist/components/EmojiGlyph.js +18 -0
- package/dist/components/EmojiPicker.d.ts +60 -0
- package/dist/components/EmojiPicker.js +277 -0
- package/dist/components/Markdown.d.ts +36 -0
- package/dist/components/Markdown.js +104 -0
- package/dist/components/ReactionBar.d.ts +57 -0
- package/dist/components/ReactionBar.js +81 -0
- package/dist/components/RelationshipGraph.js +23 -4
- package/dist/components/RichTextEditor.d.ts +128 -0
- package/dist/components/RichTextEditor.js +954 -0
- package/dist/components/SaveStatus.d.ts +32 -0
- package/dist/components/SaveStatus.js +21 -0
- package/dist/components/Tabs.d.ts +10 -3
- package/dist/components/Tabs.js +5 -3
- package/dist/emoji-image-WQLh8ZQP.d.cts +128 -0
- package/dist/emoji-image.d.ts +25 -0
- package/dist/emoji-image.js +80 -0
- package/dist/emoji-model.d.ts +66 -0
- package/dist/emoji-model.js +108 -0
- package/dist/emoji.cjs +871 -0
- package/dist/emoji.d.cts +57 -0
- package/dist/emoji.d.ts +6 -0
- package/dist/emoji.js +18 -0
- package/dist/index.cjs +587 -519
- package/dist/index.d.cts +59 -64
- package/dist/index.d.ts +1 -0
- package/dist/index.js +3 -0
- package/dist/markdown-model.d.ts +75 -0
- package/dist/markdown-model.js +438 -0
- package/dist/markdown.cjs +585 -0
- package/dist/markdown.d.cts +115 -0
- package/dist/markdown.d.ts +4 -0
- package/dist/markdown.js +9 -0
- package/dist/overlay-position-CEGSz6Z2.d.cts +59 -0
- package/dist/rich-text/emoji-items.d.ts +14 -0
- package/dist/rich-text/emoji-items.js +7 -0
- package/dist/rich-text/pipeline.d.ts +91 -0
- package/dist/rich-text/pipeline.js +379 -0
- package/dist/rich-text/round-trip.d.ts +24 -0
- package/dist/rich-text/round-trip.js +11 -0
- package/dist/rich-text.cjs +2578 -0
- package/dist/rich-text.d.cts +148 -0
- package/dist/rich-text.d.ts +8 -0
- package/dist/rich-text.js +10 -0
- package/dist/url-policy.d.ts +9 -0
- package/dist/url-policy.js +11 -0
- package/package.json +82 -1
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
export { Markdown, MarkdownProps, defaultMarkdownIsSafeUrl } from './components/Markdown.js';
|
|
2
|
+
export { MarkdownBlock, MarkdownInline, MarkdownList, MarkdownListItem, TableAlign, parseInline, parseMarkdown, plainText } from './markdown-model.js';
|
|
3
|
+
import 'react';
|
|
4
|
+
import './url-policy.js';
|
package/dist/markdown.js
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* uix overlay positioning — framework-agnostic, dependency-free anchored placement.
|
|
3
|
+
*
|
|
4
|
+
* Pure geometry (no DOM): given the anchor's rect, the floating element's size, and
|
|
5
|
+
* the viewport, compute where to put the floating element with two behaviours:
|
|
6
|
+
* • flip — if the preferred side doesn't fit, use the opposite side (or whichever
|
|
7
|
+
* side has more room);
|
|
8
|
+
* • shift — slide along the cross axis so the element stays within the viewport.
|
|
9
|
+
*
|
|
10
|
+
* The React hook (useAnchoredPosition) and the vanilla styleguide both feed this
|
|
11
|
+
* getBoundingClientRect() values, so anchored overlays land in the same place in every
|
|
12
|
+
* browser — instead of relying on CSS anchor() positioning, which is Chromium-only and
|
|
13
|
+
* silently detaches the overlay from its trigger everywhere else (UIX-FIX-02).
|
|
14
|
+
*
|
|
15
|
+
* Everything here is deterministic and unit-tested (see overlay-position.test.mjs).
|
|
16
|
+
*/
|
|
17
|
+
type Side = 'top' | 'bottom' | 'left' | 'right';
|
|
18
|
+
type Align = 'start' | 'center' | 'end';
|
|
19
|
+
type Placement = Side | `${Side}-${Align}`;
|
|
20
|
+
/** A viewport-space rectangle, as returned by getBoundingClientRect(). */
|
|
21
|
+
interface Rect {
|
|
22
|
+
x: number;
|
|
23
|
+
y: number;
|
|
24
|
+
width: number;
|
|
25
|
+
height: number;
|
|
26
|
+
}
|
|
27
|
+
interface Size {
|
|
28
|
+
width: number;
|
|
29
|
+
height: number;
|
|
30
|
+
}
|
|
31
|
+
interface PositionOptions {
|
|
32
|
+
/** Preferred placement. Default `'bottom-start'`; a bare side (e.g. `'top'`) centers on the cross axis. */
|
|
33
|
+
placement?: Placement;
|
|
34
|
+
/** Gap between the anchor and the floating element, in px. Default 6. */
|
|
35
|
+
offset?: number;
|
|
36
|
+
/** Minimum gap kept from each viewport edge when shifting, in px. Default 8. */
|
|
37
|
+
padding?: number;
|
|
38
|
+
/** Flip to the opposite side when the preferred one doesn't fit. Default true. */
|
|
39
|
+
flip?: boolean;
|
|
40
|
+
/** Slide along the cross axis to stay on-screen. Default true. */
|
|
41
|
+
shift?: boolean;
|
|
42
|
+
}
|
|
43
|
+
interface PositionResult {
|
|
44
|
+
/** Viewport-space left, for `position: fixed`. */
|
|
45
|
+
x: number;
|
|
46
|
+
/** Viewport-space top, for `position: fixed`. */
|
|
47
|
+
y: number;
|
|
48
|
+
/** The placement actually used (after any flip). */
|
|
49
|
+
placement: Placement;
|
|
50
|
+
side: Side;
|
|
51
|
+
align: Align;
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Compute the floating element's viewport-space position anchored to `anchor`.
|
|
55
|
+
* Coordinates are for `position: fixed` (same space as getBoundingClientRect).
|
|
56
|
+
*/
|
|
57
|
+
declare function computePosition(anchor: Rect, floating: Size, viewport: Size, options?: PositionOptions): PositionResult;
|
|
58
|
+
|
|
59
|
+
export { type Align as A, type Placement as P, type Rect as R, type Side as S, type PositionOptions as a, type PositionResult as b, type Size as c, computePosition as d };
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { EmojiItem } from '@tiptap/extension-emoji';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The emoji list the editor configures `@tiptap/extension-emoji` with (RTE-01, on-prem).
|
|
5
|
+
*
|
|
6
|
+
* Upstream's default list carries a `fallbackImage` on a public CDN for nearly every
|
|
7
|
+
* entry, and its GitHub list adds image-only custom emoji. Either would make a browser
|
|
8
|
+
* request third-party images for emoji the device cannot draw. This list keeps only
|
|
9
|
+
* Unicode emoji and drops every image URL, so the editor never builds a remote URL.
|
|
10
|
+
* Same-origin fallback images are opt-in through `emojiImageBaseUrl`.
|
|
11
|
+
*/
|
|
12
|
+
declare const OFFLINE_EMOJI_ITEMS: ReadonlyArray<EmojiItem>;
|
|
13
|
+
|
|
14
|
+
export { OFFLINE_EMOJI_ITEMS };
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { emojis } from "@tiptap/extension-emoji";
|
|
2
|
+
const OFFLINE_EMOJI_ITEMS = Object.freeze(
|
|
3
|
+
emojis.filter((item) => typeof item.emoji === "string" && item.emoji !== "").map(({ fallbackImage: _remote, ...item }) => item).filter((item) => !/https?:|\/\//i.test(JSON.stringify(item)))
|
|
4
|
+
);
|
|
5
|
+
export {
|
|
6
|
+
OFFLINE_EMOJI_ITEMS
|
|
7
|
+
};
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { JSONContent, AnyExtension } from '@tiptap/core';
|
|
2
|
+
import { Schema } from '@tiptap/pm/model';
|
|
3
|
+
import { MarkdownManager } from '@tiptap/markdown';
|
|
4
|
+
import { Marked } from 'marked';
|
|
5
|
+
export { defaultIsSafeUrl } from '../url-policy.js';
|
|
6
|
+
|
|
7
|
+
interface SchemaExtensionOptions {
|
|
8
|
+
/** Heading levels the schema accepts. Parsed headings outside the set still round-trip (see below). */
|
|
9
|
+
headingLevels?: ReadonlyArray<1 | 2 | 3>;
|
|
10
|
+
isSafeUrl?: (url: string) => boolean;
|
|
11
|
+
/** Replacement for the image node (same name and attributes), e.g. with a render policy. */
|
|
12
|
+
image?: AnyExtension;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* The node/mark set, identical in the editor and in headless use so the same markdown
|
|
16
|
+
* produces the same document everywhere. `headingLevels` limits what input rules,
|
|
17
|
+
* shortcuts and pasted HTML can create; the heading `level` attribute itself is not
|
|
18
|
+
* validated, so existing content at another level (a PIR with an old `## `) still
|
|
19
|
+
* round-trips unchanged.
|
|
20
|
+
*/
|
|
21
|
+
declare function createSchemaExtensions(options?: SchemaExtensionOptions): AnyExtension[];
|
|
22
|
+
interface EncodeTextOptions {
|
|
23
|
+
/** The text sits in a table cell, where `|` ends the cell. */
|
|
24
|
+
inTable?: boolean;
|
|
25
|
+
/** The next inline node opens a link, so a trailing `!` would turn it into an image. */
|
|
26
|
+
beforeLink?: boolean;
|
|
27
|
+
/** The text starts a line (first in its block, or after a hard break). Default true. */
|
|
28
|
+
lineStart?: boolean;
|
|
29
|
+
/** The text ends an ATX heading, where a trailing ` #` run would be dropped as a closing sequence. */
|
|
30
|
+
headingEnd?: boolean;
|
|
31
|
+
}
|
|
32
|
+
declare function encodeText(text: string, options?: EncodeTextOptions): string;
|
|
33
|
+
/**
|
|
34
|
+
* A marked instance of our own: raw HTML is never tokenized (it stays literal text),
|
|
35
|
+
* and `use()` calls made by Tiptap extensions land here, not on the global `marked`.
|
|
36
|
+
*/
|
|
37
|
+
declare function createMarked(): Marked;
|
|
38
|
+
/**
|
|
39
|
+
* Applies the narrower text encoding to a Tiptap MarkdownManager. The method is
|
|
40
|
+
* private upstream; the exact version pin plus the byte-exact fixture suite guard it.
|
|
41
|
+
*/
|
|
42
|
+
declare function patchManager(manager: MarkdownManager): MarkdownManager;
|
|
43
|
+
declare function createManager(extensions: AnyExtension[]): MarkdownManager;
|
|
44
|
+
interface MarkdownBlock {
|
|
45
|
+
/** Source text of the block without its trailing blank lines. */
|
|
46
|
+
raw: string;
|
|
47
|
+
/** Whitespace and reference definitions between the previous block and this one. */
|
|
48
|
+
lead: string;
|
|
49
|
+
/** Normalized top-level nodes the block parsed to (usually one). */
|
|
50
|
+
nodes: JSONContent[];
|
|
51
|
+
/** JSON keys of `nodes`, for comparison with the live document. */
|
|
52
|
+
keys: string[];
|
|
53
|
+
/** Reference definitions (and other text that parses to nothing) inside `lead`. */
|
|
54
|
+
defs: string[];
|
|
55
|
+
}
|
|
56
|
+
interface MarkdownSource {
|
|
57
|
+
source: string;
|
|
58
|
+
blocks: MarkdownBlock[];
|
|
59
|
+
/** Text after the last block (usually the final newline). */
|
|
60
|
+
tail: string;
|
|
61
|
+
/** Reference definitions inside `tail`. */
|
|
62
|
+
tailDefs: string[];
|
|
63
|
+
/** Line ending the document uses throughout (CRLF only when every break is CRLF). */
|
|
64
|
+
eol: string;
|
|
65
|
+
/** The document the editor loads. */
|
|
66
|
+
doc: JSONContent;
|
|
67
|
+
}
|
|
68
|
+
/** Stable key of a node for equality; attribute order is fixed by the schema. */
|
|
69
|
+
declare const nodeKey: (node: JSONContent) => string;
|
|
70
|
+
/**
|
|
71
|
+
* Split markdown into blocks and build the editor document from them. Each block is
|
|
72
|
+
* parsed alone, so reference-style links render as their literal text (the same as
|
|
73
|
+
* the UIx Markdown viewer) and a block that fails to parse is kept as literal text.
|
|
74
|
+
*/
|
|
75
|
+
declare function readMarkdown(source: string, manager: MarkdownManager, schema: Schema): MarkdownSource;
|
|
76
|
+
/**
|
|
77
|
+
* Serialize a document, reusing the source bytes of every unchanged block.
|
|
78
|
+
* `origin` is the MarkdownSource the document was loaded from.
|
|
79
|
+
*/
|
|
80
|
+
declare function writeMarkdown(doc: JSONContent, origin: MarkdownSource, manager: MarkdownManager): string;
|
|
81
|
+
interface MarkdownPipeline {
|
|
82
|
+
extensions: AnyExtension[];
|
|
83
|
+
schema: Schema;
|
|
84
|
+
manager: MarkdownManager;
|
|
85
|
+
read: (source: string) => MarkdownSource;
|
|
86
|
+
write: (doc: JSONContent, origin: MarkdownSource) => string;
|
|
87
|
+
}
|
|
88
|
+
/** Headless pipeline over the editor's node set. */
|
|
89
|
+
declare function createPipeline(options?: SchemaExtensionOptions): MarkdownPipeline;
|
|
90
|
+
|
|
91
|
+
export { type EncodeTextOptions, type MarkdownBlock, type MarkdownPipeline, type MarkdownSource, type SchemaExtensionOptions, createManager, createMarked, createPipeline, createSchemaExtensions, encodeText, nodeKey, patchManager, readMarkdown, writeMarkdown };
|
|
@@ -0,0 +1,379 @@
|
|
|
1
|
+
import { getSchema, renderNestedMarkdownContent } from "@tiptap/core";
|
|
2
|
+
import { MarkdownManager } from "@tiptap/markdown";
|
|
3
|
+
import { ListItem, TaskItem, TaskList } from "@tiptap/extension-list";
|
|
4
|
+
import { TableKit } from "@tiptap/extension-table";
|
|
5
|
+
import { Image } from "@tiptap/extension-image";
|
|
6
|
+
import { StarterKit } from "@tiptap/starter-kit";
|
|
7
|
+
import { Marked } from "marked";
|
|
8
|
+
import { defaultIsSafeUrl } from "../url-policy.js";
|
|
9
|
+
const indentContinuation = (h) => {
|
|
10
|
+
let first = true;
|
|
11
|
+
return {
|
|
12
|
+
...h,
|
|
13
|
+
renderChildren: (nodes) => {
|
|
14
|
+
const out = h.renderChildren(nodes);
|
|
15
|
+
if (!first) return out;
|
|
16
|
+
first = false;
|
|
17
|
+
return out.split("\n").map((line, n) => n > 0 && line ? h.indent(line) : line).join("\n");
|
|
18
|
+
}
|
|
19
|
+
};
|
|
20
|
+
};
|
|
21
|
+
const listItemRender = ListItem.config.renderMarkdown;
|
|
22
|
+
const UixListItem = ListItem.extend({
|
|
23
|
+
renderMarkdown: (node, h, ctx) => listItemRender(node, indentContinuation(h), ctx)
|
|
24
|
+
});
|
|
25
|
+
const UixTaskItem = TaskItem.extend({
|
|
26
|
+
renderMarkdown: (node, h) => {
|
|
27
|
+
const prefix = `- [${node.attrs?.checked ? "x" : " "}] `;
|
|
28
|
+
const [first, ...rest] = node.content ?? [];
|
|
29
|
+
if (first?.type !== "paragraph" || !(first.content ?? []).some((c) => c.type === "hardBreak")) {
|
|
30
|
+
return renderNestedMarkdownContent(node, h, prefix);
|
|
31
|
+
}
|
|
32
|
+
const lines = [[]];
|
|
33
|
+
for (const child of first.content ?? []) {
|
|
34
|
+
if (child.type === "hardBreak") lines.push([]);
|
|
35
|
+
else lines[lines.length - 1].push(child);
|
|
36
|
+
}
|
|
37
|
+
const paragraphs = lines.filter((line) => line.length > 0).map((content) => ({ type: "paragraph", content }));
|
|
38
|
+
return renderNestedMarkdownContent({ ...node, content: [...paragraphs, ...rest] }, h, prefix);
|
|
39
|
+
}
|
|
40
|
+
});
|
|
41
|
+
function createSchemaExtensions(options = {}) {
|
|
42
|
+
const isSafeUrl = options.isSafeUrl ?? defaultIsSafeUrl;
|
|
43
|
+
return [
|
|
44
|
+
StarterKit.configure({
|
|
45
|
+
underline: false,
|
|
46
|
+
listItem: false,
|
|
47
|
+
heading: { levels: [...options.headingLevels ?? [1, 2, 3]] },
|
|
48
|
+
link: {
|
|
49
|
+
openOnClick: false,
|
|
50
|
+
autolink: true,
|
|
51
|
+
linkOnPaste: true,
|
|
52
|
+
defaultProtocol: "https",
|
|
53
|
+
// Unsafe links never become marks: pasted or typed, they stay text.
|
|
54
|
+
isAllowedUri: (url) => isSafeUrl(url),
|
|
55
|
+
shouldAutoLink: (url) => isSafeUrl(url),
|
|
56
|
+
HTMLAttributes: { rel: "noopener noreferrer nofollow", target: null }
|
|
57
|
+
}
|
|
58
|
+
}),
|
|
59
|
+
UixListItem,
|
|
60
|
+
TaskList,
|
|
61
|
+
UixTaskItem.configure({ nested: true }),
|
|
62
|
+
TableKit.configure({ table: { resizable: false } }),
|
|
63
|
+
options.image ?? Image.configure({ inline: true, allowBase64: false })
|
|
64
|
+
];
|
|
65
|
+
}
|
|
66
|
+
const ENTITY_AFTER_AMP = /^(?:#\d+;|#x[\da-f]+;|[a-z][a-z\d]*;)/i;
|
|
67
|
+
const TAG_START = /^[a-z/!?]$/i;
|
|
68
|
+
const ASCII_PUNCT = /[!-/:-@[-`{-~]/;
|
|
69
|
+
const LIST_MARKER = /^(?:\d+|[ivxlcdmIVXLCDM]+|[a-zA-Z]{1,2})$/;
|
|
70
|
+
const BACKSLASH = String.fromCharCode(92);
|
|
71
|
+
function encodeText(text, options = {}) {
|
|
72
|
+
let out = "";
|
|
73
|
+
const chars = Array.from(text);
|
|
74
|
+
let lineStart = 0;
|
|
75
|
+
let inLead = options.lineStart ?? true;
|
|
76
|
+
for (let i = 0; i < chars.length; i += 1) {
|
|
77
|
+
const ch = chars[i];
|
|
78
|
+
const prev = chars[i - 1] ?? "";
|
|
79
|
+
const next = chars[i + 1] ?? "";
|
|
80
|
+
if (ch === "\n") {
|
|
81
|
+
out += ch;
|
|
82
|
+
lineStart = i + 1;
|
|
83
|
+
inLead = true;
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
if (inLead && (ch === " " || ch === " ")) {
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
89
|
+
const atStart = inLead;
|
|
90
|
+
inLead = false;
|
|
91
|
+
const lineRest = chars.slice(i).join("").split("\n")[0];
|
|
92
|
+
const endOrSpace = next === "" || next === "\n" || /\s/u.test(next);
|
|
93
|
+
const markerBefore = !atStart && LIST_MARKER.test(out.slice(out.lastIndexOf("\n") + 1));
|
|
94
|
+
if (
|
|
95
|
+
// `#` always: marked ends a list item at a line starting with `#`, space or not.
|
|
96
|
+
atStart && (ch === "#" || /[-+*]/.test(ch) && endOrSpace) || atStart && /[-=*_]/.test(ch) && new RegExp(`^(?:\\${ch}[ \\t]*)+$`).test(lineRest) || markerBefore && /[.)]/.test(ch) && endOrSpace
|
|
97
|
+
) {
|
|
98
|
+
out += BACKSLASH + ch;
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
if (ch === "#" && options.headingEnd && (prev === "" || /\s/u.test(prev)) && /^#+$/.test(lineRest)) {
|
|
102
|
+
out += BACKSLASH + ch;
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
switch (ch) {
|
|
106
|
+
case BACKSLASH:
|
|
107
|
+
out += ASCII_PUNCT.test(next) || next === "" ? BACKSLASH + BACKSLASH : BACKSLASH;
|
|
108
|
+
break;
|
|
109
|
+
case "`":
|
|
110
|
+
case "[":
|
|
111
|
+
case "]":
|
|
112
|
+
case "~":
|
|
113
|
+
out += BACKSLASH + ch;
|
|
114
|
+
break;
|
|
115
|
+
case "|":
|
|
116
|
+
out += options.inTable ? BACKSLASH + ch : ch;
|
|
117
|
+
break;
|
|
118
|
+
case "&":
|
|
119
|
+
out += ENTITY_AFTER_AMP.test(chars.slice(i + 1, i + 40).join("")) ? "&" : ch;
|
|
120
|
+
break;
|
|
121
|
+
case "<":
|
|
122
|
+
out += TAG_START.test(next) ? "<" : ch;
|
|
123
|
+
break;
|
|
124
|
+
case "!":
|
|
125
|
+
out += next === "" && options.beforeLink ? BACKSLASH + ch : ch;
|
|
126
|
+
break;
|
|
127
|
+
case "*":
|
|
128
|
+
case "_": {
|
|
129
|
+
const spaced = (prev === "" || /\s/u.test(prev)) && (next === "" || /\s/u.test(next));
|
|
130
|
+
const intraword = ch === "_" && /[\p{L}\p{N}]/u.test(prev) && /[\p{L}\p{N}]/u.test(next);
|
|
131
|
+
out += spaced || intraword ? ch : BACKSLASH + ch;
|
|
132
|
+
break;
|
|
133
|
+
}
|
|
134
|
+
case ">":
|
|
135
|
+
out += atStart ? BACKSLASH + ch : ch;
|
|
136
|
+
break;
|
|
137
|
+
default:
|
|
138
|
+
out += ch;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
return out;
|
|
142
|
+
}
|
|
143
|
+
function createMarked() {
|
|
144
|
+
return new Marked({
|
|
145
|
+
gfm: true,
|
|
146
|
+
tokenizer: {
|
|
147
|
+
html: () => void 0,
|
|
148
|
+
// `<br>` is the one tag read: GFM tables have no other way to hold a line break,
|
|
149
|
+
// and Tiptap writes hard breaks in cells that way.
|
|
150
|
+
tag: (src) => {
|
|
151
|
+
const br = /^<br\s*\/?>/i.exec(src);
|
|
152
|
+
return br ? { type: "br", raw: br[0] } : void 0;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
const hasLink = (node) => (node?.marks ?? []).some((m) => (typeof m === "string" ? m : m.type) === "link");
|
|
158
|
+
function patchManager(manager) {
|
|
159
|
+
const internals = manager;
|
|
160
|
+
internals.encodeTextForMarkdown = function encode(text, node, parentNode) {
|
|
161
|
+
const inCode = parentNode?.type != null && internals.codeTypes.has(parentNode.type) || (node.marks ?? []).some((m) => internals.codeTypes.has(typeof m === "string" ? m : m.type));
|
|
162
|
+
if (inCode) return text;
|
|
163
|
+
const siblings = parentNode?.content ?? [];
|
|
164
|
+
const index = siblings.indexOf(node);
|
|
165
|
+
const previous = index > 0 ? siblings[index - 1] : void 0;
|
|
166
|
+
const next = index >= 0 ? siblings[index + 1] : void 0;
|
|
167
|
+
return encodeText(text, {
|
|
168
|
+
inTable: !!internals.uixInTable,
|
|
169
|
+
beforeLink: hasLink(next) && !hasLink(node),
|
|
170
|
+
// Unknown position (node not found among its siblings): assume a line start, the safe side.
|
|
171
|
+
lineStart: index <= 0 || previous?.type === "hardBreak",
|
|
172
|
+
headingEnd: parentNode?.type === "heading" && (index < 0 || index === siblings.length - 1)
|
|
173
|
+
});
|
|
174
|
+
};
|
|
175
|
+
return manager;
|
|
176
|
+
}
|
|
177
|
+
function serializeNode(manager, node) {
|
|
178
|
+
const internals = manager;
|
|
179
|
+
internals.uixInTable = node.type === "table";
|
|
180
|
+
try {
|
|
181
|
+
return manager.serialize({ type: "doc", content: [node] }).replace(/^\n+|\n+$/g, "");
|
|
182
|
+
} finally {
|
|
183
|
+
internals.uixInTable = false;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
function createManager(extensions) {
|
|
187
|
+
return patchManager(new MarkdownManager({ marked: createMarked(), extensions }));
|
|
188
|
+
}
|
|
189
|
+
const SEPARATOR_TOKENS = /* @__PURE__ */ new Set(["space", "def"]);
|
|
190
|
+
const nodeKey = (node) => JSON.stringify(node);
|
|
191
|
+
const normalize = (schema, content) => {
|
|
192
|
+
if (content.length === 0) return [];
|
|
193
|
+
const blocks = [];
|
|
194
|
+
for (const node of content) {
|
|
195
|
+
if (node.type && schema.nodes[node.type]?.isInline) {
|
|
196
|
+
const last = blocks[blocks.length - 1];
|
|
197
|
+
if (last?.type === "paragraph" && last.wrapped) last.content.push(node);
|
|
198
|
+
else blocks.push({ type: "paragraph", content: [node], wrapped: true });
|
|
199
|
+
} else {
|
|
200
|
+
blocks.push(node);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
for (const block of blocks) delete block.wrapped;
|
|
204
|
+
const doc = schema.nodeFromJSON({ type: "doc", content: blocks });
|
|
205
|
+
doc.check();
|
|
206
|
+
return doc.toJSON().content ?? [];
|
|
207
|
+
};
|
|
208
|
+
function readMarkdown(source, manager, schema) {
|
|
209
|
+
const normalized = source.replace(/\r\n?/g, "\n");
|
|
210
|
+
const lexed = manager.instance.lexer(normalized);
|
|
211
|
+
const origin = [];
|
|
212
|
+
for (let i = 0, o = 0; i <= normalized.length; i += 1, o += 1) {
|
|
213
|
+
origin.push(o);
|
|
214
|
+
if (source[o] === "\r" && source[o + 1] === "\n") o += 1;
|
|
215
|
+
}
|
|
216
|
+
let cursor = 0;
|
|
217
|
+
const tokens = lexed.map((t) => {
|
|
218
|
+
const start = origin[cursor];
|
|
219
|
+
cursor += t.raw.length;
|
|
220
|
+
return { type: t.type, raw: source.slice(start, origin[cursor]) };
|
|
221
|
+
});
|
|
222
|
+
const joined = lexed.map((t) => t.raw).join("") === normalized ? tokens.map((t) => t.raw).join("") : null;
|
|
223
|
+
const blocks = [];
|
|
224
|
+
const eol = /\r\n/.test(source) && !/(^|[^\r])\n/.test(source) ? "\r\n" : "\n";
|
|
225
|
+
let lead = "";
|
|
226
|
+
let defs = [];
|
|
227
|
+
const keepInLead = (raw) => {
|
|
228
|
+
lead += raw;
|
|
229
|
+
const text = raw.replace(/\s+$/u, "").replace(/^\s+/u, "");
|
|
230
|
+
if (text) defs.push(text);
|
|
231
|
+
};
|
|
232
|
+
const pushBlock = (raw) => {
|
|
233
|
+
let nodes;
|
|
234
|
+
try {
|
|
235
|
+
nodes = normalize(schema, manager.parse(raw).content ?? []);
|
|
236
|
+
} catch {
|
|
237
|
+
nodes = normalize(schema, [{ type: "paragraph", content: [{ type: "text", text: raw }] }]);
|
|
238
|
+
}
|
|
239
|
+
if (nodes.length === 0) {
|
|
240
|
+
keepInLead(raw);
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
243
|
+
blocks.push({ raw, lead, nodes, keys: nodes.map(nodeKey), defs });
|
|
244
|
+
lead = "";
|
|
245
|
+
defs = [];
|
|
246
|
+
};
|
|
247
|
+
if (joined !== source) {
|
|
248
|
+
const core = source.replace(/\s+$/u, "");
|
|
249
|
+
lead = source.slice(0, source.length - source.trimStart().length);
|
|
250
|
+
if (core.trim()) pushBlock(core.trimStart());
|
|
251
|
+
const tail = source.slice(core.length);
|
|
252
|
+
return { source, blocks, tail, tailDefs: [], eol, doc: { type: "doc", content: blocks.flatMap((b) => b.nodes) } };
|
|
253
|
+
}
|
|
254
|
+
for (const token of tokens) {
|
|
255
|
+
if (SEPARATOR_TOKENS.has(token.type) || token.raw.trim() === "") {
|
|
256
|
+
if (token.type === "def") keepInLead(token.raw);
|
|
257
|
+
else lead += token.raw;
|
|
258
|
+
continue;
|
|
259
|
+
}
|
|
260
|
+
const leading = /^(?:[ \t]*\r?\n)*/.exec(token.raw)[0];
|
|
261
|
+
lead += leading;
|
|
262
|
+
const body = token.raw.slice(leading.length);
|
|
263
|
+
const core = body.replace(/\s+$/u, "");
|
|
264
|
+
pushBlock(core);
|
|
265
|
+
lead += body.slice(core.length);
|
|
266
|
+
}
|
|
267
|
+
return {
|
|
268
|
+
source,
|
|
269
|
+
blocks,
|
|
270
|
+
tail: lead,
|
|
271
|
+
tailDefs: defs,
|
|
272
|
+
eol,
|
|
273
|
+
doc: { type: "doc", content: blocks.flatMap((b) => b.nodes) }
|
|
274
|
+
};
|
|
275
|
+
}
|
|
276
|
+
const isEmptyParagraph = (node) => node.type === "paragraph" && !(node.content ?? []).some((c) => c.type !== "text" || (c.text ?? "").trim() !== "");
|
|
277
|
+
const matchesAt = (children, at, block) => block.keys.every((key, k) => children[at + k] === key);
|
|
278
|
+
const LOOKAHEAD = 8;
|
|
279
|
+
function writeMarkdown(doc, origin, manager) {
|
|
280
|
+
const content = [...doc.content ?? []];
|
|
281
|
+
while (content.length > 0 && isEmptyParagraph(content[content.length - 1])) content.pop();
|
|
282
|
+
if (content.length === 0) {
|
|
283
|
+
return origin.blocks.length === 0 ? origin.source : "";
|
|
284
|
+
}
|
|
285
|
+
const keys = content.map(nodeKey);
|
|
286
|
+
const { blocks, eol } = origin;
|
|
287
|
+
const pieces = [];
|
|
288
|
+
let i = 0;
|
|
289
|
+
let j = 0;
|
|
290
|
+
while (i < content.length) {
|
|
291
|
+
if (j < blocks.length && matchesAt(keys, i, blocks[j])) {
|
|
292
|
+
pieces.push({ text: blocks[j].raw, slot: j, edited: false });
|
|
293
|
+
i += blocks[j].keys.length;
|
|
294
|
+
j += 1;
|
|
295
|
+
continue;
|
|
296
|
+
}
|
|
297
|
+
let skip = 0;
|
|
298
|
+
for (let d = 1; d <= LOOKAHEAD && j + d < blocks.length; d += 1) {
|
|
299
|
+
if (matchesAt(keys, i, blocks[j + d])) {
|
|
300
|
+
skip = d;
|
|
301
|
+
break;
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
let insert = 0;
|
|
305
|
+
if (j < blocks.length) {
|
|
306
|
+
for (let d = 1; d <= LOOKAHEAD && i + d < content.length; d += 1) {
|
|
307
|
+
if (matchesAt(keys, i + d, blocks[j])) {
|
|
308
|
+
insert = d;
|
|
309
|
+
break;
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
if (skip && (!insert || skip <= insert)) {
|
|
314
|
+
j += skip;
|
|
315
|
+
continue;
|
|
316
|
+
}
|
|
317
|
+
let text = serializeNode(manager, content[i]);
|
|
318
|
+
if (eol !== "\n") text = text.replace(/\n/g, eol);
|
|
319
|
+
const inPlace = !insert && j < blocks.length && blocks[j].keys.length === 1;
|
|
320
|
+
pieces.push(inPlace ? { text, slot: j, edited: true } : { text, edited: true });
|
|
321
|
+
i += 1;
|
|
322
|
+
if (!insert && j < blocks.length) j += 1;
|
|
323
|
+
}
|
|
324
|
+
const blank = eol + eol;
|
|
325
|
+
const emitted = /* @__PURE__ */ new Set();
|
|
326
|
+
let out = "";
|
|
327
|
+
pieces.forEach((piece, n) => {
|
|
328
|
+
const prev = pieces[n - 1];
|
|
329
|
+
const slot = piece.slot;
|
|
330
|
+
const lead = slot !== void 0 ? blocks[slot].lead : "";
|
|
331
|
+
if (n === 0) {
|
|
332
|
+
if (slot === 0) {
|
|
333
|
+
out += lead;
|
|
334
|
+
emitted.add(0);
|
|
335
|
+
}
|
|
336
|
+
} else if (slot !== void 0 && prev?.slot === slot - 1 && // Next to a re-serialized block, only a separator with a blank line is safe.
|
|
337
|
+
(!(piece.edited || prev.edited) || /\n[ \t]*\r?\n/.test(lead))) {
|
|
338
|
+
out += lead;
|
|
339
|
+
emitted.add(slot);
|
|
340
|
+
} else {
|
|
341
|
+
out += blank;
|
|
342
|
+
}
|
|
343
|
+
out += piece.text;
|
|
344
|
+
});
|
|
345
|
+
const last = pieces[pieces.length - 1];
|
|
346
|
+
const tailKept = last.slot === blocks.length - 1;
|
|
347
|
+
const lost = [
|
|
348
|
+
...blocks.flatMap((block, n) => emitted.has(n) ? [] : block.defs),
|
|
349
|
+
...tailKept ? [] : origin.tailDefs
|
|
350
|
+
];
|
|
351
|
+
if (lost.length) out += blank + lost.join(eol);
|
|
352
|
+
if (tailKept) out += origin.tail;
|
|
353
|
+
else if (/\n$/.test(origin.tail)) out += eol;
|
|
354
|
+
return out;
|
|
355
|
+
}
|
|
356
|
+
function createPipeline(options = {}) {
|
|
357
|
+
const extensions = createSchemaExtensions(options);
|
|
358
|
+
const schema = getSchema(extensions);
|
|
359
|
+
const manager = createManager(extensions);
|
|
360
|
+
return {
|
|
361
|
+
extensions,
|
|
362
|
+
schema,
|
|
363
|
+
manager,
|
|
364
|
+
read: (source) => readMarkdown(source, manager, schema),
|
|
365
|
+
write: (doc, origin) => writeMarkdown(doc, origin, manager)
|
|
366
|
+
};
|
|
367
|
+
}
|
|
368
|
+
export {
|
|
369
|
+
createManager,
|
|
370
|
+
createMarked,
|
|
371
|
+
createPipeline,
|
|
372
|
+
createSchemaExtensions,
|
|
373
|
+
defaultIsSafeUrl,
|
|
374
|
+
encodeText,
|
|
375
|
+
nodeKey,
|
|
376
|
+
patchManager,
|
|
377
|
+
readMarkdown,
|
|
378
|
+
writeMarkdown
|
|
379
|
+
};
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/** Editor options that change how markdown is read. */
|
|
2
|
+
interface RoundTripOptions {
|
|
3
|
+
/** As on RichTextEditor. Does not affect round-trip bytes; accepted for parity. */
|
|
4
|
+
headingLevels?: ReadonlyArray<1 | 2 | 3>;
|
|
5
|
+
/** As on RichTextEditor. */
|
|
6
|
+
isSafeUrl?: (url: string) => boolean;
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* Load markdown into the RichTextEditor's document model and write it back, exactly as
|
|
10
|
+
* the editor does when a user opens a field and saves without editing it. Consumers
|
|
11
|
+
* use it to gate their own content corpus: every stored value should satisfy
|
|
12
|
+
* `await roundTripMarkdown(md) === md`.
|
|
13
|
+
*
|
|
14
|
+
* Runs without a DOM (Node, Vitest, Jest). Asynchronous so the pipeline can be loaded
|
|
15
|
+
* lazily in future versions without an API change.
|
|
16
|
+
*
|
|
17
|
+
* @example
|
|
18
|
+
* for (const article of corpus) {
|
|
19
|
+
* expect(await roundTripMarkdown(article.body)).toBe(article.body);
|
|
20
|
+
* }
|
|
21
|
+
*/
|
|
22
|
+
declare function roundTripMarkdown(markdown: string, options?: RoundTripOptions): Promise<string>;
|
|
23
|
+
|
|
24
|
+
export { type RoundTripOptions, roundTripMarkdown };
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { createPipeline } from "./pipeline.js";
|
|
2
|
+
let shared;
|
|
3
|
+
async function roundTripMarkdown(markdown, options) {
|
|
4
|
+
const pipeline = options ? createPipeline(options) : shared ?? (shared = createPipeline());
|
|
5
|
+
const origin = pipeline.read(markdown);
|
|
6
|
+
const doc = pipeline.schema.nodeFromJSON(origin.doc).toJSON();
|
|
7
|
+
return pipeline.write(doc, origin);
|
|
8
|
+
}
|
|
9
|
+
export {
|
|
10
|
+
roundTripMarkdown
|
|
11
|
+
};
|