@aicayzer/inkkit 0.0.3 → 0.0.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.
@@ -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');
@@ -0,0 +1,7 @@
1
+ import type { Fragment, Schema } from '@milkdown/kit/prose/model';
2
+ import { type DocumentContext, type ImageAdapter, type PrintableDocument } from './types.js';
3
+ type PrintableContent = Pick<PrintableDocument, 'html' | 'styles' | 'assets' | 'warnings'>;
4
+ export declare function printableText(text: string): PrintableContent;
5
+ export declare function hasAuthoredImagesInLiterals(content: Fragment, parse: (source: string) => unknown): boolean;
6
+ export declare function printableMarkdown(content: Fragment, schema: Schema, adapter: ImageAdapter | undefined, context: DocumentContext): Promise<PrintableContent>;
7
+ export {};