@rathkit/rte-core 0.1.0-alpha.4 → 0.2.0-beta.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/dist/import.js CHANGED
@@ -1,24 +1,16 @@
1
1
  import { decodeEntities } from "./html/entities.js";
2
2
  import { parseHtml } from "./html/parse.js";
3
+ import { CONTENT_PROFILES, DATA_IMAGE_SIGNATURES, DEFAULT_LINK_SCHEMES, DROPPED_ELEMENTS, GROUPING_ELEMENTS, IMAGE_SCHEMES } from "./policy.js";
3
4
  import { parseLine, SIDES, withSides } from "./cellborder.js";
4
5
  import { COLOR_VALUE, COLUMN_WIDTH, MAX_INDENT, NOTE_ID, ROW_HEIGHT, inlineText, normalizeInline, } from "./model.js";
5
6
  import { asChildren } from "./path.js";
6
7
  import { DEFAULT_MARKER } from "./model.js";
7
- const DROP = new Set([
8
- "script", "style", "link", "meta", "base", "object", "embed", "iframe", "frame", "frameset", "noscript", "template",
9
- "head", "textarea", "input", "select", "option", "button", "canvas", "audio", "video", "applet", "xml",
10
- ]);
11
- const CONTAINERS = new Set([
12
- "div", "section", "article", "header", "footer", "aside", "nav", "figure", "figcaption", "address", "main", "form",
13
- "body", "html", "center", "details", "summary", "dd", "dt", "fieldset", "hgroup",
14
- ]);
8
+ const DROP = DROPPED_ELEMENTS;
9
+ const CONTAINERS = GROUPING_ELEMENTS;
15
10
  const MARK_TAGS = {
16
11
  strong: "bold", b: "bold", em: "italic", i: "italic", cite: "italic", dfn: "italic", var: "italic", u: "underline", ins: "underline",
17
12
  s: "strike", strike: "strike", del: "strike", code: "code", tt: "code", kbd: "code", samp: "code", sup: "sup", sub: "sub",
18
13
  };
19
- const RASTER = [
20
- ["image/png", "iVBORw0KGgo"], ["image/jpeg", "/9j/"], ["image/gif", "R0lGOD"], ["image/webp", "UklGR"], ["image/bmp", "Qk"],
21
- ];
22
14
  const COLOR = COLOR_VALUE;
23
15
  export function parseStyle(style) {
24
16
  const out = new Map();
@@ -78,9 +70,11 @@ function safeImageSrc(raw, settings) {
78
70
  if (!match)
79
71
  return undefined;
80
72
  const body = match[2];
81
- return RASTER.some(([, magic]) => body.startsWith(magic)) ? value : undefined;
73
+ return DATA_IMAGE_SIGNATURES.some(([, magic]) => body.startsWith(magic)) ? value : undefined;
82
74
  }
83
- return safeUrl(value, new Set(["http", "https"]));
75
+ if (!settings.relative && !/^(?:[a-z][a-z0-9+.-]*:|\/\/)/i.test(value.replace(INVISIBLE, "")))
76
+ return undefined;
77
+ return safeUrl(value, new Set(IMAGE_SCHEMES));
84
78
  }
