@aicayzer/inkkit 0.0.16 → 0.0.18

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.
@@ -0,0 +1,477 @@
1
+ import { editorViewCtx, parserCtx } from '@milkdown/kit/core';
2
+ import { Fragment, Slice, } from '@milkdown/kit/prose/model';
3
+ import { EditorState } from '@milkdown/kit/prose/state';
4
+ import { fromMarkdown } from 'mdast-util-from-markdown';
5
+ import { gfmAutolinkLiteralFromMarkdown } from 'mdast-util-gfm-autolink-literal';
6
+ import { gfmFootnoteFromMarkdown } from 'mdast-util-gfm-footnote';
7
+ import { gfmStrikethroughFromMarkdown } from 'mdast-util-gfm-strikethrough';
8
+ import { gfmTableFromMarkdown } from 'mdast-util-gfm-table';
9
+ import { gfmTaskListItemFromMarkdown } from 'mdast-util-gfm-task-list-item';
10
+ import { gfmAutolinkLiteral } from 'micromark-extension-gfm-autolink-literal';
11
+ import { gfmFootnote } from 'micromark-extension-gfm-footnote';
12
+ import { gfmStrikethrough } from 'micromark-extension-gfm-strikethrough';
13
+ import { gfmTable } from 'micromark-extension-gfm-table';
14
+ import { gfmTaskListItem } from 'micromark-extension-gfm-task-list-item';
15
+ import { commentSource, commentTextEndsEarly } from './comments.js';
16
+ import { imageBlockName } from './image-blocks.js';
17
+ import { serialize } from './dialect.js';
18
+ import { isSafeLinkTarget, isScriptAddress } from './link-safety.js';
19
+ import { semanticSignature } from './preserve.js';
20
+ import { singleLine } from './references.js';
21
+ // HTML can carry values that the Markdown writer saves as they are, so content
22
+ // imported from HTML is kept only where its saved Markdown reads back as the
23
+ // same structure in InkKit, and where no reading of it holds an address that
24
+ // HTML import refuses. InkKit's own syntax, such as wiki links, literal source
25
+ // and comments, is read differently by other apps, so the saved Markdown is
26
+ // also read as GitHub Flavored Markdown, as strict CommonMark, and as
27
+ // CommonMark with raw HTML turned off.
28
+ const standard = () => ({
29
+ extensions: [
30
+ gfmAutolinkLiteral(),
31
+ gfmFootnote(),
32
+ gfmStrikethrough(),
33
+ gfmTable(),
34
+ gfmTaskListItem(),
35
+ ],
36
+ mdastExtensions: [
37
+ gfmAutolinkLiteralFromMarkdown(),
38
+ gfmFootnoteFromMarkdown(),
39
+ gfmStrikethroughFromMarkdown(),
40
+ gfmTableFromMarkdown(),
41
+ gfmTaskListItemFromMarkdown(),
42
+ ],
43
+ });
44
+ // An app that turns raw HTML off reads the Markdown inside HTML as text.
45
+ const withoutRawHTML = {
46
+ extensions: [{ disable: { null: ['htmlFlow', 'htmlText'] } }],
47
+ };
48
+ function standardTree(markdown) {
49
+ return fromMarkdown(markdown, standard());
50
+ }
51
+ function safeAddresses(markdown) {
52
+ // A scheme needs a colon, written as itself or as a character reference.
53
+ if (!/[:&]/.test(markdown))
54
+ return true;
55
+ const visit = (node) => {
56
+ if ((node.type === 'link' || node.type === 'definition') &&
57
+ !isSafeLinkTarget(node.url))
58
+ return false;
59
+ if (node.type === 'image' && isScriptAddress(node.url))
60
+ return false;
61
+ return !('children' in node) || node.children.every(visit);
62
+ };
63
+ return (visit(standardTree(markdown)) &&
64
+ visit(fromMarkdown(markdown)) &&
65
+ visit(fromMarkdown(markdown, withoutRawHTML)));
66
+ }
67
+ class Check {
68
+ ctx;
69
+ schema;
70
+ constructor(ctx, schema) {
71
+ this.ctx = ctx;
72
+ this.schema = schema;
73
+ }
74
+ base;
75
+ /**
76
+ * The document as the editor holds it once inserted, after the editor's
77
+ * plugins have normalised it, such as a paragraph holding only an image
78
+ * becoming an image block.
79
+ */
80
+ settled(doc) {
81
+ this.base ??= EditorState.create({
82
+ schema: this.schema,
83
+ plugins: this.ctx.get(editorViewCtx).state.plugins,
84
+ });
85
+ const { state } = this.base.applyTransaction(this.base.tr.replaceWith(0, this.base.doc.content.size, doc.content));
86
+ return state === this.base ? doc : state.doc;
87
+ }
88
+ /** Whether these blocks, saved and read back, give the same structure. */
89
+ readsBack(blocks) {
90
+ return this.reading(blocks) === true;
91
+ }
92
+ /**
93
+ * The blocks in a form that reads back as itself: as they are, or as their
94
+ * Markdown reads back when the only difference is a link that the reader
95
+ * makes of text, such as a bare web address.
96
+ */
97
+ kept(blocks) {
98
+ const reading = this.reading(blocks);
99
+ if (reading === true)
100
+ return [...blocks];
101
+ if (!reading || !reading.autolinks)
102
+ return undefined;
103
+ const children = reading.reread.children;
104
+ return this.readsBack(children) ? [...children] : undefined;
105
+ }
106
+ reading(blocks) {
107
+ try {
108
+ const doc = this.schema.topNodeType.createChecked(null, blocks);
109
+ const markdown = serialize(this.ctx, doc);
110
+ const reread = this.ctx.get(parserCtx)(markdown);
111
+ const read = semanticSignature(reread);
112
+ if (read === semanticSignature(doc) ||
113
+ read === this.settledSignature(doc))
114
+ return safeAddresses(markdown) || undefined;
115
+ return {
116
+ reread,
117
+ autolinks: semanticSignature(withoutAutolinks(reread)) ===
118
+ this.settledSignature(doc),
119
+ };
120
+ }
121
+ catch {
122
+ return undefined;
123
+ }
124
+ }
125
+ settledSignature(doc) {
126
+ return semanticSignature(this.settled(doc));
127
+ }
128
+ /** Index of the first block that reads back differently in context. */
129
+ firstDifference(blocks) {
130
+ try {
131
+ const doc = this.schema.topNodeType.createChecked(null, blocks);
132
+ const reread = this.ctx.get(parserCtx)(serialize(this.ctx, doc));
133
+ const settled = this.settled(doc);
134
+ const signature = (node) => semanticSignature(this.schema.topNodeType.create(null, node));
135
+ for (let index = 0; index < settled.childCount; index++)
136
+ if (index >= reread.childCount ||
137
+ signature(settled.child(index)) !== signature(reread.child(index)))
138
+ return index;
139
+ }
140
+ catch {
141
+ return 0;
142
+ }
143
+ // The structure matches, so an address is unsafe only in context.
144
+ return blocks.findIndex((block) => !isPlain(block));
145
+ }
146
+ }
147
+ // An address that GitHub Flavored Markdown reads from text as a link.
148
+ function autolink(href, text) {
149
+ return [text, `http://${text}`, `mailto:${text}`].includes(href);
150
+ }
151
+ function withoutAutolinks(node) {
152
+ if (node.isText)
153
+ return node.mark(node.marks.filter((mark) => mark.type.name !== 'link' ||
154
+ !autolink(String(mark.attrs.href), node.text)));
155
+ if (node.isLeaf)
156
+ return node;
157
+ return node.copy(Fragment.fromArray(node.children.map(withoutAutolinks)));
158
+ }
159
+ function isPlain(node) {
160
+ if (node.type.name !== 'paragraph')
161
+ return false;
162
+ let plain = true;
163
+ node.forEach((child) => {
164
+ if (!child.isText || child.marks.length)
165
+ plain = false;
166
+ });
167
+ return plain;
168
+ }
169
+ function leafText(node) {
170
+ switch (node.type.name) {
171
+ case 'image':
172
+ case imageBlockName:
173
+ return String(node.attrs.alt ?? '');
174
+ case 'footnote_reference':
175
+ return `[${String(node.attrs.label ?? '')}]`;
176
+ case 'inkkit_wiki_link':
177
+ return String(node.attrs.label || node.attrs.target || '');
178
+ case 'hardbreak':
179
+ return '\n';
180
+ case 'html':
181
+ return String(node.attrs.value ?? '');
182
+ default:
183
+ return node.textContent;
184
+ }
185
+ }
186
+ function text(schema, value, marks) {
187
+ return value ? [schema.text(value, marks)] : [];
188
+ }
189
+ /** The node's text as plain paragraphs, one for each line that has text. */
190
+ function paragraphs(check, node) {
191
+ const { schema } = check;
192
+ const source = node.isLeaf
193
+ ? leafText(node)
194
+ : node.textBetween(0, node.content.size, '\n', leafText);
195
+ const result = [];
196
+ for (const line of source.split(/\r\n?|\n/)) {
197
+ const value = line.trim();
198
+ if (!value)
199
+ continue;
200
+ const paragraph = schema.nodes.paragraph.create(null, schema.text(value));
201
+ result.push(...(check.kept([paragraph]) ?? []));
202
+ }
203
+ return result;
204
+ }
205
+ function codeBlock(schema, value) {
206
+ return schema.nodes.code_block.create(null, text(schema, value));
207
+ }
208
+ function literalSource(node) {
209
+ let source = '';
210
+ node.forEach((child) => {
211
+ source += child.isText ? child.text : commentSource(child);
212
+ });
213
+ return source;
214
+ }
215
+ /**
216
+ * Whether other readers take the comment as InkKit does: its text must not end
217
+ * it early, and GitHub Flavored Markdown, which has no `%%` comments, must
218
+ * read the text of one as plain text.
219
+ */
220
+ function commentHolds(node) {
221
+ const body = node.textContent;
222
+ if (commentTextEndsEarly(String(node.attrs.syntax), body))
223
+ return false;
224
+ if (node.attrs.syntax === 'html')
225
+ return true;
226
+ const source = commentSource(node);
227
+ const { children } = standardTree(source);
228
+ const paragraph = children[0];
229
+ const only = paragraph?.type === 'paragraph' ? paragraph.children : [];
230
+ return (children.length === 1 &&
231
+ only.length === 1 &&
232
+ only[0].type === 'text' &&
233
+ only[0].value === source);
234
+ }
235
+ function plainComment(check, node) {
236
+ const { schema } = check;
237
+ const body = singleLine(node.textContent).trim();
238
+ if (node.isInline)
239
+ return text(schema, body);
240
+ return [schema.nodes.paragraph.create(null, text(schema, body))];
241
+ }
242
+ /**
243
+ * Literal source and comments: InkKit shows their text, so they are kept only
244
+ * where every reading agrees. A literal that does not read back as itself,
245
+ * ending where it was written, becomes a code block with the same text. A
246
+ * `%%` comment whose text other readers would take as Markdown becomes an HTML
247
+ * comment, which they hide, and a comment that still does not hold becomes
248
+ * plain text.
249
+ */
250
+ function opaque(check, node) {
251
+ const { schema } = check;
252
+ const name = node.type.name;
253
+ // A literal must also end where it was written, so a paragraph follows it.
254
+ // One that holds only whitespace holds nothing.
255
+ if (name === 'literal_markdown')
256
+ return !literalSource(node).trim()
257
+ ? []
258
+ : check.readsBack([
259
+ node,
260
+ schema.nodes.paragraph.create(null, schema.text('x')),
261
+ ])
262
+ ? [node]
263
+ : [codeBlock(schema, literalSource(node))];
264
+ if (name === 'comment_inline' || name === 'comment_block') {
265
+ if (commentHolds(node))
266
+ return [node];
267
+ if (node.attrs.syntax === 'obsidian') {
268
+ const html = node.type.create({ ...node.attrs, syntax: 'html' }, node.content);
269
+ if (commentHolds(html))
270
+ return [html];
271
+ }
272
+ return plainComment(check, node);
273
+ }
274
+ if (node.isLeaf || node.isText)
275
+ return [node];
276
+ const children = [];
277
+ let changed = false;
278
+ node.forEach((child) => {
279
+ const replaced = opaque(check, child);
280
+ if (replaced.length !== 1 || replaced[0] !== child)
281
+ changed = true;
282
+ children.push(...replaced);
283
+ });
284
+ if (!changed)
285
+ return [node];
286
+ const content = Fragment.fromArray(children);
287
+ // A replacement the parent cannot hold turns the parent into text.
288
+ return node.type.validContent(content)
289
+ ? [node.copy(content)]
290
+ : paragraphs(check, node);
291
+ }
292
+ function plainMarks(schema, marks) {
293
+ return marks.map((mark) => mark.type === schema.marks.link
294
+ ? mark.type.create({ href: mark.attrs.href, title: mark.attrs.title })
295
+ : mark);
296
+ }
297
+ /**
298
+ * The block with InkKit's own metadata removed: inline nodes other than images
299
+ * and line breaks become their text, links lose their reference, callouts
300
+ * become quotes, definitions are dropped and code fences take default markers.
301
+ */
302
+ function plainForm(check, node) {
303
+ const { schema } = check;
304
+ const name = node.type.name;
305
+ if (node.isText)
306
+ return [schema.text(node.text, plainMarks(schema, node.marks))];
307
+ if (name === 'comment_inline' || name === 'comment_block')
308
+ return plainComment(check, node);
309
+ if (node.isInline) {
310
+ if (name === 'image' || name === 'hardbreak')
311
+ return [node.mark(plainMarks(schema, node.marks))];
312
+ return text(schema, leafText(node), plainMarks(schema, node.marks));
313
+ }
314
+ if (name === 'reference_definition')
315
+ return [];
316
+ if (name === 'literal_markdown')
317
+ return [codeBlock(schema, literalSource(node))];
318
+ if (name === 'code_block')
319
+ return [node.type.create({ language: node.attrs.language }, node.content)];
320
+ const children = [];
321
+ node.forEach((child) => children.push(...plainForm(check, child)));
322
+ if (name === 'footnote_definition')
323
+ return children;
324
+ if (name === 'inkkit_callout') {
325
+ const title = String(node.attrs.title ?? '').trim();
326
+ const quote = schema.nodes.blockquote.createChecked(null, [
327
+ ...(title
328
+ ? [schema.nodes.paragraph.create(null, schema.text(title))]
329
+ : []),
330
+ ...children,
331
+ ]);
332
+ return [quote];
333
+ }
334
+ const content = Fragment.fromArray(children);
335
+ return node.type.validContent(content)
336
+ ? [node.type.create(node.attrs, content, node.marks)]
337
+ : paragraphs(check, node);
338
+ }
339
+ function repair(check, block) {
340
+ const kept = check.kept([block]);
341
+ if (kept)
342
+ return kept;
343
+ try {
344
+ const plain = check.kept(plainForm(check, block));
345
+ if (plain)
346
+ return plain;
347
+ }
348
+ catch {
349
+ /* A plain form that the schema refuses falls back to text. */
350
+ }
351
+ return paragraphs(check, block);
352
+ }
353
+ function blocks(check, content) {
354
+ const result = [];
355
+ content.forEach((node) => result.push(...opaque(check, node)));
356
+ const kept = check.kept(result);
357
+ if (kept)
358
+ return kept;
359
+ let repaired = result.flatMap((node) => repair(check, node));
360
+ // Blocks that read back alone can still merge with a neighbour. The first
361
+ // block that differs takes its plain form, then becomes text, then goes.
362
+ const simplified = new Set();
363
+ for (let attempt = 0, limit = repaired.length * 3 + 1; attempt < limit && !check.readsBack(repaired); attempt++) {
364
+ const index = Math.max(0, check.firstDifference(repaired));
365
+ const block = repaired[index];
366
+ if (!block)
367
+ return [];
368
+ let replacement;
369
+ if (isPlain(block))
370
+ replacement = [];
371
+ else if (simplified.has(block))
372
+ replacement = paragraphs(check, block);
373
+ else {
374
+ try {
375
+ replacement = plainForm(check, block);
376
+ }
377
+ catch {
378
+ replacement = paragraphs(check, block);
379
+ }
380
+ for (const node of replacement)
381
+ simplified.add(node);
382
+ }
383
+ repaired = [
384
+ ...repaired.slice(0, index),
385
+ ...replacement,
386
+ ...repaired.slice(index + 1),
387
+ ];
388
+ }
389
+ return check.kept(repaired) ?? [];
390
+ }
391
+ function openDepth(content, open, start) {
392
+ let depth = 0;
393
+ let node = start ? content.firstChild : content.lastChild;
394
+ // Code and preserved source are never opened, so their text is not joined
395
+ // to the paragraph they are inserted beside.
396
+ while (depth < open &&
397
+ node &&
398
+ node.isBlock &&
399
+ !node.isLeaf &&
400
+ !node.type.spec.code) {
401
+ depth++;
402
+ node = start ? node.firstChild : node.lastChild;
403
+ }
404
+ return depth;
405
+ }
406
+ /**
407
+ * The slice's nodes as blocks a document holds, wrapped as insertion would
408
+ * wrap them: inline nodes in a paragraph and list items in a list.
409
+ */
410
+ export function topLevel(schema, content) {
411
+ const top = schema.topNodeType.contentMatch;
412
+ const result = [];
413
+ let run = [];
414
+ const close = () => {
415
+ const type = run[0]?.type;
416
+ const wrapping = type
417
+ ? type.isInline
418
+ ? [schema.nodes.paragraph]
419
+ : top.findWrapping(type)
420
+ : null;
421
+ if (wrapping?.length)
422
+ result.push(wrapping.reduceRight((inner, wrapper) => wrapper.create(null, inner), run));
423
+ else
424
+ result.push(...run);
425
+ run = [];
426
+ };
427
+ content.forEach((node) => {
428
+ const previous = run[0];
429
+ if (previous &&
430
+ (node.isInline ? !previous.isInline : node.type !== previous.type))
431
+ close();
432
+ if (node.isInline || !top.matchType(node.type))
433
+ run.push(node);
434
+ else {
435
+ close();
436
+ result.push(node);
437
+ }
438
+ });
439
+ close();
440
+ return result;
441
+ }
442
+ /**
443
+ * Returns the slice with every part whose Markdown would read back as
444
+ * different structure replaced by a form that reads back the same, keeping as
445
+ * much of its text and formatting as possible.
446
+ */
447
+ export function importableSlice(ctx, slice) {
448
+ const first = slice.content.firstChild;
449
+ if (!first)
450
+ return slice;
451
+ const check = new Check(ctx, first.type.schema);
452
+ const inline = first.isInline;
453
+ const content = Fragment.fromArray(topLevel(check.schema, slice.content));
454
+ const result = Fragment.fromArray(blocks(check, content));
455
+ // Inline nodes beside blocks were wrapped, so that form is the one to
456
+ // insert; other wrapping, such as list items in a list, is left to insertion.
457
+ let inlineCount = 0;
458
+ slice.content.forEach((node) => {
459
+ if (node.isInline)
460
+ inlineCount++;
461
+ });
462
+ const mixed = inlineCount > 0 && inlineCount < slice.content.childCount;
463
+ if (result.eq(content) && !mixed) {
464
+ const start = openDepth(slice.content, slice.openStart, true);
465
+ const end = openDepth(slice.content, slice.openEnd, false);
466
+ return start === slice.openStart && end === slice.openEnd
467
+ ? slice
468
+ : new Slice(slice.content, start, end);
469
+ }
470
+ if (inline &&
471
+ result.childCount === 1 &&
472
+ result.firstChild.type.name === 'paragraph')
473
+ return new Slice(result.firstChild.content, 0, 0);
474
+ const openStart = inline ? 1 : slice.openStart;
475
+ const openEnd = inline ? 1 : slice.openEnd;
476
+ return new Slice(result, openDepth(result, openStart, true), openDepth(result, openEnd, false));
477
+ }
@@ -1,5 +1,6 @@
1
1
  import type { MilkdownPlugin } from '@milkdown/kit/ctx';
