@rathkit/rte-core 0.1.0-alpha.4 → 0.1.0-alpha.5

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,8 @@ 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;
9
11
  }
10
12
  /** Link reference definitions found in the document: `[label]: address "title"`. Keys come from `refLabel`. */
11
13
  export type Refs = ReadonlyMap<string, {
@@ -331,7 +331,7 @@ export function parseInline(src, options = {}) {
331
331
  }
332
332
  p.dead = true;
333
333
  }
334
- const safe = checkImageSrc(decodeEntities(tailInfo.dest), options.maxDataImageLength, options.sanitizeSvg);
334
+ const safe = checkImageSrc(decodeEntities(tailInfo.dest), options.maxDataImageLength, options.sanitizeSvg, options.relativeImages ?? true);
335
335
  const img = top;
336
336
  if (safe) {
337
337
  Object.assign(img, { k: "img", src: safe, alt });
@@ -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,15 @@ 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,
48
51
  });
49
52
  return blocks.length > 0 ? blocks : undefined;
50
53
  });
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;
package/dist/policy.js ADDED
@@ -0,0 +1,40 @@
1
+ export const DROPPED_ELEMENTS = new Set([
2
+ "script", "style", "link", "meta", "base", "object", "embed", "iframe", "frame", "frameset", "noscript", "template",
3
+ "head", "textarea", "input", "select", "option", "button", "canvas", "audio", "video", "applet", "xml",
4
+ ]);
5
+ export const GROUPING_ELEMENTS = new Set([
6
+ "div", "section", "article", "header", "footer", "aside", "nav", "figure", "figcaption", "address", "main", "form",
7
+ "body", "html", "center", "details", "summary", "dd", "dt", "fieldset", "hgroup",
8
+ ]);
9
+ export const DEFAULT_LINK_SCHEMES = ["http", "https", "mailto", "tel"];
10
+ export const IMAGE_SCHEMES = ["http", "https"];
11
+ export const DATA_IMAGE_SIGNATURES = [
12
+ ["image/png", "iVBORw0KGgo"], ["image/jpeg", "/9j/"], ["image/gif", "R0lGOD"], ["image/webp", "UklGR"], ["image/bmp", "Qk"],
13
+ ];
14
+ export const CONTENT_PROFILES = {
15
+ strict: { pasteRelativeImages: false, linkSchemes: ["http", "https", "mailto"], styles: "semantic", maxDataImageLength: 0, maxDepth: 32, maxInputLength: 1_000_000 },
16
+ standard: { pasteRelativeImages: false, linkSchemes: DEFAULT_LINK_SCHEMES, styles: "allowlist", maxDataImageLength: 12_000_000, maxDepth: 100, maxInputLength: 50_000_000 },
17
+ loose: { pasteRelativeImages: false, linkSchemes: ["http", "https", "mailto", "tel", "sms", "ftp", "ftps"], styles: "allowlist", maxDataImageLength: 25_000_000, maxDepth: 200, maxInputLength: 200_000_000 },
18
+ };
19
+ export function contentPolicy(options = {}, complain = () => undefined) {
20
+ const { profile = "standard", ...rest } = options;
21
+ if (!Object.hasOwn(CONTENT_PROFILES, profile))
22
+ complain(`content.profile "${String(profile)}" is not a profile: using standard (strict, standard or loose).`);
23
+ const base = Object.hasOwn(CONTENT_PROFILES, profile) ? CONTENT_PROFILES[profile] : CONTENT_PROFILES.standard;
24
+ const given = {};
25
+ for (const [key, value] of Object.entries(rest)) {
26
+ if (value === undefined)
27
+ continue;
28
+ const ok = key === "linkSchemes" ? Array.isArray(value) && value.every((v) => typeof v === "string" && /^[a-z][a-z0-9+.-]*$/i.test(v))
29
+ : key === "styles" ? value === "semantic" || value === "allowlist"
30
+ : key === "pasteRelativeImages" ? typeof value === "boolean"
31
+ : key === "maxDepth" ? Number.isInteger(value) && value >= 1
32
+ : (key === "maxInputLength" || key === "maxDataImageLength") ? Number.isInteger(value) && value >= 0
33
+ : false;
34
+ if (ok)
35
+ given[key] = value;
36
+ else
37
+ complain(`content.${key} cannot be ${JSON.stringify(value)}: using the ${profile in CONTENT_PROFILES ? profile : "standard"} profile's value.`);
38
+ }
39
+ return { ...base, ...given };
40
+ }
@@ -25,4 +25,3 @@ export interface StandardOptions extends Omit<EditorOptions, "plugins">, Standar
25
25
  /** The standard plugins, for hosts that build their own editor but want the usual parts. */
