@aicayzer/inkkit 0.0.3 → 0.0.4

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.
@@ -8,4 +8,4 @@ export declare function clipboardText(content: Fragment, missingImages?: Readonl
8
8
  export declare function clipboardContent(content: Fragment, schema: Schema): ClipboardContent;
9
9
  export declare const visibleClipboard: import("@milkdown/kit/utils").$Prose;
10
10
  /** Export a frozen document fragment; presentation URLs never enter the clipboard. */
11
- export declare function portableClipboard(content: Fragment, schema: Schema, markdown: string, adapter: ImageAdapter | undefined, context: DocumentContext): Promise<ClipboardOutput>;
11
+ export declare function portableClipboard(content: Fragment, schema: Schema, markdown: string, adapter: ImageAdapter | undefined, context: DocumentContext, completeDiagrams?: ReadonlySet<string>): Promise<ClipboardOutput>;
package/dist/clipboard.js CHANGED
@@ -2,6 +2,7 @@ import { DOMSerializer, } from '@milkdown/kit/prose/model';
2
2
  import { Plugin, PluginKey } from '@milkdown/kit/prose/state';
3
3
  import { $prose } from '@milkdown/kit/utils';
4
4
  import { shareableFragment } from './comments.js';
5
+ import { diagramImage } from './mermaid.js';
5
6
  // Clipboard text is the visible document, not Markdown serialization: escaping
6
7
  // punctuation or trailing spaces here leaks source syntax into other apps.
7
8
  export function clipboardText(content, missingImages = new Set()) {
@@ -110,7 +111,7 @@ export const visibleClipboard = $prose(() => new Plugin({
110
111
  },
111
112
  }));
112
113
  /** Export a frozen document fragment; presentation URLs never enter the clipboard. */
113
- export async function portableClipboard(content, schema, markdown, adapter, context) {
114
+ export async function portableClipboard(content, schema, markdown, adapter, context, completeDiagrams) {
114
115
  content = shareableFragment(content);
115
116
  const base = clipboardContent(content, schema);
116
117
  const container = document.createElement('div');
@@ -130,6 +131,7 @@ export async function portableClipboard(content, schema, markdown, adapter, cont
130
131
  });
131
132
  await Promise.all(captured.map(async ({ element, reference, alt }, index) => {
132
133
  element.setAttribute('data-inkkit-image-slot', String(index));
134
+ element.setAttribute('data-inkkit-authored-image-slot', String(index));
133
135
  try {
134
136
  if (!adapter)
135
137
  throw new Error('The image is unavailable.');
@@ -152,6 +154,55 @@ export async function portableClipboard(content, schema, markdown, adapter, cont
152
154
  images[index] = { reference, alt, error: message };
153
155
  }
154
156
  }));
157
+ const diagrams = [];
158
+ for (const code of container.querySelectorAll('pre[data-language="mermaid"] > code')) {
159
+ const source = code.textContent ?? '';
160
+ if (completeDiagrams && !completeDiagrams.has(source))
161
+ continue;
162
+ const result = { source };
163
+ diagrams.push(result);
164
+ try {
165
+ const image = await diagramImage(source);
166
+ let binary = '';
167
+ for (const byte of image.bytes)
168
+ binary += String.fromCharCode(byte);
169
+ const figure = document.createElement('figure');
170
+ figure.setAttribute('data-inkkit-mermaid', source);
171
+ const fence = code.parentElement.getAttribute('data-inkkit-fence');
172
+ if (fence)
173
+ figure.setAttribute('data-inkkit-fence', fence);
174
+ const element = document.createElement('img');
175
+ element.src = `data:image/png;base64,${btoa(binary)}`;
176
+ element.alt = `Mermaid diagram: ${source}`;
177
+ figure.append(element);
178
+ code.parentElement.replaceWith(figure);
179
+ result.image = image;
180
+ }
181
+ catch (error) {
182
+ result.error =
183
+ error instanceof Error
184
+ ? error.message
185
+ : 'The diagram could not be exported.';
186
+ const warning = document.createElement('p');
187
+ warning.setAttribute('data-inkkit-diagram-error', '');
188
+ warning.textContent = `Diagram unavailable: ${result.error}`;
189
+ code.parentElement.before(warning);
190
+ }
191
+ }
192
+ // Native attachment slots follow DOM order, including generated diagrams.
193
+ const orderedImages = [];
194
+ for (const element of container.querySelectorAll('img')) {
195
+ const diagram = element.closest('[data-inkkit-mermaid]');
196
+ const item = diagram
197
+ ? {
198
+ reference: '',
199
+ alt: element.alt,
200
+ image: diagrams.find((entry) => entry.source === diagram.getAttribute('data-inkkit-mermaid')).image,
201
+ }
202
+ : images[Number(element.getAttribute('data-inkkit-image-slot'))];
203
+ element.setAttribute('data-inkkit-image-slot', String(orderedImages.length));
204
+ orderedImages.push(item);
205
+ }
155
206
  // Scriptable links and event attributes are not part of semantic rich text.
156
207
  for (const element of container.querySelectorAll('*')) {
157
208
  for (const attribute of [...element.attributes])
@@ -165,6 +216,7 @@ export async function portableClipboard(content, schema, markdown, adapter, cont
165
216
  text: clipboardText(content, missing),
166
217
  html: container.innerHTML,
167
218
  markdown,
168
- images,
219
+ images: [...orderedImages, ...images.filter((image) => image.error)],
220
+ diagrams,
169
221
  };
170
222
  }
@@ -0,0 +1,8 @@
1
+ import { type Options } from 'mdast-util-to-markdown';
2
+ import type { Ctx } from '@milkdown/kit/ctx';
3
+ import type { Node as ProseNode } from '@milkdown/kit/prose/model';
4
+ export declare const remarkCodeFences: import("@milkdown/kit/utils").$Remark<"codeFences", unknown>;
5
+ export declare const preservingCodeBlocks: import("@milkdown/kit/utils").$NodeSchema<"code_block">;
6
+ export declare const fencedCodeHandler: NonNullable<Options['handlers']>['code'];
7
+ export declare function restoreCodeEndings(ctx: Ctx, doc: ProseNode, markdown: string): string;
8
+ export declare function finalCodeEnding(doc: ProseNode): string | undefined;
@@ -0,0 +1,166 @@
1
+ import { codeBlockSchema } from '@milkdown/kit/preset/commonmark';
2
+ import { $remark } from '@milkdown/kit/utils';
3
+ import { defaultHandlers } from 'mdast-util-to-markdown';
4
+ import { remarkCtx } from '@milkdown/kit/core';
5
+ function readFence(value) {
6
+ if (typeof value !== 'object' || value == null)
7
+ return null;
8
+ const fence = value;
9
+ return typeof fence.marker === 'string' &&
10
+ /^(?:`{3,}|~{3,})$/.test(fence.marker) &&
11
+ fence.marker.length <= 30000 &&
12
+ typeof fence.info === 'string' &&
13
+ !/[\r\n]/.test(fence.info) &&
14
+ typeof fence.close === 'string' &&
15
+ (!fence.close ||
16
+ new RegExp(`^${fence.marker[0]}{${fence.marker.length},}$`).test(fence.close)) &&
17
+ typeof fence.trailing === 'string' &&
18
+ /^[ \t]*$/.test(fence.trailing) &&
19
+ ['\n', '\r\n'].includes(fence.ending)
20
+ ? fence
21
+ : null;
22
+ }
23
+ export const remarkCodeFences = $remark('codeFences', () => () => (tree, file) => {
24
+ const source = String(file.value).replace(/^\uFEFF/, '');
25
+ const visit = (node) => {
26
+ if (node.type === 'code') {
27
+ const raw = source.slice(node.position?.start.offset, node.position?.end.offset);
28
+ const opening = /^(`{3,}|~{3,})([^\r\n]*)(\r?\n)/.exec(raw);
29
+ if (opening) {
30
+ const close = /(?:^|\n)[ \t>]*(`{3,}|~{3,})([ \t]*)\r?$/.exec(raw);
31
+ const matching = close &&
32
+ close[1][0] === opening[1][0] &&
33
+ close[1].length >= opening[1].length;
34
+ node.data = {
35
+ ...node.data,
36
+ inkkitFence: {
37
+ marker: opening[1],
38
+ info: opening[2],
39
+ close: matching ? close[1] : '',
40
+ trailing: matching ? close[2] : '',
41
+ ending: opening[3],
42
+ },
43
+ };
44
+ }
45
+ }
46
+ if ('children' in node)
47
+ node.children.forEach(visit);
48
+ };
49
+ visit(tree);
50
+ });
51
+ export const preservingCodeBlocks = codeBlockSchema.extendSchema((base) => (ctx) => {
52
+ const schema = base(ctx);
53
+ return {
54
+ ...schema,
55
+ attrs: { ...schema.attrs, authoredFence: { default: null } },
56
+ parseDOM: [
57
+ {
58
+ tag: 'pre',
59
+ preserveWhitespace: 'full',
60
+ getAttrs: (dom) => {
61
+ let authoredFence = null;
62
+ try {
63
+ authoredFence = readFence(JSON.parse(dom.getAttribute('data-inkkit-fence') ?? 'null'));
64
+ }
65
+ catch {
66
+ /* Ordinary code stays usable without provenance. */
67
+ }
68
+ return { language: dom.dataset.language ?? '', authoredFence };
69
+ },
70
+ },
71
+ ],
72
+ toDOM: (node) => [
73
+ 'pre',
74
+ {
75
+ 'data-language': node.attrs.language,
76
+ ...(node.attrs.authoredFence
77
+ ? { 'data-inkkit-fence': JSON.stringify(node.attrs.authoredFence) }
78
+ : {}),
79
+ },
80
+ ['code', 0],
81
+ ],
82
+ parseMarkdown: {
83
+ match: (node) => node.type === 'code',
84
+ runner: (state, node, type) => {
85
+ state.openNode(type, {
86
+ language: node.lang ?? '',
87
+ authoredFence: readFence(node.data?.inkkitFence),
88
+ });
89
+ if (node.value)
90
+ state.addText(String(node.value).replace(/\r\n?/g, '\n'));
91
+ state.closeNode();
92
+ },
93
+ },
94
+ toMarkdown: {
95
+ match: (node) => node.type.name === 'code_block',
96
+ runner: (state, node) => {
97
+ state.addNode('code', undefined, node.textContent, {
98
+ lang: node.attrs.language,
99
+ data: { inkkitFence: node.attrs.authoredFence },
100
+ });
101
+ },
102
+ },
103
+ };
104
+ });
105
+ export const fencedCodeHandler = (node, parent, state, info) => {
106
+ const fence = readFence(node.data?.inkkitFence);
107
+ if (!fence)
108
+ return defaultHandlers.code(node, parent, state, info);
109
+ const character = fence.marker[0];
110
+ let length = fence.marker.length;
111
+ const body = node.value.replace(/\r\n?/g, '\n');
112
+ for (const line of body.split('\n')) {
113
+ const run = new RegExp(`^ {0,3}(${character}+)[ \\t]*$`).exec(line);
114
+ if (run)
115
+ length = Math.max(length, run[1].length + 1);
116
+ }
117
+ const close = fence.close
118
+ ? character.repeat(Math.max(length, fence.close.length)) + fence.trailing
119
+ : '';
120
+ return (character.repeat(length) +
121
+ fence.info +
122
+ fence.ending +
123
+ body.replaceAll('\n', fence.ending) +
124
+ (close ? (body ? fence.ending : '') + close : ''));
125
+ };
126
+ export function restoreCodeEndings(ctx, doc, markdown) {
127
+ const endings = [];
128
+ doc.descendants((node) => {
129
+ if (node.type.name === 'code_block')
130
+ endings.push(readFence(node.attrs.authoredFence)?.ending ?? '\n');
131
+ });
132
+ const patches = [];
133
+ let index = 0;
134
+ const visit = (node) => {
135
+ if (node.type === 'code') {
136
+ const ending = endings[index++];
137
+ const from = node.position?.start.offset, to = node.position?.end.offset;
138
+ if (ending === '\r\n' && from != null && to != null)
139
+ patches.push({
140
+ from,
141
+ to,
142
+ text: markdown
143
+ .slice(from, to)
144
+ .replace(/\r\n?/g, '\n')
145
+ .replaceAll('\n', ending),
146
+ });
147
+ }
148
+ if ('children' in node)
149
+ node.children.forEach(visit);
150
+ };
151
+ const processor = ctx.get(remarkCtx);
152
+ visit(processor.runSync(processor.parse(markdown), { value: markdown }));
153
+ for (const patch of patches.reverse())
154
+ markdown =
155
+ markdown.slice(0, patch.from) + patch.text + markdown.slice(patch.to);
156
+ return markdown;
157
+ }
158
+ export function finalCodeEnding(doc) {
159
+ let last = doc.lastChild;
160
+ while (last) {
161
+ if (last.type.name === 'code_block')
162
+ return readFence(last.attrs.authoredFence)?.ending;
163
+ last = last.lastChild;
164
+ }
165
+ return undefined;
166
+ }
package/dist/dialect.js CHANGED
@@ -3,6 +3,7 @@ import { commonmark, hardbreakSchema, imageSchema, paragraphSchema, remarkInline
3
3
  import { extendListItemSchemaForTask, strikethroughAttr, strikethroughInputRule, strikethroughKeymap, strikethroughSchema, toggleStrikethroughCommand, wrapInTaskListInputRule, tableSchema, tableHeaderRowSchema, tableHeaderSchema, tableRowSchema, tableCellSchema, tableEditingPlugin, tableKeymap, goToNextTableCellCommand, goToPrevTableCellCommand, insertTableCommand, addRowBeforeCommand, addRowAfterCommand, addColBeforeCommand, addColAfterCommand, selectRowCommand, selectColCommand, deleteSelectedCellsCommand, setAlignCommand, exitTable, } from '@milkdown/kit/preset/gfm';
4
4
  import { $remark } from '@milkdown/kit/utils';
5
5
  import { autolinkInputRule } from './autolink.js';
6
+ import { preservingCodeBlocks, remarkCodeFences, fencedCodeHandler, } from './code-fences.js';
6
7
  import { literalBlock, createLiteralPreservation } from './literals.js';
7
8
  import { references, remarkReferencesPlugin } from './references.js';
8
9
  import { callouts, remarkCalloutsPlugin } from './callouts.js';
@@ -85,7 +86,7 @@ function remarkDialect() {
85
86
  add('toMarkdownExtensions', gfmTaskListItemToMarkdown());
86
87
  add('toMarkdownExtensions', gfmAutolinkLiteralToMarkdown());
87
88
  add('toMarkdownExtensions', gfmTableToMarkdown());
88
- add('toMarkdownExtensions', { handlers: { link } });
89
+ add('toMarkdownExtensions', { handlers: { link, code: fencedCodeHandler } });
89
90
  }
90
91
  export const remarkDialectPlugin = $remark('remarkDialect', () => remarkDialect);
91
92
  // Reference links retain their authored form instead of expanding to inline links.
@@ -254,6 +255,7 @@ export const tables = [
254
255
  ].flat();
255
256
  export function createDialect(images) {
256
257
  return [
258
+ remarkCodeFences,
257
259
  remarkReferencesPlugin,
258
260
  remarkCalloutsPlugin,
259
261
  remarkCommentsPlugin,
@@ -264,6 +266,7 @@ export function createDialect(images) {
264
266
  callouts,
265
267
  comments,
266
268
  inlineHighlight,
269
+ preservingCodeBlocks,
267
270
  preserveSpacerParagraphs,
268
271
  visibleSoftbreaks,
269
272
  normalizedImages,
package/dist/editor.js CHANGED
@@ -26,6 +26,7 @@ import { PasteController } from './paste.js';
26
26
  import { selectionPlugin } from './selection.js';
27
27
  import { search, SearchQuery, getSearchState, setSearchState, } from 'prosemirror-search';
28
28
  import { taskListPlugin, toggleTaskList } from './tasks.js';
29
+ import { isMermaid, mermaidPreview } from './mermaid.js';
29
30
  const shortcutCommands = {
30
31
  heading1: ['heading', 1],
31
32
  heading2: ['heading', 2],
@@ -342,9 +343,24 @@ export class InkKitEditor {
342
343
  : this.plain.value.slice(this.plain.selectionStart, this.plain.selectionEnd);
343
344
  const pre = document.createElement('pre');
344
345
  pre.textContent = text;
345
- return { text, html: pre.outerHTML, markdown: text, images: [] };
346
+ return {
347
+ text,
348
+ html: pre.outerHTML,
349
+ markdown: text,
350
+ images: [],
351
+ diagrams: [],
352
+ };
346
353
  }
347
354
  const { doc, schema, selection } = this.editor.ctx.get(editorViewCtx).state;
355
+ const completeDiagrams = new Set();
356
+ doc.descendants((node, pos) => {
357
+ if (isMermaid(node) &&
358
+ (all ||
359
+ (selection.from <= pos + 1 &&
360
+ selection.to >= pos + node.nodeSize - 1))) {
361
+ completeDiagrams.add(node.textContent.replace(/\r\n?/g, '\n'));
362
+ }
363
+ });
348
364
  const content = all
349
365
  ? doc.content
350
366
  : commentSelectionContent(doc, selection.from, selection.to, selection.content().content);
@@ -355,14 +371,16 @@ export class InkKitEditor {
355
371
  try {
356
372
  markdown = all
357
373
  ? snapshot.text
358
- : selectionMarkdown(this.editor.ctx, doc, valid);
374
+ : completeDiagrams.size
375
+ ? new Preservation(this.editor.ctx, snapshot.text).serialize(doc.type.create(null, selectionContent(doc, valid)))
376
+ : selectionMarkdown(this.editor.ctx, doc, valid);
359
377
  }
360
378
  catch (error) {
361
379
  throw new InkKitError('preservation', error instanceof Error
362
380
  ? error.message
363
381
  : 'Cannot copy the selected Markdown');
364
382
  }
365
- const result = await portableClipboard(all ? content : selectionContent(doc, content), schema, markdown, this.options.images, this.context());
383
+ const result = await portableClipboard(all ? content : selectionContent(doc, content), schema, markdown, this.options.images, this.context(), completeDiagrams);
366
384
  const metadata = referenceMetadata(this.editor.ctx, doc, content);
367
385
  if (metadata != null)
368
386
  result.html = withReferenceMetadata(result.html, metadata);
@@ -371,6 +389,8 @@ export class InkKitEditor {
371
389
  throw new InkKitError('stale-document', 'Document changed');
372
390
  if (result.images.some((image) => image.error))
373
391
  this.events.error?.(new InkKitError('image-unavailable', 'Some copied images were unavailable; their descriptions were retained'));
392
+ if (result.diagrams?.some((diagram) => diagram.error))
393
+ this.events.error?.(new InkKitError('diagram-unavailable', 'Some diagrams could not be exported; their source was retained'));
374
394
  return result;
375
395
  }
376
396
  async paste(input) {
@@ -527,6 +547,7 @@ export class InkKitEditor {
527
547
  .use(codeCopyPlugin((text) => events.copy(text)))
528
548
  .use(placeholderPlugin)
529
549
  .use(highlightPlugin)
550
+ .use(mermaidPreview((error) => events.error?.(error)))
530
551
  .use(selectionPlugin)
531
552
  .use($prose(() => search()))
532
553
  .use(options.images ? imageView(options.images) : [])
@@ -595,7 +616,7 @@ export class InkKitEditor {
595
616
  const expanded = selectionContent(view.state.doc, fragment);
596
617
  let hasImages = false;
597
618
  expanded.descendants((node) => {
598
- if (node.type.name === 'image')
619
+ if (node.type.name === 'image' || isMermaid(node))
599
620
  hasImages = true;
600
621
  });
601
622
  if (!hasImages) {
@@ -633,8 +654,10 @@ export class InkKitEditor {
633
654
  void instance
634
655
  .clipboardSnapshot(false)
635
656
  .then(async (content) => {
636
- if (cut && content.images.some((image) => image.error))
637
- throw new InkKitError('image-unavailable', 'The selected images could not be cut safely');
657
+ if (cut &&
658
+ (content.images.some((image) => image.error) ||
659
+ content.diagrams?.some((diagram) => diagram.error)))
660
+ throw new InkKitError('image-unavailable', 'The selected content could not be cut safely');
638
661
  await events.clipboard(content);
639
662
  if (cut) {
640
663
  instance.assertCurrent();
@@ -651,7 +674,7 @@ export class InkKitEditor {
651
674
  container.textContent = clipboardText(expanded);
652
675
  event.clipboardData?.setData('text/plain', container.textContent ?? '');
653
676
  event.clipboardData?.setData('text/html', container.outerHTML);
654
- events.error?.(new InkKitError('image-unavailable', 'The host must provide a clipboard handler to copy images'));
677
+ events.error?.(new InkKitError('image-unavailable', 'The host must provide a clipboard handler for portable images and diagrams'));
655
678
  }
656
679
  };
657
680
  root.addEventListener('copy', instance.copyHandler, true);
package/dist/index.d.ts CHANGED
@@ -2,4 +2,4 @@ export { InkKitEditor } from './editor.js';
2
2
  export type { EditorEvents, CaretState, Mark, Block, FormatCommand, Keymap, } from './editor.js';
3
3
  export type { TableCommand } from './tables.js';
4
4
  export { InkKitError } from './types.js';
5
- export type { DocumentFormat, DocumentContext, DocumentInput, DocumentSnapshot, EditorErrorCode, ImageAdapter, CapturedImage, PortableImage, ClipboardInput, ClipboardOutput, ClipboardImage, } from './types.js';
5
+ export type { DocumentFormat, DocumentContext, DocumentInput, DocumentSnapshot, EditorErrorCode, ImageAdapter, CapturedImage, PortableImage, ClipboardInput, ClipboardOutput, ClipboardImage, ClipboardDiagram, } from './types.js';
@@ -0,0 +1,8 @@
1
+ import type { Node as ProseNode } from '@milkdown/kit/prose/model';
2
+ import { type PortableImage } from './types.js';
3
+ export declare function isMermaid(node: ProseNode): boolean;
4
+ export declare function validateDiagram(source: string): void;
5
+ export declare function safeDiagramSVG(svg: string): string;
6
+ export declare function renderDiagram(source: string): Promise<string>;
7
+ export declare function diagramImage(source: string): Promise<PortableImage>;
8
+ export declare function mermaidPreview(onError: (error: Error) => void): import("@milkdown/kit/utils").$Prose;
@@ -0,0 +1,216 @@
1
+ import mermaid from 'mermaid';
2
+ import DOMPurify from 'dompurify';
3
+ import { Plugin, PluginKey } from '@milkdown/kit/prose/state';
4
+ import { Decoration, DecorationSet } from '@milkdown/kit/prose/view';
5
+ import { $prose } from '@milkdown/kit/utils';
6
+ import { InkKitError } from './types.js';
7
+ export function isMermaid(node) {
8
+ return node.type.name === 'code_block' && node.attrs.language === 'mermaid';
9
+ }
10
+ export function validateDiagram(source) {
11
+ if (source.length > 30000)
12
+ throw new Error('The diagram exceeds the 30,000 character rendering limit.');
13
+ if (!/^\s*(?:%%[^\n]*\n\s*)*(?:flowchart|graph|sequenceDiagram|classDiagram|stateDiagram(?:-v2)?|erDiagram|pie)\b/.test(source))
14
+ throw new Error('This diagram type is not supported. The source is retained.');
15
+ // Mermaid can load image bytes before sanitising its result. Reject resource
16
+ // and configuration syntax before calling the renderer, including entities.
17
+ if (/%%\{|^\s*---|<\s*[a-z!/?]|&(?:#\w+|[a-z]+);|@\{|\$\$|(?:https?|data|file|javascript|vbscript|blob):|\/\/|\b(?:click|callback|href|link|image|icon|fontawesome|style|classDef|linkStyle)\b|url\s*\(|@import|\\/im.test(source))
18
+ throw new Error('HTML, actions, resources and diagram configuration are disabled. The source is retained.');
19
+ }
20
+ let sequence = 0;
21
+ let queue = Promise.resolve();
22
+ const cache = new Map();
23
+ export function safeDiagramSVG(svg) {
24
+ const clean = DOMPurify.sanitize(svg, {
25
+ USE_PROFILES: { svg: true, svgFilters: true },
26
+ FORBID_TAGS: ['foreignObject', 'image', 'a', 'script', 'animate', 'set'],
27
+ FORBID_ATTR: ['href', 'xlink:href'],
28
+ });
29
+ const template = document.createElement('template');
30
+ template.innerHTML = clean;
31
+ const root = template.content.querySelector('svg');
32
+ if (!root)
33
+ throw new Error('The renderer did not produce a diagram.');
34
+ for (const element of [root, ...root.querySelectorAll('*')]) {
35
+ for (const attribute of [...element.attributes]) {
36
+ if (/^on/i.test(attribute.name) ||
37
+ /(?:https?|data|file|javascript|blob):|@import|url\s*\(\s*[^#]|\\/i.test(attribute.value))
38
+ element.removeAttribute(attribute.name);
39
+ }
40
+ if (element.tagName.toLowerCase() === 'style' &&
41
+ /@import|(?:https?|data|file):|url\s*\(\s*[^#]|\\/i.test(element.textContent ?? ''))
42
+ element.remove();
43
+ }
44
+ root.setAttribute('xmlns', 'http://www.w3.org/2000/svg');
45
+ return root.outerHTML;
46
+ }
47
+ export function renderDiagram(source) {
48
+ try {
49
+ validateDiagram(source);
50
+ }
51
+ catch (error) {
52
+ return Promise.reject(error);
53
+ }
54
+ const previous = cache.get(source);
55
+ if (previous)
56
+ return previous;
57
+ const rendered = queue.then(async () => {
58
+ mermaid.initialize({
59
+ startOnLoad: false,
60
+ securityLevel: 'strict',
61
+ htmlLabels: false,
62
+ suppressErrorRendering: true,
63
+ fontFamily: 'Arial, sans-serif',
64
+ altFontFamily: 'Arial, sans-serif',
65
+ layout: 'dagre',
66
+ look: 'classic',
67
+ theme: 'default',
68
+ maxTextSize: 30000,
69
+ maxEdges: 500,
70
+ flowchart: { htmlLabels: false },
71
+ sequence: {
72
+ actorFontFamily: 'Arial, sans-serif',
73
+ noteFontFamily: 'Arial, sans-serif',
74
+ messageFontFamily: 'Arial, sans-serif',
75
+ },
76
+ secure: [
77
+ 'securityLevel',
78
+ 'htmlLabels',
79
+ 'theme',
80
+ 'themeCSS',
81
+ 'themeVariables',
82
+ 'fontFamily',
83
+ 'dompurifyConfig',
84
+ 'flowchart',
85
+ 'startOnLoad',
86
+ 'maxTextSize',
87
+ 'maxEdges',
88
+ 'secure',
89
+ 'altFontFamily',
90
+ 'layout',
91
+ 'look',
92
+ 'sequence',
93
+ 'class',
94
+ 'state',
95
+ 'er',
96
+ 'pie',
97
+ 'suppressErrorRendering',
98
+ ],
99
+ });
100
+ const holder = document.createElement('div');
101
+ holder.style.cssText =
102
+ 'position:fixed;left:-100000px;top:0;width:1200px;pointer-events:none';
103
+ holder.setAttribute('aria-hidden', 'true');
104
+ document.body.append(holder);
105
+ try {
106
+ const result = await mermaid.render(`inkkitDiagram${++sequence}`, source, holder);
107
+ return safeDiagramSVG(result.svg);
108
+ }
109
+ finally {
110
+ holder.remove();
111
+ }
112
+ });
113
+ queue = rendered.catch(() => undefined);
114
+ cache.set(source, rendered);
115
+ void rendered.catch(() => {
116
+ if (cache.get(source) === rendered)
117
+ cache.delete(source);
118
+ });
119
+ if (cache.size > 64)
120
+ cache.delete(cache.keys().next().value);
121
+ return rendered;
122
+ }
123
+ export async function diagramImage(source) {
124
+ const svg = await renderDiagram(source);
125
+ const parsed = new DOMParser().parseFromString(svg, 'image/svg+xml').documentElement;
126
+ const bounds = (parsed.getAttribute('viewBox') ?? '')
127
+ .split(/[\s,]+/)
128
+ .map(Number);
129
+ const width = bounds[2] || Number.parseFloat(parsed.getAttribute('width') ?? '');
130
+ const height = bounds[3] || Number.parseFloat(parsed.getAttribute('height') ?? '');
131
+ if (!(width > 0 && height > 0 && Number.isFinite(width + height)))
132
+ throw new Error('The rendered diagram has no usable dimensions.');
133
+ const scale = Math.min(2, 4096 / width, 4096 / height);
134
+ const canvas = document.createElement('canvas');
135
+ canvas.width = Math.max(1, Math.ceil(width * scale));
136
+ canvas.height = Math.max(1, Math.ceil(height * scale));
137
+ const context = canvas.getContext('2d');
138
+ if (!context)
139
+ throw new Error('Portable diagram output is unavailable.');
140
+ parsed.setAttribute('width', String(width));
141
+ parsed.setAttribute('height', String(height));
142
+ const image = new Image();
143
+ const url = URL.createObjectURL(new Blob([new XMLSerializer().serializeToString(parsed)], {
144
+ type: 'image/svg+xml',
145
+ }));
146
+ try {
147
+ await new Promise((resolve, reject) => {
148
+ image.onload = () => resolve();
149
+ image.onerror = () => reject(new Error('The diagram image could not be rendered.'));
150
+ image.src = url;
151
+ });
152
+ context.fillStyle = '#ffffff';
153
+ context.fillRect(0, 0, canvas.width, canvas.height);
154
+ context.drawImage(image, 0, 0, canvas.width, canvas.height);
155
+ const data = canvas.toDataURL('image/png').split(',')[1];
156
+ if (!data)
157
+ throw new Error('The diagram image could not be exported.');
158
+ return {
159
+ bytes: Uint8Array.from(atob(data), (character) => character.charCodeAt(0)),
160
+ mimeType: 'image/png',
161
+ filename: 'diagram.png',
162
+ };
163
+ }
164
+ finally {
165
+ URL.revokeObjectURL(url);
166
+ }
167
+ }
168
+ export function mermaidPreview(onError) {
169
+ return $prose(() => new Plugin({
170
+ key: new PluginKey('mermaidPreview'),
171
+ props: {
172
+ decorations(state) {
173
+ const widgets = [];
174
+ state.doc.descendants((node, pos) => {
175
+ if (!isMermaid(node))
176
+ return true;
177
+ const source = node.textContent;
178
+ widgets.push(Decoration.widget(pos + node.nodeSize, () => {
179
+ const preview = document.createElement('div');
180
+ preview.className = 'inkkit-mermaid-preview';
181
+ preview.contentEditable = 'false';
182
+ preview.textContent = 'Rendering diagram…';
183
+ let active = true;
184
+ preview.cancel =
185
+ () => {
186
+ active = false;
187
+ };
188
+ void renderDiagram(source).then((svg) => {
189
+ if (active) {
190
+ preview.innerHTML = svg;
191
+ preview.dataset.state = 'ready';
192
+ }
193
+ }, (error) => {
194
+ if (!active)
195
+ return;
196
+ const message = error instanceof Error
197
+ ? error.message
198
+ : 'The diagram could not be rendered.';
199
+ preview.textContent = `Diagram unavailable: ${message}`;
200
+ preview.dataset.state = 'error';
201
+ onError(new InkKitError('diagram-unavailable', message));
202
+ });
203
+ return preview;
204
+ }, {
205
+ key: `mermaid:${source}`,
206
+ side: -1,
207
+ ignoreSelection: true,
208
+ destroy: (node) => node.cancel?.(),
209
+ }));
210
+ return false;
211
+ });
212
+ return DecorationSet.create(state.doc, widgets);
213
+ },
214
+ },
215
+ }));
216
+ }
package/dist/paste.js CHANGED
@@ -136,6 +136,23 @@ export class PasteController {
136
136
  const template = document.createElement('template');
137
137
  template.innerHTML = input.html;
138
138
  const container = template.content;
139
+ for (const warning of container.querySelectorAll('[data-inkkit-diagram-error]'))
140
+ warning.remove();
141
+ for (const diagram of container.querySelectorAll('figure[data-inkkit-mermaid]')) {
142
+ const source = diagram.getAttribute('data-inkkit-mermaid') ?? '';
143
+ if (source.length > 30000)
144
+ continue;
145
+ const pre = document.createElement('pre');
146
+ pre.dataset.language = 'mermaid';
147
+ const fence = diagram.getAttribute('data-inkkit-fence');
148
+ if (fence)
149
+ pre.setAttribute('data-inkkit-fence', fence);
150
+ const code = document.createElement('code');
151
+ code.className = 'language-mermaid';
152
+ code.textContent = source;
153
+ pre.append(code);
154
+ diagram.replaceWith(pre);
155
+ }
139
156
  if (markdown == null) {
140
157
  // Native destinations can keep node metadata after dropping the
141
158
  // Markdown wrapper and its definitions. Import their visible HTML.
@@ -301,8 +318,7 @@ export class PasteController {
301
318
  const slot = /^inkkit-clipboard-image:(\d+)$/.exec(String(node.attrs.src));
302
319
  const position = slot ? Number(slot[1]) : imageIndex++;
303
320
  const element = elements.some((element) => element.hasAttribute('data-inkkit-image-slot'))
304
- ? elements.find((element) => Number(element.getAttribute('data-inkkit-image-slot')) ===
305
- position)
321
+ ? elements.find((element) => Number(element.getAttribute('data-inkkit-authored-image-slot') ?? element.getAttribute('data-inkkit-image-slot')) === position)
306
322
  : elements[position];
307
323
  if (!element || !container.contains(element))
308
324
  return schema.text(`[${node.attrs.alt || 'Image'}: image unavailable]`);
package/dist/preserve.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { parserCtx, remarkCtx } from '@milkdown/kit/core';
2
2
  import { serialize } from './dialect.js';
3
+ import { restoreCodeEndings, finalCodeEnding } from './code-fences.js';
3
4
  function signature(node) {
4
5
  return JSON.stringify(node, (key, value) => ['position', 'data', 'url', 'title', 'marker'].includes(key)
5
6
  ? undefined
@@ -115,6 +116,40 @@ function retainSource(before, after, source, canonical, lineEnding) {
115
116
  if (start == null || end == null || newStart == null || newEnd == null)
116
117
  throw new PreservationError('A source token has no location');
117
118
  const raw = source.slice(start, end);
119
+ if (before.type === 'code' &&
120
+ after.type === 'code' &&
121
+ before.lang === after.lang) {
122
+ const opening = /^([ \t>]*)(`{3,}|~{3,})([^\r\n]*)(\r?\n)/.exec(raw);
123
+ if (opening) {
124
+ const lines = raw.slice(opening[0].length).split(/\r?\n/);
125
+ const closing = /^([ \t>]*)(`{3,}|~{3,})([ \t]*)$/.exec(lines.at(-1) ?? '');
126
+ const originalLine = (before.value ?? '').replace(/\r\n?/g, '\n').split('\n')[0] ?? '';
127
+ const first = lines[0] ?? '';
128
+ const gutter = first.endsWith(originalLine)
129
+ ? first.slice(0, first.length - originalLine.length)
130
+ : opening[1];
131
+ const body = (after.value ?? '').replace(/\r\n?/g, '\n').split('\n');
132
+ const character = opening[2][0];
133
+ let length = opening[2].length;
134
+ for (const line of body) {
135
+ const run = new RegExp(`^ {0,3}(${character}+)[ \\t]*$`).exec(line);
136
+ if (run)
137
+ length = Math.max(length, run[1].length + 1);
138
+ }
139
+ const fence = character.repeat(length);
140
+ return (opening[1] +
141
+ fence +
142
+ opening[3] +
143
+ lineEnding +
144
+ body.map((line) => gutter + line).join(lineEnding) +
145
+ (closing
146
+ ? lineEnding +
147
+ closing[1] +
148
+ character.repeat(Math.max(length, closing[2].length)) +
149
+ closing[3]
150
+ : ''));
151
+ }
152
+ }
118
153
  if (before.type === 'definition' &&
119
154
  after.type === 'definition' &&
120
155
  before.label === after.label &&
@@ -193,7 +228,7 @@ export class PreservationError extends Error {
193
228
  }
194
229
  }
195
230
  function form(text) {
196
- return text.replace(/\n+$/, '');
231
+ return text.replace(/\r\n?/g, '\n').replace(/\n+$/, '');
197
232
  }
198
233
  function blockForm(ctx, node) {
199
234
  // An empty document is empty Markdown, but an empty block inside a document
@@ -219,7 +254,7 @@ function semanticSignature(doc) {
219
254
  continue;
220
255
  const token = {
221
256
  ...child,
222
- text,
257
+ text: text.replace(/\r\n?/g, '\n'),
223
258
  marks: index === 1 ? child.marks : undefined,
224
259
  };
225
260
  const previous = tokens.at(-1);
@@ -242,6 +277,7 @@ function semanticSignature(doc) {
242
277
  'id',
243
278
  'label',
244
279
  'spread',
280
+ 'authoredFence',
245
281
  ].includes(key)
246
282
  ? undefined
247
283
  : value));
@@ -398,16 +434,17 @@ export class Preservation {
398
434
  output += retainSource(block.ast, positionalNode(currentRawTree, currentTree.children[index]), body, canonical, this.lineEnding);
399
435
  }
400
436
  else
401
- output += text.replaceAll('\n', this.lineEnding);
437
+ output += restoreCodeEndings(this.ctx, doc.type.create(null, [doc.child(index)]), text.replaceAll('\n', this.lineEnding));
402
438
  previous = position;
403
439
  });
404
440
  if (previous === this.blocks.length - 1 && this.blocks.length)
405
441
  output += this.source.slice(this.blocks[previous].end);
406
442
  else if (output)
407
- output += this.lineEnding;
443
+ output += finalCodeEnding(doc) ?? this.lineEnding;
408
444
  const parse = this.ctx.get(parserCtx);
409
445
  const reopened = parse(output);
410
- if (serialize(this.ctx, reopened) === canonical &&
446
+ if (serialize(this.ctx, reopened).replace(/\r\n?/g, '\n') ===
447
+ canonical.replace(/\r\n?/g, '\n') &&
411
448
  semanticSignature(reopened) === semanticSignature(doc))
412
449
  return output;
413
450
  throw new PreservationError('The edited Markdown cannot be reopened without changing its content');
package/dist/style.css CHANGED
@@ -652,3 +652,19 @@ body {
652
652
  display: block !important;
653
653
  }
654
654
  }
655
+ /* Diagram previews are presentation; the authored code remains editable. */
656
+ .inkkit-mermaid-preview {
657
+ padding: 16px;
658
+ overflow: auto;
659
+ background: #fff;
660
+ color: #222;
661
+ }
662
+ .inkkit-mermaid-preview svg {
663
+ display: block;
664
+ max-width: 100%;
665
+ height: auto;
666
+ }
667
+ .inkkit-mermaid-preview[data-state='error'] {
668
+ color: #9b2424;
669
+ font-size: 0.9em;
670
+ }
package/dist/types.d.ts CHANGED
@@ -14,7 +14,7 @@ export interface DocumentSnapshot extends DocumentInput {
14
14
  revision: number;
15
15
  dirty: boolean;
16
16
  }
17
- export type EditorErrorCode = 'not-ready' | 'stale-document' | 'composition' | 'operation-pending' | 'destroyed' | 'preservation' | 'image-unavailable';
17
+ export type EditorErrorCode = 'not-ready' | 'stale-document' | 'composition' | 'operation-pending' | 'destroyed' | 'preservation' | 'image-unavailable' | 'diagram-unavailable';
18
18
  export declare class InkKitError extends Error {
19
19
  readonly code: EditorErrorCode;
20
20
  constructor(code: EditorErrorCode, message: string);
@@ -57,4 +57,10 @@ export interface ClipboardOutput {
57
57
  html: string;
58
58
  markdown: string;
59
59
  images: ClipboardImage[];
60
+ diagrams?: ClipboardDiagram[];
61
+ }
62
+ export interface ClipboardDiagram {
63
+ source: string;
64
+ image?: PortableImage;
65
+ error?: string;
60
66
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aicayzer/inkkit",
3
- "version": "0.0.3",
3
+ "version": "0.0.4",
4
4
  "description": "A preserving Markdown editor for offline web views and the web",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -46,6 +46,7 @@
46
46
  },
47
47
  "dependencies": {
48
48
  "@milkdown/kit": "7.22.1",
49
+ "dompurify": "3.4.16",
49
50
  "lowlight": "^3.3.0",
50
51
  "mdast-util-from-markdown": "^2.1.0",
51
52
  "mdast-util-gfm-autolink-literal": "^2.0.1",
@@ -55,6 +56,7 @@
55
56
  "mdast-util-gfm-task-list-item": "^2.0.0",
56
57
  "mdast-util-highlight-mark": "1.2.2",
57
58
  "mdast-util-to-markdown": "2.2.0",
59
+ "mermaid": "12.1.0",
58
60
  "micromark-extension-gfm-autolink-literal": "^2.1.0",
59
61
  "micromark-extension-gfm-footnote": "^2.1.0",
60
62
  "micromark-extension-gfm-strikethrough": "^2.1.0",