2
2
  import { type Node as ProseNode, type NodeType } from '@milkdown/kit/prose/model';
3
+ import { type EditorState } from '@milkdown/kit/prose/state';
3
4
  import type { ParserState } from '@milkdown/kit/transformer';
4
5
  export declare const imageBlockName = "image_block";
5
6
  /** Attributes shared by an inline image and a standalone image block. */
@@ -60,6 +61,20 @@ export declare function markdownImageAttrs(node: MarkdownImage): {
60
61
  /** Whether a node displays an authored image, inline or as its own block. */
61
62
  export declare function isImage(node: ProseNode): boolean;
62
63
  export declare function isImageBlock(node: ProseNode | null | undefined): boolean;
64
+ export type ImagePlacement = {
65
+ kind: 'blocks';
66
+ } | {
67
+ kind: 'inline';
68
+ } | {
69
+ kind: 'after';
70
+ pos: number;
71
+ };
72
+ /** Where `insertImages` puts images for a selection. `blocks` replaces the selection with one block per
73
+ * image. `inline` places them on the one line of a table cell, which cannot hold blocks. `after` puts
74
+ * them below the nearest block that takes a following paragraph, leaving the selection and its
75
+ * block as written: the selection is in source that cannot hold an image, such as code, a comment
76
+ * or an unsupported literal block, or it spans table cells. Nothing is ever split or discarded. */
77
+ export declare function imagePlacement(state: EditorState): ImagePlacement;
63
78
  /** The image a paragraph holds as its only content, without marks, which makes the paragraph a
64
79
  * standalone image block. A linked or emphasised image stays inline so its marks are kept. */
65
80
  export declare function standaloneImage(children: readonly ProseNode[]): ProseNode | undefined;
@@ -2,6 +2,7 @@ import { keydownHandler } from '@milkdown/kit/prose/keymap';
2
2
  import { GapCursor } from '@milkdown/kit/prose/gapcursor';
3
3
  import { Fragment, } from '@milkdown/kit/prose/model';
4
4
  import { NodeSelection, Plugin, PluginKey, Selection, TextSelection, } from '@milkdown/kit/prose/state';
5
+ import { CellSelection } from '@milkdown/kit/prose/tables';
5
6
  import { $nodeSchema, $prose } from '@milkdown/kit/utils';
6
7
  export const imageBlockName = 'image_block';
7
8
  /** Attributes shared by an inline image and a standalone image block. */
@@ -38,6 +39,43 @@ export function isImageBlock(node) {
38
39
  function accepts(parent, index, type) {
39
40
  return parent.canReplaceWith(index, index + 1, type);
40
41
  }
42
+ const cellTypes = new Set(['table_cell', 'table_header']);
43
+ function cellDepth($pos) {
44
+ for (let depth = $pos.depth; depth > 0; depth--)
45
+ if (cellTypes.has($pos.node(depth).type.name))
46
+ return depth;
47
+ return 0;
48
+ }
49
+ /** Where `insertImages` puts images for a selection. `blocks` replaces the selection with one block per
50
+ * image. `inline` places them on the one line of a table cell, which cannot hold blocks. `after` puts
51
+ * them below the nearest block that takes a following paragraph, leaving the selection and its
52
+ * block as written: the selection is in source that cannot hold an image, such as code, a comment
53
+ * or an unsupported literal block, or it spans table cells. Nothing is ever split or discarded. */
54
+ export function imagePlacement(state) {
55
+ const image = state.schema.nodes.image;
56
+ const paragraph = state.schema.nodes.paragraph;
57
+ const { selection } = state;
58
+ const { $from, $to } = selection;
59
+ const unfit = selection instanceof CellSelection ||
60
+ [$from, $to].some(({ parent }) => parent.inlineContent &&
61
+ !(image && parent.type.contentMatch.matchType(image)));
62
+ const from = cellDepth($from);
63
+ const to = cellDepth($to);
64
+ if (!unfit && !from && !to)
65
+ return { kind: 'blocks' };
66
+ if (!unfit && from && to && $from.sameParent($to))
67
+ return { kind: 'inline' };
68
+ for (let depth = $to.depth; depth > 0; depth--) {
69
+ const node = $to.node(depth);
70
+ const parent = $to.node(depth - 1);
71
+ if (node.isInline)
72
+ continue;
73
+ const index = $to.index(depth - 1) + 1;
74
+ if (paragraph && parent.canReplaceWith(index, index, paragraph))
75
+ return { kind: 'after', pos: $to.after(depth) };
76
+ }
77
+ return { kind: 'after', pos: state.doc.content.size };
78
+ }
41
79
  /** The image a paragraph holds as its only content, without marks, which makes the paragraph a
42
80
  * standalone image block. A linked or emphasised image stays inline so its marks are kept. */
43
81
  export function standaloneImage(children) {
@@ -61,10 +99,15 @@ export function readParagraph(state, node, paragraph) {
61
99
  const image = standaloneImage(element.content);
62
100
  state.close();
63
101
  const parent = state.top();
102
+ const content = Fragment.fromArray(parent?.content ?? []);
103
+ // A parent that must open with other content, such as a list item that
104
+ // opens with a code fence, is filled in front of it when it closes.
105
+ const filler = parent?.type.contentMatch.fillBefore(content);
64
106
  if (image &&
65
107
  type &&
108
+ filler &&
66
109
  parent?.type.contentMatch
67
- .matchFragment(Fragment.fromArray(parent.content))
110
+ .matchFragment(filler.append(content))
68
111
  ?.matchType(type))
69
112
  state.addNode(type, image.attrs);
70
113
  else {
package/dist/index.d.ts CHANGED
@@ -1,7 +1 @@
1
- export { InkKitEditor } from './editor.js';
2
- export type { EditorEvents, EditorOptions, TextInputPreferences, CommandState, EditingMode, CaretState, Mark, Block, FormatCommand, Keymap, } from './editor.js';
3
- export type { TableCommand, TableOptions } from './tables.js';
4
- export type { Heading } from './outline.js';
5
- export { InkKitError } from './types.js';
6
- export type { DocumentFormat, DocumentContext, DocumentInput, DocumentSnapshot, EditorErrorCode, ImageAdapter, WikiLinkReference, WikiLinkAdapter, FileReference, FileContext, FilePresentation, FileAdapter, CapturedImage, PortableImage, ClipboardInput, ClipboardOutput, ClipboardImage, ClipboardDiagram, PrintableDocument, PrintableWarning, TextRange, ReadableTextSnapshot, TextRect, ViewportInsets, ViewportOptions, ViewportSnapshot, } from './types.js';
7
- export type { EditorLabels } from './labels.js';
1
+ export * from './core.js';
package/dist/index.js CHANGED
@@ -1,2 +1,3 @@
1
- export { InkKitEditor } from './editor.js';
2
- export { InkKitError } from './types.js';
1
+ import { registerMermaid } from './mermaid.js';
2
+ registerMermaid(async () => (await import('mermaid')).default);
3
+ export * from './core.js';
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Whether a link address may be kept. Relative paths, queries and fragments
3
+ * pass, and so does an address whose scheme is listed, in any letter case.
4
+ * Spaces, tabs and line breaks at the ends are ignored, as in a browser. Any
5
+ * other control character makes the address unsafe, because a browser may skip
6
+ * it while it reads the scheme. The scheme is the text before the first `:` in
7
+ * the part before the first `/`, `?`, `#` or `\`, so an unknown or malformed
8
+ * scheme is refused.
9
+ */
10
+ export declare function isSafeLinkTarget(value: string): boolean;
11
+ /**
12
+ * Whether an image source would run script if a renderer treated it as an
13
+ * address. Image sources are opaque references that a host adapter owns, so
14
+ * only these schemes are refused. Spaces and control characters are ignored
15
+ * wherever they appear, which refuses at least what a browser would run.
16
+ */
17
+ export declare function isScriptAddress(value: string): boolean;
@@ -0,0 +1,29 @@
1
+ // Schemes a link may keep in imported HTML and in print export. Each opens a
2
+ // mail, phone or web destination and runs no code.
3
+ const linkSchemes = new Set(['http', 'https', 'mailto', 'tel']);
4
+ /**
5
+ * Whether a link address may be kept. Relative paths, queries and fragments
6
+ * pass, and so does an address whose scheme is listed, in any letter case.
7
+ * Spaces, tabs and line breaks at the ends are ignored, as in a browser. Any
8
+ * other control character makes the address unsafe, because a browser may skip
9
+ * it while it reads the scheme. The scheme is the text before the first `:` in
10
+ * the part before the first `/`, `?`, `#` or `\`, so an unknown or malformed
11
+ * scheme is refused.
12
+ */
13
+ export function isSafeLinkTarget(value) {
14
+ const target = value.replace(/^[ \t\n\f\r]+|[ \t\n\f\r]+$/g, '');
15
+ if (/[\u0000-\u001f\u007f]/.test(target))
16
+ return false;
17
+ const head = target.slice(0, target.search(/[/?#\\]|$/));
18
+ const colon = head.indexOf(':');
19
+ return colon < 0 || linkSchemes.has(head.slice(0, colon).toLowerCase());
20
+ }
21
+ /**
22
+ * Whether an image source would run script if a renderer treated it as an
23
+ * address. Image sources are opaque references that a host adapter owns, so
24
+ * only these schemes are refused. Spaces and control characters are ignored
25
+ * wherever they appear, which refuses at least what a browser would run.
26
+ */
27
+ export function isScriptAddress(value) {
28
+ return /^(?:javascript|vbscript):/i.test(value.replace(/[\u0000-\u0020\u007f]/g, ''));
29
+ }
@@ -13,6 +13,7 @@ declare module 'mdast' {
13
13
  }
14
14
  }
15
15
  export declare function sourceFrontmatter(source: string): string | undefined;
16
+ export declare function lineBreakHTML(value: string): boolean;
16
17
  export declare function createLiteralPreservation(images: boolean): import("@milkdown/kit/utils").$Remark<"preserveLiterals", unknown>;
17
18
  export declare const preserveLiterals: import("@milkdown/kit/utils").$Remark<"preserveLiterals", unknown>;
18
19
  export declare const literalBlock: import("@milkdown/kit/utils").$Node;