26
26
  export declare function standardPlugins(options?: StandardPluginOptions): Plugin[];
27
27
  export declare function createEditor(options: StandardOptions): Editor;
28
- export { Editor, type EditorOptions };
@@ -31,4 +31,3 @@ export function createEditor(options) {
31
31
  void [_lists, _tables, _find, _autoformat, _footnotes, _plugins, _u, _m, _f, _r];
32
32
  return new Editor({ ...editorOptions, plugins: standardPlugins(options) });
33
33
  }
34
- export { Editor };
package/dist/stats.js CHANGED
@@ -4,7 +4,7 @@ import { selectedText } from "./edit.js";
4
4
  const WORD = /[\p{L}\p{M}\p{N}]+(?:['’\-‑][\p{L}\p{M}\p{N}]+)*/gu;
5
5
  export function countText(text) {
6
6
  const clean = text.replace(//g, "").replace(/\n/g, " ");
7
- return { words: (clean.match(WORD) ?? []).length, characters: [...clean.replace(/\s+$/g, "").replace(/^\s+/g, "")].length };
7
+ return { words: (clean.match(WORD) ?? []).length, characters: [...clean.trim()].length };
8
8
  }
9
9
  export function countDocument(doc) {
10
10
  let words = 0;
package/dist/svg/svg.js CHANGED
@@ -44,7 +44,15 @@ const decodeXml = (value) => value.replace(ENTITY, (_, dec, hex, name) => {
44
44
  function parseXml(source, limits) {
45
45
  if (/<!ENTITY|<!ATTLIST|<!NOTATION/i.test(source) || /<!\[CDATA\[/i.test(source))
46
46
  return undefined;
47
- const stripped = source.replace(/^/, "").replace(/<\?xml[^>]*\?>/i, "").replace(/<!DOCTYPE[^>[]*(?:\[[^\]]*\])?[^>]*>/gi, "").replace(/<!--[\s\S]*?-->/g, "");
47
+ let stripped = source.replace(/^/, "").replace(/<\?xml[^>]*\?>/i, "").replace(/<!DOCTYPE[^>[]*(?:\[[^\]]*\])?[^>]*>/gi, "");
48
+ for (let pass = 0; pass < 4; pass++) {
49
+ const next = stripped.replace(/<!--[\s\S]*?-->/g, "");
50
+ if (next === stripped)
51
+ break;
52
+ stripped = next;
53
+ }
54
+ if (stripped.includes("<!--"))
55
+ return undefined;
48
56
  const stack = [];
49
57
  let root;
50
58
  let count = 0;
@@ -1,6 +1,6 @@
1
1
  import type { ViewHost } from "../plugin.ts";
2
2
  /** The attribute on a cell that a selection covers, for styling. */
3
- export declare const SELECTED_CELL = "data-rk-cell-selected";
3
+ export declare const SELECTED_CELL = "data-rte-cell-selected";
4
4
  /**
5
5
  * Marks the cells of the rectangle a selection runs across (when it starts and ends in different cells of one table). The marks are
6
6
  * attributes on the drawn cells, not part of the model, and they are put on again after every change.
@@ -1,6 +1,6 @@
1
1
  import { pathKey } from "../path.js";
2
2
  import { inRow, locateCell, selectedCells } from "../tablegrid.js";
3
- export const SELECTED_CELL = "data-rk-cell-selected";
3
+ export const SELECTED_CELL = "data-rte-cell-selected";
4
4
  const GRAB = 5;
5
5
  const MIN_WIDTH = 24;
6
6
  const MIN_HEIGHT = 16;
@@ -96,7 +96,7 @@ export function installColumnResize(host) {
96
96
  const rowBox = row.getBoundingClientRect();
97
97
  const rowTable = rowCell.closest("table")?.getBoundingClientRect() ?? rowBox;
98
98
  const rowLine = document.createElement("div");
99
- rowLine.setAttribute("data-rk-resize-line", "");
99
+ rowLine.setAttribute("data-rte-resize-line", "");
100
100
  rowLine.style.cssText = `position:fixed;z-index:2147483647;pointer-events:none;height:2px;background:#17408b;top:${rowBox.bottom - 1}px;left:${rowTable.left}px;width:${rowTable.width}px`;
101
101
  document.body.appendChild(rowLine);
102
102
  rowDrag = { path: rowPath, startY: event.clientY, startHeight: rowBox.height, top: rowBox.top, line: rowLine, height: rowBox.height };
@@ -106,7 +106,7 @@ export function installColumnResize(host) {
106
106
  const box = cell.getBoundingClientRect();
107
107
  const table = cell.closest("table")?.getBoundingClientRect() ?? box;
108
108
  const line = document.createElement("div");
109
- line.setAttribute("data-rk-resize-line", "");
109
+ line.setAttribute("data-rte-resize-line", "");
110
110
  line.style.cssText = `position:fixed;z-index:2147483647;pointer-events:none;width:2px;background:#17408b;left:${box.right - 1}px;top:${table.top}px;height:${table.height}px`;
111
111
  document.body.appendChild(line);
112
112
  drag = { cell, path, startX: event.clientX, startWidth: box.width, line, width: box.width };
@@ -82,7 +82,7 @@ export class TableGrips {
82
82
  }
83
83
  make(kind) {
84
84
  const el = document.createElement("div");
85
- el.setAttribute("data-rk-grip", kind);
85
+ el.setAttribute("data-rte-grip", kind);
86
86
  el.setAttribute("role", "presentation");
87
87
  const cursor = kind === "row" ? "ns-resize" : kind === "column" ? "ew-resize" : kind === "width" ? "col-resize" : "row-resize";
88
88
  el.style.cssText = `position:fixed;z-index:2147483646;width:${SIZE}px;height:${SIZE}px;box-sizing:border-box;background:${COLOR};border:2px solid #fff;border-radius:${kind === "row" || kind === "column" ? "4px" : "50%"};cursor:${cursor};touch-action:none;opacity:0.9`;
@@ -120,7 +120,7 @@ export class TableGrips {
120
120
  const t = table.getBoundingClientRect();
121
121
  const horizontal = kind === "column" || kind === "width";
122
122
  const line = document.createElement("div");
123
- line.setAttribute("data-rk-resize-line", "");
123
+ line.setAttribute("data-rte-resize-line", "");
124
124
  line.style.cssText = `position:fixed;z-index:2147483645;pointer-events:none;background:${COLOR};${horizontal ? `width:2px;top:${t.top}px;height:${t.height}px` : `height:2px;left:${t.left}px;width:${t.width}px`}`;
125
125
  document.body.appendChild(line);
126
126
  this.guide = line;
@@ -0,0 +1,16 @@
1
+ /** Why a warning was made. The codes are stable: a host can switch on them. */
2
+ export declare const WARNING_CODES: readonly ["plugin-failed", "plugin-rejected", "plugin-message", "key-conflict", "content-too-long", "content-unreadable", "copy-failed", "upload-failed", "bad-option", "missing-label", "late-options", "listener-failed"];
3
+ /** One of the codes in `WARNING_CODES`. */
4
+ export type WarningCode = (typeof WARNING_CODES)[number];
5
+ /** A problem the editor carried on from, reported to `onWarning`. Never thrown. */
6
+ export interface Warning {
7
+ readonly code: WarningCode;
8
+ /** For a person: what happened and what was done about it. */
9
+ readonly message: string;
10
+ /** The plugin it concerns, when there is one. */
11
+ readonly plugin?: string;
12
+ /** The error that was caught, when one was. */
13
+ readonly error?: unknown;
14
+ }
15
+ /** What an error says, whatever was thrown. */
16
+ export declare const describeError: (error: unknown) => string;
@@ -0,0 +1,5 @@
1
+ export const WARNING_CODES = [
2
+ "plugin-failed", "plugin-rejected", "plugin-message", "key-conflict", "content-too-long", "content-unreadable",
3
+ "copy-failed", "upload-failed", "bad-option", "missing-label", "late-options", "listener-failed",
4
+ ];
5
+ export const describeError = (error) => (error instanceof Error ? error.message : String(error));