85
79
  class Sink {
86
80
  blocks = [];
@@ -508,7 +502,7 @@ function convertList(el, ctx, settings, notes = false) {
508
502
  continue;
509
503
  if (child.tag === "ul" || child.tag === "ol") {
510
504
  const nested = convertList(child, ctx, settings);
511
- const last = list.items[list.items.length - 1] ?? list.items[list.items.push({ content: [], children: [] }) - 1];
505
+ const last = list.items[list.items.length - 1] ?? list.items[list.items.push({ content: [], children: [], bare: true }) - 1];
512
506
  last.children.push(nested);
513
507
  continue;
514
508
  }
@@ -548,6 +542,8 @@ function convertList(el, ctx, settings, notes = false) {
548
542
  if (id !== undefined)
549
543
  item.id = id;
550
544
  }
545
+ if (item.content.length === 0 && !task && item.children.length > 0 && /list-style(-type)?\s*:\s*none/i.test(child.attrs.style ?? ""))
546
+ item.bare = true;
551
547
  const level = levelOf(child.attrs["data-aria-level"] ?? child.attrs["aria-level"]);
552
548
  if (level !== undefined)
553
549
  item.level = level;
@@ -675,7 +671,7 @@ function rowHeights(rowEls, kept) {
675
671
  return heights.some((h) => h !== null) ? heights : undefined;
676
672
  }
677
673
  function columnWidths(table, rows) {
678
- const cols = Math.max(0, ...rows.map((row) => row.reduce((sum, cell) => sum + cell.colspan, 0)));
674
+ const cols = rows.reduce((most, row) => Math.max(most, row.reduce((sum, cell) => sum + cell.colspan, 0)), 0);
679
675
  const widths = [];
680
676
  const visit = (nodes) => {
681
677
  for (const n of nodes) {
@@ -696,7 +692,7 @@ function columnWidths(table, rows) {
696
692
  if (firstRow)
697
693
  for (const cell of firstRow.children)
698
694
  if (cell.type === "el" && (cell.tag === "td" || cell.tag === "th")) {
699
- const span = Math.max(Number.parseInt(cell.attrs.colspan ?? "1", 10) || 1, 1);
695
+ const span = Math.min(Math.max(Number.parseInt(cell.attrs.colspan ?? "1", 10) || 1, 1), 100);
700
696
  if (span === 1)
701
697
  widths.push(widthOf(cell));
702
698
  else
@@ -731,7 +727,7 @@ function padRows(rows) {
731
727
  reach[k] = reach[k] - 1;
732
728
  return used;
733
729
  });
734
- const widest = Math.max(0, ...widths);
730
+ const widest = widths.reduce((most, w) => Math.max(most, w), 0);
735
731
  return rows.map((cells, i) => {
736
732
  const missing = widest - widths[i];
737
733
  return missing > 0 ? [...cells, ...Array.from({ length: missing }, () => ({ header: false, colspan: 1, rowspan: 1, blocks: [{ type: "p", content: [] }] }))] : cells;
@@ -781,7 +777,7 @@ function nestByLevel(lists) {
781
777
  const fresh = { type: list.type, items: [] };
782
778
  const parent = stack[stack.length - 1];
783
779
  if (parent) {
784
- const host = parent.items[parent.items.length - 1] ?? parent.items[parent.items.push({ content: [], children: [] }) - 1];
780
+ const host = parent.items[parent.items.length - 1] ?? parent.items[parent.items.push({ content: [], children: [], bare: true }) - 1];
785
781
  host.children.push(fresh);
786
782
  }
787
783
  else {
@@ -800,7 +796,7 @@ function nestByLevel(lists) {
800
796
  return result.map((l) => freezeList(l, 0));
801
797
  }
802
798
  function freezeList(list, depth) {
803
- const items = list.items.map((item) => ({ content: item.content, children: item.children.map((child) => (child.type === "ul" || child.type === "ol" ? freezeList(child, depth + 1) : child)), ...(item.checked !== undefined ? { checked: item.checked } : {}), ...(item.id !== undefined ? { id: item.id } : {}) }));
799
+ const items = list.items.map((item) => ({ content: item.content, children: item.children.map((child) => (child.type === "ul" || child.type === "ol" ? freezeList(child, depth + 1) : child)), ...(item.checked !== undefined ? { checked: item.checked } : {}), ...(item.id !== undefined ? { id: item.id } : {}), ...(item.bare ? { bare: true } : {}) }));
804
800
  const style = list.style !== undefined && list.style !== DEFAULT_MARKER(list.type, depth) ? list.style : undefined;
805
801
  return { type: list.type, ...(list.notes ? { notes: true } : {}), ...(list.start !== undefined ? { start: list.start } : {}), ...(style !== undefined ? { style } : {}), items };
806
802
  }
@@ -821,17 +817,29 @@ function repairRewrittenSpaces(nodes) {
821
817
  export function importBlocks(html, options = {}) {
822
818
  const settings = {
823
819
  allowStyles: (options.styles ?? "allowlist") === "allowlist",
824
- schemes: new Set(options.linkSchemes ?? ["http", "https", "mailto", "tel"]),
820
+ schemes: new Set(options.linkSchemes ?? DEFAULT_LINK_SCHEMES),
825
821
  keepEmpty: options.keepEmptyBlocks ?? false,
826
- maxData: options.maxDataImageLength ?? 12_000_000,
822
+ maxData: options.maxDataImageLength ?? CONTENT_PROFILES.standard.maxDataImageLength,
823
+ relative: options.relativeImages ?? true,
827
824
  svg: options.sanitizeSvg,
828
825
  recognizers: options.recognizers ?? [],
829
826
  };
830
- const sink = new Sink(settings);
831
- const tree = parseHtml(html);
832
- convertNodes(/-webkit-text-stroke-width/.test(html) ? repairRewrittenSpaces(tree) : tree, sink, { marks: [], inLink: false, pre: false }, settings);
833
- sink.flush();
834
- return finishBlocks(sink.blocks);
827
+ const maxInput = options.maxInputLength ?? CONTENT_PROFILES.standard.maxInputLength;
828
+ if (html.length > maxInput) {
829
+ options.onLimit?.({ limit: "maxInputLength", max: maxInput, length: html.length });
830
+ return [];
831
+ }
832
+ try {
833
+ const sink = new Sink(settings);
834
+ const tree = parseHtml(html, options.maxDepth ?? CONTENT_PROFILES.standard.maxDepth);
835
+ convertNodes(/-webkit-text-stroke-width/.test(html) ? repairRewrittenSpaces(tree) : tree, sink, { marks: [], inLink: false, pre: false }, settings);
836
+ sink.flush();
837
+ return finishBlocks(sink.blocks);
838
+ }
839
+ catch (error) {
840
+ options.onError?.(error);
841
+ return [];
842
+ }
835
843
  }
836
844
  export function importHtml(html, options = {}) {
837
845
  const blocks = importBlocks(html, { keepEmptyBlocks: true, ...options });
@@ -854,4 +862,4 @@ export function importText(value) {
854
862
  }
855
863
  const decodeLineBreaks = (value) => value.replace(/\r\n?|\u2028|\u2029/g, "\n").replace(/^\n+|\n+$/g, "");
856
864
  export { decodeEntities };
857
- export const checkImageSrc = (src, maxDataLength = 12_000_000, sanitizeSvg) => safeImageSrc(src, { maxData: maxDataLength, svg: sanitizeSvg });
865
+ export const checkImageSrc = (src, maxDataLength = 12_000_000, sanitizeSvg, relative = true) => safeImageSrc(src, { maxData: maxDataLength, svg: sanitizeSvg, relative });
package/dist/index.d.ts CHANGED
@@ -1,6 +1,7 @@
1
- export declare const PACKAGE_NAME: "@rathkit/rte-core";
2
1
  export * from "./model.ts";
3
2
  export { checkImageSrc, importBlocks, importHtml, importText, safeUrl, parseStyle, type ImportOptions } from "./import.ts";
3
+ export { WARNING_CODES, type Warning, type WarningCode } from "./warning.ts";
4
+ export { CONTENT_PROFILES, contentPolicy, type ContentLimits, type ContentOptions, type ContentPolicy, type ContentProfile } from "./policy.ts";
4
5
  export { exportBlock, exportHtml, exportInline } from "./export.ts";
5
6
  export * from "./path.ts";
6
7
  export * from "./edit.ts";
package/dist/index.js CHANGED
@@ -1,6 +1,7 @@
1
- export const PACKAGE_NAME = "@rathkit/rte-core";
2
1
  export * from "./model.js";
3
2
  export { checkImageSrc, importBlocks, importHtml, importText, safeUrl, parseStyle } from "./import.js";
3
+ export { WARNING_CODES } from "./warning.js";
4
+ export { CONTENT_PROFILES, contentPolicy } from "./policy.js";
4
5
  export { exportBlock, exportHtml, exportInline } from "./export.js";
5
6
  export * from "./path.js";
6
7
  export * from "./edit.js";
@@ -68,7 +68,7 @@ function splitList(list, path, liftChildren) {
68
68
  const item = list.items[i];
69
69
  const numbering = (count) => (list.type === "ol" ? { start: (list.start ?? 1) + count } : {});
70
70
  if (k === undefined) {
71
- const placeholder = item.children.length > 0 && !liftChildren ? [{ content: [], children: item.children }] : [];
71
+ const placeholder = item.children.length > 0 && !liftChildren ? [{ content: [], children: item.children, bare: true }] : [];
72
72
  const rest = [...placeholder, ...list.items.slice(i + 1)];
73
73
  return {
74
74
  before: i > 0 ? { ...list, items: list.items.slice(0, i) } : undefined,
@@ -80,7 +80,7 @@ function splitList(list, path, liftChildren) {
80
80
  const inner = splitList(item.children[k], path.slice(2), liftChildren);
81
81
  const keptSublists = [...item.children.slice(0, k), ...(inner.before ? [inner.before] : [])];
82
82
  const afterSublists = [...(inner.after ? [inner.after] : []), ...item.children.slice(k + 1)];
83
- const rest = [...(afterSublists.length > 0 ? [{ content: [], children: afterSublists }] : []), ...list.items.slice(i + 1)];
83
+ const rest = [...(afterSublists.length > 0 ? [{ content: [], children: afterSublists, bare: true }] : []), ...list.items.slice(i + 1)];
84
84
  return {
85
85
  before: { ...list, items: [...list.items.slice(0, i), { content: item.content, children: keptSublists }] },
86
86
  content: inner.content,
@@ -271,7 +271,7 @@ export function indentItems(doc, sel, maxDepth = Number.POSITIVE_INFINITY) {
271
271
  const rest = list.items.slice(index + group.length);
272
272
  if (previous)
273
273
  return { ...list, items: [...list.items.slice(0, index - 1), nested(previous), ...rest] };
274
- return { ...list, items: [nested({ content: [], children: [] }), ...rest] };
274
+ return { ...list, items: [nested({ content: [], children: [], bare: true }), ...rest] };
275
275
  });
276
276
  }
277
277
  return { doc: next, selection: carry(doc, next, sel) };
@@ -286,6 +286,10 @@ export function outdentItems(doc, sel) {
286
286
  if (!loc)
287
287
  continue;
288
288
  if (loc.item.length === 1) {
289
+ const here = listOfItem(next, path);
290
+ const held = here?.list.items[here.index];
291
+ if (held && held.content.length === 0 && held.children.length > 0)
292
+ continue;
289
293
  next = takeOutOfList(next, path, true);
290
294
  continue;
291
295
  }
@@ -300,6 +304,7 @@ export function outdentItems(doc, sel) {
300
304
  const keptSub = itemIndex > 0 ? [{ ...sub, items: sub.items.slice(0, itemIndex) }] : [];
301
305
  const lifted = {
302
306
  content: moved.content,
307
+ ...(moved.bare ? { bare: true } : {}),
303
308
  children: [...moved.children, ...(followers.length > 0 ? [{ type: sub.type, ...(sub.style !== undefined ? { style: sub.style } : {}), items: followers }] : [])],
304
309
  };
305
310
  const children = [...parent.children.slice(0, sublistIndex), ...keptSub, ...parent.children.slice(sublistIndex + 1)];
@@ -355,6 +360,11 @@ export function backspaceAtItemStart(doc, pos) {
355
360
  const held = listOfItem(doc, pos.path);
356
361
  if (held?.list.items[held.index]?.checked !== undefined)
357
362
  return { doc: setChecked(doc, pos.path, undefined), selection: caret(pos) };
363
+ const only = held?.list.items[held.index];
364
+ if (only && only.content.length === 0 && only.bare !== true && (only.children[0]?.type === "ul" || only.children[0]?.type === "ol")) {
365
+ const next = mapListAt(doc, pos.path, (list, index) => ({ ...list, items: list.items.map((item, i) => (i === index ? { ...item, bare: true } : item)) }));
366
+ return { doc: next, selection: caret(pos) };
367
+ }
358
368
  if (!held || held.index !== 0)
359
369
  return undefined;
360
370
  return leaveListItem(doc, pos);
@@ -375,7 +385,7 @@ export function splitListItem(doc, pos) {
375
385
  ...list,
376
386
  items: list.items.flatMap((candidate, i) => (i !== index ? [candidate] : [
377
387
  { content: before, children: [], ...(item.checked !== undefined ? { checked: item.checked } : {}), ...(item.id !== undefined ? { id: item.id } : {}) },
378
- { content: after, children: item.children, ...(item.checked !== undefined ? { checked: false } : {}) },
388
+ { content: after, children: item.children, ...(item.checked !== undefined ? { checked: false } : {}), ...(item.bare && after.length === 0 ? { bare: true } : {}) },
379
389
  ])),
380
390
  }));
381
391
  return { doc: next, selection: caret({ path: containerPaths(next)[ordinal + 1] ?? pos.path, offset: 0 }) };
@@ -66,7 +66,7 @@ function inline(content, flat, table) {
66
66
  return;
67
67
  }
68
68
  const lead = /^\s*/.exec(text)?.[0] ?? "";
69
- const trail = lead.length === text.length ? "" : /\s*$/.exec(text)?.[0] ?? "";
69
+ const trail = lead.length === text.length ? "" : text.slice(text.trimEnd().length);
70
70
  const core = text.slice(lead.length, text.length - trail.length);
71
71
  const prev = runs[r - 1];
72
72
  const next = runs[r + 1];
@@ -6,6 +6,13 @@ export interface MarkdownOptions {
6
6
  readonly sanitizeSvg?: ((dataUrl: string) => string | undefined) | undefined;
7
7
  /** Largest `data:` image address, in characters. */
8
8
  readonly maxDataImageLength?: number;
9
+ /** Keep an image whose address is relative. Default true. */
10
+ readonly relativeImages?: boolean;
11
+ /**
12
+ * How deep lists and quotes nest before what is inside stays text, at most 24 whatever is given (the editor passes its content profile's
13
+ * `maxDepth`, so a stricter profile reads less). Each level reads the lines inside it again, so this also bounds the work to 24 times the text.
14
+ */
15
+ readonly maxDepth?: number;
9
16
  }
10
17
  /** Link reference definitions found in the document: `[label]: address "title"`. Keys come from `refLabel`. */
11
18
  export type Refs = ReadonlyMap<string, {
@@ -39,10 +39,21 @@ export function parseInline(src, options = {}) {
39
39
  let head = null;
40
40
  let tail = null;
41
41
  let buffer = "";
42
+ let pending = "";
43
+ const add = (text) => {
44
+ const kept = text.trimEnd().length;
45
+ if (kept === 0) {
46
+ pending += text;
47
+ return;
48
+ }
49
+ buffer += pending + text.slice(0, kept);
50
+ pending = text.slice(kept);
51
+ };
42
52
  const flush = () => {
43
- if (buffer !== "") {
44
- pieces.push({ k: "t", s: buffer });
53
+ if (buffer !== "" || pending !== "") {
54
+ pieces.push({ k: "t", s: buffer + pending });
45
55
  buffer = "";
56
+ pending = "";
46
57
  }
47
58
  };
48
59
  const unlink = (d) => {
@@ -187,25 +198,23 @@ export function parseInline(src, options = {}) {
187
198
  continue;
188
199
  }
189
200
  if (isAsciiPunct(n)) {
190
- buffer += n;
201
+ add(n);
191
202
  i += 2;
192
203
  continue;
193
204
  }
194
- buffer += c;
205
+ add(c);
195
206
  i++;
196
207
  continue;
197
208
  }
198
209
  if (c === "\n") {
199
- let spaces = 0;
200
- const kept = buffer.trimEnd().length;
201
- spaces = buffer.length - kept;
202
- buffer = buffer.slice(0, kept);
210
+ const spaces = pending.length;
211
+ pending = "";
203
212
  if (spaces >= 2) {
204
213
  flush();
205
214
  pieces.push({ k: "br" });
206
215
  }
207
216
  else
208
- buffer += " ";
217
+ add(" ");
209
218
  i++;
210
219
  while (src[i] === " " || src[i] === "\t")
211
220
  i++;
@@ -214,11 +223,11 @@ export function parseInline(src, options = {}) {
214
223
  if (c === "&") {
215
224
  const m = /^&(?:#\d{1,7}|#[xX][0-9a-fA-F]{1,6}|[A-Za-z][A-Za-z0-9]{1,31});/.exec(src.slice(i, i + 40));
216
225
  if (m) {
217
- buffer += decodeEntities(m[0]);
226
+ add(decodeEntities(m[0]));
218
227
  i += m[0].length;
219
228
  continue;
220
229
  }
221
- buffer += c;
230
+ add(c);
222
231
  i++;
223
232
  continue;
224
233
  }
@@ -228,7 +237,7 @@ export function parseInline(src, options = {}) {
228
237
  run++;
229
238
  const end = closingTicks(src, i + run, run);
230
239
  if (end === -1) {
231
- buffer += "`".repeat(run);
240
+ add("`".repeat(run));
232
241
  i += run;
233
242
  continue;
234
243
  }
@@ -245,7 +254,7 @@ export function parseInline(src, options = {}) {
245
254
  while (src[i + run] === c)
246
255
  run++;
247
256
  if (c === "~" && run > 2) {
248
- buffer += c.repeat(run);
257
+ add(c.repeat(run));
249
258
  i += run;
250
259
  continue;
251
260
  }
@@ -270,7 +279,7 @@ export function parseInline(src, options = {}) {
270
279
  const image = c === "!";
271
280
  flush();
272
281
  if (brackets.length >= MAX_BRACKETS) {
273
- buffer += image ? "![" : "[";
282
+ add(image ? "![" : "[");
274
283
  i += image ? 2 : 1;
275
284
  continue;
276
285
  }
@@ -283,14 +292,14 @@ export function parseInline(src, options = {}) {
283
292
  if (c === "]") {
284
293
  const top = brackets.pop();
285
294
  if (!top) {
286
- buffer += c;
295
+ add(c);
287
296
  i++;
288
297
  continue;
289
298
  }
290
299
  if (!top.active) {
291
300
  flush();
292
301
  asText(top);
293
- buffer += c;
302
+ add(c);
294
303
  i++;
295
304
  continue;
296
305
  }
@@ -311,7 +320,7 @@ export function parseInline(src, options = {}) {
311
320
  if (!tailInfo) {
312
321
  flush();
313
322
  asText(top);
314
- buffer += c;
323
+ add(c);
315
324
  i++;
316
325
  continue;
317
326
  }
@@ -331,7 +340,7 @@ export function parseInline(src, options = {}) {
331
340
  }
332
341
  p.dead = true;
333
342
  }
334
- const safe = checkImageSrc(decodeEntities(tailInfo.dest), options.maxDataImageLength, options.sanitizeSvg);
343
+ const safe = checkImageSrc(decodeEntities(tailInfo.dest), options.maxDataImageLength, options.sanitizeSvg, options.relativeImages ?? true);
335
344
  const img = top;
336
345
  if (safe) {
337
346
  Object.assign(img, { k: "img", src: safe, alt });
@@ -368,7 +377,7 @@ export function parseInline(src, options = {}) {
368
377
  i = tag.end;
369
378
  continue;
370
379
  }
371
- buffer += c;
380
+ add(c);
372
381
  i++;
373
382
  continue;
374
383
  }
@@ -381,7 +390,7 @@ export function parseInline(src, options = {}) {
381
390
  continue;
382
391
  }
383
392
  }
384
- buffer += c;
393
+ add(c);
385
394
  i++;
386
395
  }
387
396
  flush();
@@ -394,7 +394,7 @@ function parseList(lines, start, depth, options) {
394
394
  }
395
395
  function parseBlocks(lines, depth, options) {
396
396
  const out = [];
397
- if (depth >= MAX_DEPTH) {
397
+ if (depth >= Math.min(MAX_DEPTH, options.maxDepth ?? MAX_DEPTH)) {
398
398
  const p = paragraph(lines.join("\n"), options);
399
399
  return p ? [p] : [];
400
400
  }
@@ -23,7 +23,7 @@ export function markdown(options = {}) {
23
23
  return false;
24
24
  const { doc, selection } = ctx.snapshot;
25
25
  const blocks = host.ops.isCollapsed(selection) ? doc.blocks : host.ops.extractSelection(doc, selection);
26
- clipboard.writeText(exportMarkdown({ blocks }, { inlineHtml: options.inlineHtml === true })).catch((error) => host.editor.warn(`Copy as Markdown failed: ${String(error)}`));
26
+ clipboard.writeText(exportMarkdown({ blocks }, { inlineHtml: options.inlineHtml === true })).catch((error) => host.editor.report({ code: "copy-failed", plugin: "rathkit.markdown", message: `Copy as Markdown failed: ${error instanceof Error ? error.message : String(error)}`, error }));
27
27
  return true;
28
28
  },
29
29
  });
@@ -39,12 +39,16 @@ export function markdown(options = {}) {
39
39
  const path = selection.focus.path;
40
40
  if (kindAt(doc, path) === "pre" || getBlock(doc, path)?.type === "pre")
41
41
  return undefined;
42
+ if (text.length > (host.editor.importOptions.maxInputLength ?? Infinity))
43
+ return undefined;
42
44
  if (mode === "detect" && !looksLikeMarkdown(text))
43
45
  return undefined;
44
46
  const blocks = parseMarkdown(text, {
45
47
  ...(options.linkSchemes ? { linkSchemes: options.linkSchemes } : {}),
46
48
  ...(options.maxDataImageLength !== undefined ? { maxDataImageLength: options.maxDataImageLength } : {}),
47
49
  sanitizeSvg: host.editor.importOptions.sanitizeSvg,
50
+ relativeImages: host.editor.importOptions.pasteRelativeImages ?? false,
51
+ ...(host.editor.importOptions.maxDepth !== undefined ? { maxDepth: host.editor.importOptions.maxDepth } : {}),
48
52
  });
49
53
  return blocks.length > 0 ? blocks : undefined;
50
54
  });
package/dist/model.d.ts CHANGED
@@ -76,6 +76,11 @@ export interface ListItem {
76
76
  readonly checked?: boolean;
77
77
  /** In a notes list: the id of the reference this note belongs to. */
78
78
  readonly id?: string;
79
+ /**
80
+ * Set on an item that has no text and holds only a nested list, and shows no marker of its own: the nested list sits under it
81
+ * without a bullet or number above it. Without this an item with no text still shows its marker. Ignored once the item has text.
82
+ */
83
+ readonly bare?: true;
79
84
  }
80
85
  /** What a list item may hold besides its own text. Tables and definition lists are not allowed in an item. */
81
86
  export type ItemChild = List | Paragraph | Heading | CodeBlock | Quote | Rule;
package/dist/paste.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { importBlocks, importText } from "./import.js";
2
+ import { CONTENT_PROFILES } from "./policy.js";
2
3
  const START = "<!--StartFragment-->";
3
4
  const END = "<!--EndFragment-->";
4
5
  export function extractFragment(html) {
@@ -17,11 +18,17 @@ function unwrapSingleCell(blocks) {
17
18
  }
18
19
  export function defaultPaste(data, importOptions = {}) {
19
20
  if (data.html && data.html.trim() !== "") {
20
- const blocks = importBlocks(data.html, { ...importOptions, styles: "semantic" });
21
+ const blocks = importBlocks(data.html, { ...importOptions, styles: "semantic", relativeImages: importOptions.pasteRelativeImages ?? false });
21
22
  if (blocks.length > 0)
22
23
  return unwrapSingleCell(blocks);
23
24
  }
24
- return importText(data.text ?? "");
25
+ const text = data.text ?? "";
26
+ const max = importOptions.maxInputLength ?? CONTENT_PROFILES.standard.maxInputLength;
27
+ if (text.length > max) {
28
+ importOptions.onLimit?.({ limit: "maxInputLength", max, length: text.length });
29
+ return [];
30
+ }
31
+ return importText(text);
25
32
  }
26
33
  export function pasteBlocks(data, handlers = [], importOptions = {}) {
27
34
  for (const handler of handlers) {
package/dist/path.d.ts CHANGED
@@ -21,11 +21,18 @@ export declare const pathKey: (path: Path) => string;
21
21
  * nested under it, rows run left to right then top to bottom, and so on. Negative when `a` comes first.
22
22
  */
23
23
  export declare function comparePath(a: Path, b: Path): number;
24
+ /** A horizontal rule: where it is, and how many containers come before it in reading order. */
25
+ export interface RuleAt {
26
+ readonly path: Path;
27
+ readonly before: number;
28
+ }
24
29
  /**
25
30
  * Every container in the document, in reading order. Documents are never changed once made, so the answer is remembered
26
31
  * per document. The array is shared: do not change it.
27
32
  */
28
33
  export declare function containerPaths(doc: Doc): Path[];
34
+ /** Every horizontal rule, in reading order, with the number of containers before it. A rule between container n-1 and n has `before` n. The array is shared. */
35
+ export declare function rulePaths(doc: Doc): RuleAt[];
29
36
  export declare function blockContainers(block: Block, prefix: readonly number[]): Path[];
30
37
  /** True when the path leads to a container that exists. Cheap: it follows the path instead of listing every container. */
31
38
  export declare function hasContainer(doc: Doc, path: Path): boolean;
package/dist/path.js CHANGED
@@ -11,13 +11,13 @@ export function comparePath(a, b) {
11
11
  }
12
12
  return a.length - b.length;
13
13
  }
14
- function listPaths(list, prefix, out) {
14
+ function listPaths(list, prefix, out, rules) {
15
15
  list.items.forEach((item, i) => {
16
16
  out.push([...prefix, i]);
17
- item.children.forEach((child, k) => blockPaths(child, [...prefix, i, k], out));
17
+ item.children.forEach((child, k) => blockPaths(child, [...prefix, i, k], out, rules));
18
18
  });
19
19
  }
20
- function blockPaths(block, prefix, out) {
20
+ function blockPaths(block, prefix, out, rules) {
21
21
  switch (block.type) {
22
22
  case "p":
23
23
  case "h":
@@ -26,18 +26,20 @@ function blockPaths(block, prefix, out) {
26
26
  break;
27
27
  case "ul":
28
28
  case "ol":
29
- listPaths(block, prefix, out);
29
+ listPaths(block, prefix, out, rules);
30
30
  break;
31
31
  case "dl":
32
32
  block.entries.forEach((_, i) => out.push([...prefix, i]));
33
33
  break;
34
34
  case "table":
35
- block.rows.forEach((row, r) => row.forEach((cell, c) => cell.blocks.forEach((inner, n) => blockPaths(inner, [...prefix, r, c, n], out))));
35
+ block.rows.forEach((row, r) => row.forEach((cell, c) => cell.blocks.forEach((inner, n) => blockPaths(inner, [...prefix, r, c, n], out, rules))));
36
36
  break;
37
37
  case "quote":
38
- block.blocks.forEach((inner, n) => blockPaths(inner, [...prefix, n], out));
38
+ block.blocks.forEach((inner, n) => blockPaths(inner, [...prefix, n], out, rules));
39
+ break;
40
+ case "hr":
41
+ rules?.push({ path: prefix, before: out.length });
39
42
  break;
40
- case "hr": break;
41
43
  }
42
44
  }
43
45
  const containerCache = new WeakMap();
@@ -50,6 +52,17 @@ export function containerPaths(doc) {
50
52
  containerCache.set(doc, out);
51
53
  return out;
52
54
  }
55
+ const ruleCache = new WeakMap();
56
+ export function rulePaths(doc) {
57
+ const hit = ruleCache.get(doc);
58
+ if (hit)
59
+ return hit;
60
+ const out = [];
61
+ const containers = [];
62
+ doc.blocks.forEach((block, b) => blockPaths(block, [b], containers, out));
63
+ ruleCache.set(doc, out);
64
+ return out;
65
+ }
53
66
  export function blockContainers(block, prefix) {
54
67
  const out = [];
55
68
  blockPaths(block, prefix, out);
package/dist/plugin.d.ts CHANGED
@@ -69,7 +69,7 @@ export interface FileLike extends BlobLike {
69
69
  /** Turns pasted or dropped files into blocks, or returns undefined if it does not handle them. */
70
70
  export type FileHandler = (files: readonly FileLike[]) => readonly Block[] | undefined | Promise<readonly Block[] | undefined>;
71
71
  /** What a plugin sees of the editor: the same object a host gets, nothing more. */
72
- export type EditorHandle = Pick<Engine, "snapshot" | "apply" | "commands" | "subscribe" | "select" | "keymap" | "warn" | "importOptions" | "ui">;
72
+ export type EditorHandle = Pick<Engine, "snapshot" | "apply" | "commands" | "subscribe" | "select" | "keymap" | "warn" | "report" | "importOptions" | "ui">;
73
73
  export interface PluginHost {
74
74
  readonly editor: EditorHandle;
75
75
  /** The editing operations, so a plugin does not import the editor to use them. */
@@ -0,0 +1,41 @@
1
+ /** Elements that are dropped together with everything inside them. */
2
+ export declare const DROPPED_ELEMENTS: ReadonlySet<string>;
3
+ /** Elements that only group blocks: they end the paragraph before and after, and what is inside is kept. */
4
+ export declare const GROUPING_ELEMENTS: ReadonlySet<string>;
5
+ export declare const DEFAULT_LINK_SCHEMES: readonly string[];
6
+ /** Schemes an image address may use. Not configurable: a `data:` address is checked separately. */
7
+ export declare const IMAGE_SCHEMES: readonly string[];
8
+ /** The `data:` image types kept, with the first bytes (base64) their data must start with: the bytes are trusted, not the declared type. */
9
+ export declare const DATA_IMAGE_SIGNATURES: readonly (readonly [string, string])[];
10
+ /** The limits on what is read. */
11
+ export interface ContentLimits {
12
+ /** Longest markup read, in characters. Longer is refused whole: nothing is read and `onLimit` is told. */
13
+ readonly maxInputLength: number;
14
+ /** Deepest nesting of elements. An element nested deeper is dropped and what is inside it stays at the deepest allowed level. */
15
+ readonly maxDepth: number;
16
+ /** Longest `data:` image address, in characters. 0 refuses every `data:` image. */
17
+ readonly maxDataImageLength: number;
18
+ }
19
+ /** A profile: the limits and the choices a host makes. */
20
+ export interface ContentPolicy extends ContentLimits {
21
+ /** URL schemes a link may use besides relative and fragment addresses. */
22
+ readonly linkSchemes: readonly string[];
23
+ /** `semantic` keeps meaning only; `allowlist` also keeps text color, background and alignment. (Pasting is always `semantic`.) */
24
+ readonly styles: "semantic" | "allowlist";
25
+ /** Keep relative image addresses (`a.png`, `/a.png`) when pasting. Loading always keeps them. Default false in every profile. */
26
+ readonly pasteRelativeImages: boolean;
27
+ }
28
+ /** The name of a profile in `CONTENT_PROFILES`. */
29
+ export type ContentProfile = "strict" | "standard" | "loose";
30
+ /** The three profiles. `standard` is the default; `strict` is for hosts that take content from people they do not trust; `loose` for hosts that trust theirs. */
31
+ export declare const CONTENT_PROFILES: Readonly<Record<ContentProfile, ContentPolicy>>;
32
+ /** What a host may say: a profile to start from, then single choices on top of it. */
33
+ export type ContentOptions = {
34
+ readonly profile?: ContentProfile;
35
+ } & Partial<ContentPolicy>;
36
+ /**
37
+ * The policy for the options: the profile (`standard` when none is named), with each given choice laid over it. A choice that
38
+ * cannot be used (an unknown profile, a limit that is not a whole number of at least 0, a list that is not strings) is left out, the
39
+ * profile's value is used, and `complain` is told.
40
+ */
41
+ export declare function contentPolicy(options?: ContentOptions, complain?: (message: string) => void): ContentPolicy;