@segnavia/markdown-editor 0.44.0 → 0.46.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.
@@ -1,11 +1,180 @@
1
1
  import { LockedBlock } from "./markdown-rich-locked.js";
2
- import { Extension, getSchema } from "@tiptap/core";
2
+ import { rawInline } from "./markdown-rich-raw-inline.js";
3
+ import { listSourceAttributes, sourceFormStarterExtensions, sourceListAttrs, tableRowSourceAttributes, tableSourceExtension, taskItemSourceExtension } from "./markdown-rich-source-forms.js";
4
+ import { Extension, getExtensionField, getSchema } from "@tiptap/core";
3
5
  import Image from "@tiptap/extension-image";
4
- import { TaskItem, TaskList } from "@tiptap/extension-list";
6
+ import { BulletList, OrderedList, TaskItem, TaskList } from "@tiptap/extension-list";
5
7
  import { TableKit } from "@tiptap/extension-table";
6
8
  import { MarkdownManager } from "@tiptap/markdown";
7
9
  import StarterKit from "@tiptap/starter-kit";
10
+ import { Marked } from "marked";
8
11
  //#region src/markdown-rich-extensions.ts
12
+ const listAttributes = { loose: {
13
+ default: false,
14
+ parseHTML: (element) => element.getAttribute("data-loose") === "true",
15
+ renderHTML: (attrs) => attrs.loose ? { "data-loose": "true" } : {}
16
+ } };
17
+ const listSeparator = (node) => node.attrs?.loose ? "\n\n" : "\n";
18
+ const listItemPattern = /^(\s*)([-+*]|\d+[.)])(\s+)(?:\[[ xX]\]\s+)?/;
19
+ const indentWidth = (value) => value.replace(/\t/g, " ").length;
20
+ const lineIndent = (line) => indentWidth(/^\s*/.exec(line)?.[0] ?? "");
21
+ const itemContentIndent = (match) => indentWidth(match[1] ?? "") + (match[2]?.length ?? 0) + indentWidth(match[3] ?? "");
22
+ function updateListContext(match, indent, state) {
23
+ if (match && indent === state.baseIndent) {
24
+ state.contentIndent = itemContentIndent(match);
25
+ state.nestedIndent = void 0;
26
+ } else if (match && indent >= state.contentIndent && state.nestedIndent === void 0) state.nestedIndent = indent;
27
+ }
28
+ function consumeFence(line, indent, state) {
29
+ const match = /^(\s*)(`{3,}|~{3,})/.exec(line);
30
+ if (!match || indent < state.contentIndent || state.nestedIndent !== void 0) return false;
31
+ const marker = match[2]?.[0] ?? "";
32
+ const length = match[2]?.length ?? 0;
33
+ if (!state.fence) state.fence = {
34
+ marker,
35
+ length,
36
+ indent
37
+ };
38
+ else if (marker === state.fence.marker && length >= state.fence.length && indent <= state.fence.indent) state.fence = void 0;
39
+ return true;
40
+ }
41
+ function nextNonblank(lines, index) {
42
+ return lines.slice(index + 1).find((line) => !/^\s*$/.test(line));
43
+ }
44
+ function blankLineIsLoose(lines, index, state) {
45
+ const nextLine = nextNonblank(lines, index);
46
+ if (nextLine === void 0) return false;
47
+ const nextItem = listItemPattern.exec(nextLine);
48
+ const nextIndent = lineIndent(nextLine);
49
+ if (nextItem && nextIndent === state.baseIndent) return true;
50
+ if (state.nestedIndent !== void 0) {
51
+ if (nextIndent > state.nestedIndent || nextItem && nextIndent === state.nestedIndent) return false;
52
+ state.nestedIndent = void 0;
53
+ }
54
+ return nextIndent >= state.contentIndent;
55
+ }
56
+ function listLineIsLoose(lines, index, state) {
57
+ const line = lines[index] ?? "";
58
+ const match = listItemPattern.exec(line);
59
+ const indent = lineIndent(line);
60
+ updateListContext(match, indent, state);
61
+ if (consumeFence(line, indent, state)) return false;
62
+ return /^\s*$/.test(line) && state.fence === void 0 && blankLineIsLoose(lines, index, state);
63
+ }
64
+ function listIsLoose(raw) {
65
+ const lines = raw.split("\n");
66
+ const firstMatch = listItemPattern.exec(lines.find((line) => listItemPattern.test(line)) ?? "");
67
+ if (!firstMatch) return false;
68
+ const state = {
69
+ baseIndent: indentWidth(firstMatch[1] ?? ""),
70
+ contentIndent: itemContentIndent(firstMatch)
71
+ };
72
+ for (let index = 0; index < lines.length; index++) if (listLineIsLoose(lines, index, state)) return true;
73
+ return false;
74
+ }
75
+ const LooseBulletList = BulletList.extend({
76
+ addAttributes() {
77
+ return {
78
+ ...this.parent?.(),
79
+ ...listAttributes,
80
+ ...listSourceAttributes
81
+ };
82
+ },
83
+ parseMarkdown(token, helpers) {
84
+ if (token.type !== "list" || token.ordered) return [];
85
+ return {
86
+ type: "bulletList",
87
+ attrs: {
88
+ loose: listIsLoose(token.raw ?? ""),
89
+ ...sourceListAttrs(token.raw ?? "")
90
+ },
91
+ content: helpers.parseChildren(token.items ?? [])
92
+ };
93
+ },
94
+ renderMarkdown(node, helpers) {
95
+ return node.content ? helpers.renderChildren(node.content, listSeparator(node)) : "";
96
+ }
97
+ });
98
+ const LooseOrderedList = OrderedList.extend({
99
+ addAttributes() {
100
+ return {
101
+ ...this.parent?.(),
102
+ ...listAttributes,
103
+ ...listSourceAttributes
104
+ };
105
+ },
106
+ parseMarkdown(token, helpers) {
107
+ if (token.type !== "list" || !token.ordered) return [];
108
+ return {
109
+ type: "orderedList",
110
+ attrs: {
111
+ start: token.start || 1,
112
+ type: token.typeMarker ?? null,
113
+ loose: listIsLoose(token.raw ?? ""),
114
+ ...sourceListAttrs(token.raw ?? "", true)
115
+ },
116
+ content: helpers.parseChildren(token.items ?? [])
117
+ };
118
+ },
119
+ renderMarkdown(node, helpers) {
120
+ if (!node.content) return "";
121
+ const recorded = node.attrs?.sourceOrderedNumbers;
122
+ const keepsWrittenNumbers = Array.isArray(recorded) && recorded.length === node.content.length && node.content.every((item, index) => item.attrs?.sourceNumber === recorded[index]);
123
+ const content = node.content.map((item) => ({
124
+ ...item,
125
+ attrs: {
126
+ ...item.attrs,
127
+ sourceKeepWrittenNumbers: keepsWrittenNumbers
128
+ }
129
+ }));
130
+ return helpers.renderChildren(content, listSeparator(node));
131
+ }
132
+ });
133
+ const LooseTaskList = TaskList.extend({
134
+ addAttributes() {
135
+ return {
136
+ ...this.parent?.(),
137
+ ...listAttributes,
138
+ ...listSourceAttributes
139
+ };
140
+ },
141
+ parseMarkdown(token, helpers) {
142
+ return helpers.createNode("taskList", {
143
+ loose: listIsLoose(token.raw ?? ""),
144
+ ...sourceListAttrs(token.raw ?? "")
145
+ }, helpers.parseChildren(token.items ?? []));
146
+ },
147
+ renderMarkdown(node, helpers) {
148
+ return node.content ? helpers.renderChildren(node.content, listSeparator(node)) : "";
149
+ },
150
+ markdownTokenizer: {
151
+ name: "taskList",
152
+ level: "block",
153
+ start: (src) => src.search(/^\s*[-+*]\s+\[[ xX]\]\s+/m),
154
+ tokenize(src, _tokens, lexer) {
155
+ const token = new Marked().lexer(src)[0];
156
+ if (token?.type !== "list" || token.ordered || !token.items?.every((item) => item.task)) return void 0;
157
+ const items = token.items.map((item) => {
158
+ const lines = item.text.split("\n");
159
+ const mainContent = lines.shift() ?? "";
160
+ const nested = lines.join("\n").trim();
161
+ return {
162
+ type: "taskItem",
163
+ raw: item.raw,
164
+ checked: item.checked ?? false,
165
+ text: mainContent,
166
+ tokens: lexer.inlineTokens(mainContent),
167
+ nestedTokens: nested ? lexer.blockTokens(`${nested}\n`) : []
168
+ };
169
+ });
170
+ return {
171
+ type: "taskList",
172
+ raw: token.raw,
173
+ items
174
+ };
175
+ }
176
+ }
177
+ });
9
178
  const directionalBlocks = Extension.create({
10
179
  name: "directionalBlocks",
11
180
  addGlobalAttributes() {
@@ -30,32 +199,66 @@ const directionalBlocks = Extension.create({
30
199
  }];
31
200
  }
32
201
  });
33
- function richExtensions() {
202
+ function richExtensions(definitionLabels = /* @__PURE__ */ new Set()) {
203
+ const starter = StarterKit.configure({
204
+ trailingNode: false,
205
+ link: { openOnClick: false },
206
+ bulletList: false,
207
+ orderedList: false
208
+ });
209
+ const starterChildren = getExtensionField(starter, "addExtensions", {
210
+ name: starter.name,
211
+ options: starter.options,
212
+ storage: starter.storage
213
+ })?.() ?? [];
214
+ const tables = TableKit.configure();
215
+ const tableChildren = getExtensionField(tables, "addExtensions", {
216
+ name: tables.name,
217
+ options: tables.options,
218
+ storage: tables.storage
219
+ })?.() ?? [];
34
220
  return [
35
- StarterKit.configure({
36
- trailingNode: false,
37
- link: { openOnClick: false }
221
+ ...sourceFormStarterExtensions(starterChildren),
222
+ LooseBulletList,
223
+ LooseOrderedList,
224
+ ...tableChildren.map((extension) => {
225
+ if (extension.name === "tableRow") return extension.extend({ addAttributes() {
226
+ return {
227
+ ...this.parent?.(),
228
+ ...tableRowSourceAttributes
229
+ };
230
+ } });
231
+ return tableSourceExtension(extension);
38
232
  }),
39
- TableKit,
40
- TaskList,
41
- TaskItem.configure({ nested: true }),
233
+ LooseTaskList,
234
+ taskItemSourceExtension(TaskItem.configure({ nested: true })),
42
235
  Image.configure({ inline: true }),
43
236
  directionalBlocks,
44
- LockedBlock
237
+ LockedBlock,
238
+ rawInline(definitionLabels)
45
239
  ];
46
240
  }
47
241
  let manager;
242
+ const isolatedMarked = () => new Marked();
48
243
  function richMarkdownManager() {
49
244
  manager ??= new MarkdownManager({
245
+ marked: isolatedMarked(),
50
246
  extensions: richExtensions(),
51
247
  markedOptions: { gfm: true }
52
248
  });
53
249
  return manager;
54
250
  }
251
+ function scopedRichMarkdownManager(definitionLabels) {
252
+ return new MarkdownManager({
253
+ marked: isolatedMarked(),
254
+ extensions: richExtensions(definitionLabels),
255
+ markedOptions: { gfm: true }
256
+ });
257
+ }
55
258
  let schema;
56
259
  function richSchema() {
57
260
  schema ??= getSchema(richExtensions());
58
261
  return schema;
59
262
  }
60
263
  //#endregion
61
- export { richExtensions, richMarkdownManager, richSchema };
264
+ export { listIsLoose, richExtensions, richMarkdownManager, richSchema, scopedRichMarkdownManager };
@@ -2,6 +2,26 @@ import { isDiagramFence, isGithubAlertBlock } from "@segnavia/html";
2
2
  import { Node } from "@tiptap/core";
3
3
  //#region src/markdown-rich-locked.ts
4
4
  const LOCKED_BLOCK_NAME = "lockedBlock";
5
+ const FOOTNOTE_OPENING = /^ {0,3}\[\^/m;
6
+ function matchFootnoteDefinition(src) {
7
+ const opening = FOOTNOTE_OPENING.exec(src);
8
+ if (opening?.index !== 0) return void 0;
9
+ const rest = /^[^\]\n]+\]:[^\n]*(?:\n|$)/.exec(src.slice(opening[0].length))?.[0];
10
+ const first = rest === void 0 ? void 0 : opening[0] + rest;
11
+ if (!first) return void 0;
12
+ let end = first.length;
13
+ while (end < src.length) {
14
+ const continuation = /^(?: {4,}|\t)[^\n]*(?:\n|$)/.exec(src.slice(end))?.[0];
15
+ if (continuation) {
16
+ end += continuation.length;
17
+ continue;
18
+ }
19
+ const blanks = /^(?:[ \t]*\n)+/.exec(src.slice(end))?.[0];
20
+ if (!blanks || !/^(?: {4,}|\t)\S/.test(src.slice(end + blanks.length))) break;
21
+ end += blanks.length;
22
+ }
23
+ return src.slice(0, end);
24
+ }
5
25
  function lockedFenceLabel(lockedFences, language) {
6
26
  return lockedFences[language?.toLowerCase() ?? ""];
7
27
  }
@@ -27,7 +47,7 @@ function isHtmlComment(raw) {
27
47
  return /^<!--[\s\S]*-->$/.test(raw.replace(/\n+$/, ""));
28
48
  }
29
49
  function isLockedToken(token, lockedFences = {}) {
30
- if (token.type === "frontmatter" || token.type === "directive") return true;
50
+ if (token.type === "frontmatter" || token.type === "directive" || token.type === "footnoteDefinition") return true;
31
51
  if (token.type === "code" && (isDiagramFence(token.lang) || Boolean(lockedFenceLabel(lockedFences, token.lang)))) return true;
32
52
  if (token.type === "blockquote" && isGithubAlertBlock(token.raw)) return true;
33
53
  return token.type === "html" && !isHtmlComment(token.raw);
@@ -75,7 +95,8 @@ const LockedBlock = Node.create({
75
95
  name: "lockedFence",
76
96
  level: "block",
77
97
  start(src) {
78
- return src.startsWith("---") || src.startsWith(":::") ? 0 : -1;
98
+ if (src.startsWith("---") || src.startsWith(":::")) return 0;
99
+ return FOOTNOTE_OPENING.exec(src)?.index ?? -1;
79
100
  },
80
101
  tokenize(src, tokens) {
81
102
  if (tokens.length === 0) {
@@ -85,6 +106,11 @@ const LockedBlock = Node.create({
85
106
  raw: frontmatter
86
107
  };
87
108
  }
109
+ const footnote = matchFootnoteDefinition(src);
110
+ if (footnote) return {
111
+ type: "footnoteDefinition",
112
+ raw: footnote
113
+ };
88
114
  const directive = matchDirectiveContainer(src);
89
115
  return directive ? {
90
116
  type: "directive",
@@ -0,0 +1,123 @@
1
+ import { Node } from "@tiptap/core";
2
+ //#region src/markdown-rich-raw-inline.ts
3
+ const RAW_INLINE_NAME = "rawInline";
4
+ const RAW_INLINE_ATTRIBUTE = "data-raw-inline";
5
+ const RAW_INLINE_SOURCE_ATTRIBUTE = "data-raw-source";
6
+ const RAW_INLINE_DISPLAY_ATTRIBUTE = "data-raw-display";
7
+ const ASCII_PUNCTUATION = String.raw`[!-/:-@\[-` + "{-~]";
8
+ const escapePattern = new RegExp(`^\\\\${ASCII_PUNCTUATION}`);
9
+ const entityPattern = /^&(?:[A-Za-z][A-Za-z0-9]+|#[0-9]+|#[xX][0-9A-Fa-f]+);/;
10
+ const footnotePattern = /^\[\^[^\]\n]+\]/;
11
+ const inlineHtmlPattern = /^(?:<!--[\s\S]*?-->|<\/?[A-Za-z](?:[^<>"']|"[^"]*"|'[^']*')*?>)/;
12
+ const bracketContent = String.raw`(?:\\.|[^\[\]\\]|\[(?:\\.|[^\[\]\\])*\])*`;
13
+ const referencePattern = new RegExp(`^(!?)\\[(${bracketContent})\\](?:\\[(${bracketContent})\\])?`);
14
+ function normalizeReferenceLabel(label) {
15
+ return label.trim().replace(/\s+/g, " ").toLowerCase();
16
+ }
17
+ function referenceSource(src, labels) {
18
+ const match = referencePattern.exec(src);
19
+ if (!match) return void 0;
20
+ const raw = match[0];
21
+ const firstLabel = match[2] ?? "";
22
+ const secondLabel = match[3];
23
+ if (secondLabel === void 0 && src[raw.length] === "(") return void 0;
24
+ const label = secondLabel === void 0 || secondLabel === "" ? firstLabel : secondLabel;
25
+ return labels.has(normalizeReferenceLabel(label)) ? raw : void 0;
26
+ }
27
+ function rawSource(src, labels) {
28
+ const escaped = escapePattern.exec(src)?.[0];
29
+ if (escaped) return {
30
+ source: escaped,
31
+ display: escaped.slice(1)
32
+ };
33
+ const entity = entityPattern.exec(src)?.[0];
34
+ if (entity) return {
35
+ source: entity,
36
+ display: entity
37
+ };
38
+ const footnote = footnotePattern.exec(src)?.[0];
39
+ if (footnote) return {
40
+ source: footnote,
41
+ display: footnote
42
+ };
43
+ const reference = referenceSource(src, labels);
44
+ if (reference) return {
45
+ source: reference,
46
+ display: reference
47
+ };
48
+ const html = inlineHtmlPattern.exec(src)?.[0];
49
+ return html && (!html.startsWith("<!") ? !/\n[ \t]*\n/.test(html) : true) ? {
50
+ source: html,
51
+ display: html
52
+ } : void 0;
53
+ }
54
+ function rawInline(labels = /* @__PURE__ */ new Set()) {
55
+ return Node.create({
56
+ name: RAW_INLINE_NAME,
57
+ group: "inline",
58
+ inline: true,
59
+ atom: true,
60
+ selectable: true,
61
+ draggable: false,
62
+ addAttributes() {
63
+ return {
64
+ source: {
65
+ default: "",
66
+ parseHTML: (element) => element.getAttribute("data-raw-source") ?? "",
67
+ renderHTML: (attrs) => ({ [RAW_INLINE_SOURCE_ATTRIBUTE]: attrs.source })
68
+ },
69
+ display: {
70
+ default: "",
71
+ parseHTML: (element) => element.getAttribute("data-raw-display") ?? "",
72
+ renderHTML: (attrs) => ({ [RAW_INLINE_DISPLAY_ATTRIBUTE]: attrs.display })
73
+ }
74
+ };
75
+ },
76
+ parseHTML() {
77
+ return [{ tag: `span[${RAW_INLINE_ATTRIBUTE}]` }];
78
+ },
79
+ renderHTML({ HTMLAttributes }) {
80
+ return ["span", {
81
+ ...HTMLAttributes,
82
+ [RAW_INLINE_ATTRIBUTE]: "",
83
+ dir: "auto"
84
+ }];
85
+ },
86
+ parseMarkdown(token) {
87
+ return {
88
+ type: RAW_INLINE_NAME,
89
+ attrs: {
90
+ source: token.source,
91
+ display: token.display
92
+ }
93
+ };
94
+ },
95
+ renderMarkdown(node) {
96
+ return typeof node.attrs?.source === "string" ? node.attrs.source : "";
97
+ },
98
+ markdownTokenizer: {
99
+ name: RAW_INLINE_NAME,
100
+ level: "inline",
101
+ start(src) {
102
+ const candidates = labels.size ? /\\|&|!?\[|</g : /\\|&|\[\^|</g;
103
+ for (const match of src.matchAll(candidates)) {
104
+ if (match.index === void 0) continue;
105
+ if (rawSource(src.slice(match.index), labels)) return match.index;
106
+ }
107
+ return -1;
108
+ },
109
+ tokenize(src) {
110
+ const raw = rawSource(src, labels);
111
+ return raw ? {
112
+ type: RAW_INLINE_NAME,
113
+ raw: raw.source,
114
+ source: raw.source,
115
+ display: raw.display
116
+ } : void 0;
117
+ }
118
+ }
119
+ });
120
+ }
121
+ const RawInline = rawInline();
122
+ //#endregion
123
+ export { RAW_INLINE_ATTRIBUTE, RAW_INLINE_DISPLAY_ATTRIBUTE, RAW_INLINE_NAME, RAW_INLINE_SOURCE_ATTRIBUTE, RawInline, normalizeReferenceLabel, rawInline };
@@ -1,5 +1,7 @@
1
1
  import { isLockedToken, isSilentToken, lockedFenceLabel, lockedNode } from "./markdown-rich-locked.js";
2
- import { richMarkdownManager, richSchema } from "./markdown-rich-extensions.js";
2
+ import { normalizeReferenceLabel } from "./markdown-rich-raw-inline.js";
3
+ import { meaningfulAttributes, withoutRecordedSourceAttrs } from "./markdown-rich-source-attrs.js";
4
+ import { richMarkdownManager, richSchema, scopedRichMarkdownManager } from "./markdown-rich-extensions.js";
3
5
  import { Node } from "@tiptap/pm/model";
4
6
  //#region src/markdown-rich-save.ts
5
7
  let cachedOriginal;
@@ -39,15 +41,24 @@ const dropTrailingEmptyParagraph = (nodes) => {
39
41
  return last && isEmptyParagraph(last) ? nodes.slice(0, -1) : nodes;
40
42
  };
41
43
  const restoreAmpersands = (markdown) => markdown.replace(/&amp;(?![a-zA-Z][a-zA-Z0-9]*;)/g, "&");
42
- function parseBlock(token, raw, lockedFences) {
44
+ function parseBlock(manager, token, raw, lockedFences) {
43
45
  if (isSilentToken(token)) return [];
44
46
  if (isLockedToken(token, lockedFences)) return [lockedNode(raw, lockedFenceLabel(lockedFences, token.lang))];
45
- const parsed = richMarkdownManager().parse(raw);
47
+ const parsed = manager.parse(raw);
46
48
  return Node.fromJSON(richSchema(), parsed).toJSON().content ?? [];
47
49
  }
48
- function blocks(markdown, lockedFences = {}) {
50
+ function definitionLabels(tokens) {
51
+ const labels = /* @__PURE__ */ new Set();
52
+ for (const token of tokens) {
53
+ const definition = token.type === "def" ? /^ {0,3}\[([^\]]+)\]:/.exec(token.raw)?.[1] : void 0;
54
+ if (definition !== void 0) labels.add(normalizeReferenceLabel(definition));
55
+ }
56
+ return labels;
57
+ }
58
+ function blocks(markdown, labels, lockedFences = {}) {
49
59
  const out = [];
50
- const tokens = richMarkdownManager().instance.lexer(markdown);
60
+ const manager = scopedRichMarkdownManager(labels);
61
+ const tokens = manager.instance.lexer(markdown);
51
62
  let at = 0;
52
63
  let prevEnd = 0;
53
64
  for (const token of tokens) {
@@ -55,7 +66,7 @@ function blocks(markdown, lockedFences = {}) {
55
66
  at += token.raw.length;
56
67
  if (token.type === "space") continue;
57
68
  const raw = token.raw.replace(/\n+$/, "");
58
- const nodes = parseBlock(token, raw, lockedFences);
69
+ const nodes = parseBlock(manager, token, raw, lockedFences);
59
70
  out.push({
60
71
  raw,
61
72
  sep: markdown.slice(prevEnd, start),
@@ -73,7 +84,9 @@ function originalEntry(original, options) {
73
84
  const nextFencesKey = fencesKey(options.lockedFences);
74
85
  if (cachedOriginal?.original === original && cachedOriginal.fencesKey === nextFencesKey && cachedOriginal.bodyTransform === options.bodyTransform) return cachedOriginal;
75
86
  const split = options.bodyTransform?.split(original);
76
- const editableBlocks = blocks(split?.editable ?? original, options.lockedFences);
87
+ const editable = split?.editable ?? original;
88
+ const labels = definitionLabels(richMarkdownManager().instance.lexer(editable));
89
+ const editableBlocks = blocks(editable, labels, options.lockedFences);
77
90
  const nodes = editableBlocks.flatMap((block) => block.nodes);
78
91
  const keys = editableBlocks.flatMap((block) => block.keys);
79
92
  const indicesByFirstKey = /* @__PURE__ */ new Map();
@@ -92,7 +105,8 @@ function originalEntry(original, options) {
92
105
  blocks: editableBlocks,
93
106
  nodes,
94
107
  keys,
95
- indicesByFirstKey
108
+ indicesByFirstKey,
109
+ definitionLabels: labels
96
110
  };
97
111
  return cachedOriginal;
98
112
  }
@@ -106,11 +120,115 @@ function firstAtOrAfter(indices, minimum) {
106
120
  }
107
121
  return indices[low] ?? -1;
108
122
  }
109
- function serializeNodes(nodes) {
110
- return restoreAmpersands(richMarkdownManager().serialize({
123
+ function semantic(value, attrNodeType) {
124
+ if (Array.isArray(value)) {
125
+ const values = value.map((item) => semantic(item));
126
+ const merged = [];
127
+ for (const item of values) {
128
+ const previous = merged.at(-1);
129
+ if (isRecord(item) && item.type === "text" && typeof item.text === "string" && isRecord(previous) && previous.type === "text" && typeof previous.text === "string" && JSON.stringify(previous.marks) === JSON.stringify(item.marks)) previous.text += item.text;
130
+ else merged.push(item);
131
+ }
132
+ return merged;
133
+ }
134
+ if (!isRecord(value)) return value;
135
+ if (value.type === "rawInline" && isRecord(value.attrs) && /^\\(?:\[|\])$/.test(String(value.attrs.source))) return {
136
+ type: "text",
137
+ text: value.attrs.display
138
+ };
139
+ return meaningfulAttributes(Object.fromEntries(Object.entries(value).map(([key, item]) => [key, semantic(item, key === "attrs" ? value.type : void 0)])), {}, attrNodeType);
140
+ }
141
+ function sameDocument(markdown, nodes, manager) {
142
+ const parsed = Node.fromJSON(richSchema(), manager.parse(markdown)).toJSON().content ?? [];
143
+ return JSON.stringify(semantic(parsed)) === JSON.stringify(semantic(nodes));
144
+ }
145
+ function chooseBracketEscapes(markdown, readsSame, dangerousOffsets) {
146
+ if (!/\\(?:\[|\])/.test(markdown)) return {
147
+ markdown,
148
+ same: readsSame(markdown)
149
+ };
150
+ const all = markdown.replace(/\\(\[|\])/g, "$1");
151
+ if (readsSame(all)) return {
152
+ markdown: all,
153
+ same: true
154
+ };
155
+ let selective = all;
156
+ for (const offset of dangerousOffsets(all).sort((left, right) => right - left)) selective = `${selective.slice(0, offset)}\\${selective.slice(offset)}`;
157
+ if (selective !== all && readsSame(selective)) return {
158
+ markdown: selective,
159
+ same: true
160
+ };
161
+ return {
162
+ markdown,
163
+ same: readsSame(markdown)
164
+ };
165
+ }
166
+ function dangerousBracketOffsets(markdown, manager) {
167
+ const sources = [];
168
+ const seen = /* @__PURE__ */ new Set();
169
+ const pending = [manager.instance.lexer(markdown)];
170
+ while (pending.length) {
171
+ const value = pending.pop();
172
+ if (!value || typeof value !== "object" || seen.has(value)) continue;
173
+ seen.add(value);
174
+ if (Array.isArray(value)) {
175
+ pending.push(...[...value].reverse());
176
+ continue;
177
+ }
178
+ const token = value;
179
+ const raw = typeof token.raw === "string" ? token.raw : "";
180
+ const type = token.type;
181
+ const source = typeof token.source === "string" ? token.source : raw;
182
+ if ((type === "link" || type === "image" || type === "rawInline") && source.includes("[")) {
183
+ sources.push(raw || source);
184
+ continue;
185
+ }
186
+ const children = Object.entries(token).filter(([key]) => key !== "raw" && key !== "text" && key !== "source").map(([, child]) => child).reverse();
187
+ pending.push(...children);
188
+ }
189
+ const offsets = [];
190
+ let cursor = 0;
191
+ for (const source of sources) {
192
+ const at = markdown.indexOf(source, cursor);
193
+ const bracket = source.indexOf("[");
194
+ if (at < 0 || bracket < 0) continue;
195
+ offsets.push(at + bracket);
196
+ cursor = at + source.length;
197
+ }
198
+ return offsets;
199
+ }
200
+ function serializeNodes(nodes, definitionLabels) {
201
+ const json = nodes.map(asJson);
202
+ let marker = "raw-inline-amp";
203
+ const source = JSON.stringify(json);
204
+ while (source.includes(marker)) marker += "-";
205
+ const openMarker = `${marker}open`;
206
+ const closeMarker = `${marker}close`;
207
+ let textMarkerCode = 57600;
208
+ while (source.includes(String.fromCodePoint(textMarkerCode)) || source.includes(String.fromCodePoint(textMarkerCode + 1))) textMarkerCode += 2;
209
+ const textOpenMarker = String.fromCodePoint(textMarkerCode);
210
+ const textCloseMarker = String.fromCodePoint(textMarkerCode + 1);
211
+ const restoreProtected = (value) => value.replaceAll(openMarker, "\\[").replaceAll(closeMarker, "\\]").replaceAll(marker, "&");
212
+ const protect = (node) => ({
213
+ ...node,
214
+ text: node.type === "text" && typeof node.text === "string" && !node.marks?.some((mark) => mark.type === "link") ? node.text.replaceAll("[", textOpenMarker).replaceAll("]", textCloseMarker) : node.text,
215
+ attrs: node.type === "rawInline" && typeof node.attrs?.source === "string" ? {
216
+ ...node.attrs,
217
+ source: node.attrs.source.replaceAll("&", marker).replaceAll("\\[", openMarker).replaceAll("\\]", closeMarker)
218
+ } : node.attrs,
219
+ content: node.content?.map(protect)
220
+ });
221
+ const manager = scopedRichMarkdownManager(definitionLabels);
222
+ const render = (content) => restoreAmpersands(manager.serialize({
111
223
  type: "doc",
112
- content: nodes.map(asJson)
113
- }).trim());
224
+ content: content.map(protect)
225
+ }).replace(/^\n+|\n+$/g, "")).replaceAll(textOpenMarker, "\\[").replaceAll(textCloseMarker, "\\]");
226
+ const meaningful = (candidate) => {
227
+ return chooseBracketEscapes(candidate, (value) => sameDocument(restoreProtected(value), json, manager), (value) => dangerousBracketOffsets(value, manager));
228
+ };
229
+ let result = meaningful(render(json));
230
+ if (!result.same) result = meaningful(render(json.map(withoutRecordedSourceAttrs)));
231
+ return restoreProtected(result.markdown);
114
232
  }
115
233
  function matchingBlockIndex(entry, keys, keyIndex, blockIndex) {
116
234
  const currentKey = keys[keyIndex];
@@ -133,7 +251,7 @@ function stitch(entry, edited, keys) {
133
251
  const pending = [];
134
252
  const emitNew = () => {
135
253
  if (!pending.length) return;
136
- out += (out ? "\n\n" : bs[blockIndex]?.sep ?? "") + serializeNodes(pending.splice(0));
254
+ out += (out ? "\n\n" : bs[blockIndex]?.sep ?? "") + serializeNodes(pending.splice(0), entry.definitionLabels);
137
255
  last = -1;
138
256
  };
139
257
  const emitOld = (hit) => {
@@ -179,4 +297,4 @@ function saveRichDocument(original, edited, options = {}) {
179
297
  return entry.split && options.bodyTransform ? options.bodyTransform.join(saved, entry.split.retained) : saved;
180
298
  }
181
299
  //#endregion
182
- export { parseRichDocument, saveRichDocument };
300
+ export { chooseBracketEscapes, parseRichDocument, saveRichDocument };
@@ -0,0 +1,32 @@
1
+ import "./markdown-rich-raw-inline.js";
2
+ //#region src/markdown-rich-source-attrs.ts
3
+ function isRecordedSourceAttr(key, nodeType) {
4
+ return key.startsWith("source") && !(nodeType === "rawInline" && key === "source");
5
+ }
6
+ function meaningfulAttributes(value, options = {}, attrNodeType) {
7
+ if (Array.isArray(value)) return value.map((item) => meaningfulAttributes(item, options));
8
+ if (!value || typeof value !== "object") return value;
9
+ const record = value;
10
+ const nodeType = record.type ?? attrNodeType;
11
+ const out = {};
12
+ for (const [key, item] of Object.entries(record)) {
13
+ if (key === "dir" || isRecordedSourceAttr(key, nodeType) || item === null || item === void 0) continue;
14
+ if (!options.keepEmptyArrays && Array.isArray(item) && item.length === 0) continue;
15
+ if (options.omitDefaultTableSpans && (key === "colspan" || key === "rowspan") && item === 1) continue;
16
+ const next = meaningfulAttributes(item, options, key === "attrs" ? nodeType : void 0);
17
+ if (next && typeof next === "object" && !Array.isArray(next) && Object.keys(next).length === 0) continue;
18
+ out[key] = next;
19
+ }
20
+ return out;
21
+ }
22
+ function withoutRecordedSourceAttrs(value) {
23
+ const attrs = value.attrs ? Object.fromEntries(Object.entries(value.attrs).filter(([key]) => !isRecordedSourceAttr(key, value.type))) : value.attrs;
24
+ return {
25
+ ...value,
26
+ attrs,
27
+ marks: value.marks?.map((mark) => withoutRecordedSourceAttrs(mark)),
28
+ content: value.content?.map(withoutRecordedSourceAttrs)
29
+ };
30
+ }
31
+ //#endregion
32
+ export { isRecordedSourceAttr, meaningfulAttributes, withoutRecordedSourceAttrs };