@aicayzer/inkkit 0.0.1 → 0.0.2

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/preserve.js CHANGED
@@ -1,5 +1,172 @@
1
1
  import { parserCtx, remarkCtx } from '@milkdown/kit/core';
2
2
  import { serialize } from './dialect.js';
3
+ function signature(node) {
4
+ return JSON.stringify(node, (key, value) => ['position', 'data', 'url', 'title', 'marker'].includes(key)
5
+ ? undefined
6
+ : value);
7
+ }
8
+ function ownShape(node) {
9
+ return JSON.stringify(node, (key, value) => ['position', 'data', 'children', 'value', 'marker'].includes(key)
10
+ ? undefined
11
+ : value);
12
+ }
13
+ function retainText(raw, before, after) {
14
+ before = before.replace(/\r\n?/g, '\n');
15
+ after = after.replace(/\r\n?/g, '\n');
16
+ let prefix = 0;
17
+ while (prefix < before.length &&
18
+ prefix < after.length &&
19
+ before[prefix] === after[prefix])
20
+ prefix++;
21
+ let suffix = 0;
22
+ while (suffix < before.length - prefix &&
23
+ suffix < after.length - prefix &&
24
+ before[before.length - 1 - suffix] === after[after.length - 1 - suffix])
25
+ suffix++;
26
+ const offsets = [0];
27
+ let decoded = '';
28
+ for (let index = 0; index < raw.length;) {
29
+ const escaped = /^\\([!"#$%&'()*+,\-./:;<=>?@[\]\\^_`{|}~])/.exec(raw.slice(index));
30
+ const entity = /^&(?:#[xX][\da-fA-F]+|#\d+|[A-Za-z][A-Za-z\d]+);/.exec(raw.slice(index));
31
+ let value;
32
+ let length;
33
+ if (escaped) {
34
+ value = escaped[1];
35
+ length = escaped[0].length;
36
+ }
37
+ else if (entity) {
38
+ const element = document.createElement('textarea');
39
+ element.innerHTML = entity[0];
40
+ value = element.value;
41
+ length = entity[0].length;
42
+ }
43
+ else if (raw.slice(index, index + 2) === '\r\n') {
44
+ value = '\n';
45
+ length = 2;
46
+ }
47
+ else {
48
+ value = raw[index];
49
+ length = 1;
50
+ }
51
+ decoded += value;
52
+ index += length;
53
+ for (let part = 0; part < value.length; part++)
54
+ offsets.push(index);
55
+ }
56
+ if (decoded !== before)
57
+ return after;
58
+ const inserted = after
59
+ .slice(prefix, after.length - suffix)
60
+ .replace(/[\\`*_[\]<>]/g, '\\$&');
61
+ return (raw.slice(0, offsets[prefix]) +
62
+ inserted +
63
+ raw.slice(offsets[before.length - suffix]));
64
+ }
65
+ function retainBoundaryWhitespace(raw, canonical) {
66
+ // A middle-of-line space becomes indentation or trimmed text at a block edge.
67
+ const token = '(?:[ \\t]|&#(?:[xX]0*(?:20|9)|0*(?:32|9));|&Tab;)';
68
+ const decoded = (value) => (value.match(new RegExp(token, 'g')) ?? [])
69
+ .map((value) => value === '\t' || /(?:9;|Tab;)$/.test(value) ? '\t' : ' ')
70
+ .join('');
71
+ const encode = (value) => value.replace(/[ \t]/g, (value) => (value === '\t' ? '&#x9;' : '&#x20;'));
72
+ const start = /^[ \t]+/.exec(raw)?.[0];
73
+ const rawStart = new RegExp(`^${token}+`).exec(raw)?.[0];
74
+ const encodedStart = new RegExp(`^${token}+`).exec(canonical)?.[0];
75
+ if (start &&
76
+ rawStart &&
77
+ encodedStart?.includes('&') &&
78
+ decoded(rawStart) === decoded(encodedStart))
79
+ raw = encode(start) + raw.slice(start.length);
80
+ const end = /[ \t]+$/.exec(raw)?.[0];
81
+ const rawEnd = new RegExp(`${token}+$`).exec(raw)?.[0];
82
+ const encodedEnd = new RegExp(`${token}+$`).exec(canonical)?.[0];
83
+ if (end &&
84
+ rawEnd &&
85
+ encodedEnd?.includes('&') &&
86
+ decoded(rawEnd) === decoded(encodedEnd))
87
+ raw = raw.slice(0, -end.length) + encode(end);
88
+ return raw;
89
+ }
90
+ // Patch changed leaves so an adjacent edit retains authored delimiters and labels.
91
+ function retainSource(before, after, source, canonical, lineEnding) {
92
+ const start = before.position?.start.offset;
93
+ const end = before.position?.end.offset;
94
+ const newStart = after.position?.start.offset;
95
+ const newEnd = after.position?.end.offset;
96
+ if (start == null || end == null || newStart == null || newEnd == null)
97
+ throw new PreservationError('A source token has no location');
98
+ const raw = source.slice(start, end);
99
+ if (before.type === 'definition' &&
100
+ after.type === 'definition' &&
101
+ before.label === after.label &&
102
+ before.title === after.title) {
103
+ const destination = /^(\[[\s\S]*?\]:[ \t]*)(<[^>\n]*>|[^\s]+)([\s\S]*)$/.exec(raw);
104
+ if (destination)
105
+ return (destination[1] +
106
+ (destination[2].startsWith('<')
107
+ ? `<${after.url}>`
108
+ : String(after.url)) +
109
+ destination[3]);
110
+ }
111
+ if (before.type === 'text' && after.type === 'text')
112
+ return retainBoundaryWhitespace(retainText(raw, before.value, after.value), canonical.slice(newStart, newEnd));
113
+ if (signature(before) === signature(after) &&
114
+ ownShape(before) === ownShape(after))
115
+ return raw;
116
+ if (before.type === after.type &&
117
+ ownShape(before) === ownShape(after) &&
118
+ before.children &&
119
+ after.children &&
120
+ before.children.length === after.children.length) {
121
+ let output = '', previous = start;
122
+ before.children.forEach((child, index) => {
123
+ const childStart = child.position?.start.offset, childEnd = child.position?.end.offset;
124
+ if (childStart == null || childEnd == null)
125
+ throw new PreservationError('A source token has no location');
126
+ output +=
127
+ source.slice(previous, childStart) +
128
+ retainSource(child, after.children[index], source, canonical, lineEnding);
129
+ previous = childEnd;
130
+ });
131
+ return output + source.slice(previous, end);
132
+ }
133
+ if (before.type === after.type &&
134
+ ownShape(before) === ownShape(after) &&
135
+ before.children &&
136
+ after.children) {
137
+ let prefix = 0, suffix = 0;
138
+ while (prefix < before.children.length &&
139
+ prefix < after.children.length &&
140
+ signature(before.children[prefix]) === signature(after.children[prefix]))
141
+ prefix++;
142
+ while (suffix < before.children.length - prefix &&
143
+ suffix < after.children.length - prefix &&
144
+ signature(before.children[before.children.length - 1 - suffix]) ===
145
+ signature(after.children[after.children.length - 1 - suffix]))
146
+ suffix++;
147
+ const from = prefix
148
+ ? before.children[prefix - 1].position?.end.offset
149
+ : start;
150
+ const to = suffix
151
+ ? before.children[before.children.length - suffix].position?.start.offset
152
+ : end;
153
+ const newFrom = prefix
154
+ ? after.children[prefix - 1].position?.end.offset
155
+ : newStart;
156
+ const newTo = suffix
157
+ ? after.children[after.children.length - suffix].position?.start.offset
158
+ : newEnd;
159
+ if (from != null && to != null && newFrom != null && newTo != null)
160
+ return retainBoundaryWhitespace(source.slice(start, from) +
161
+ canonical.slice(newFrom, newTo).replaceAll('\n', lineEnding) +
162
+ source.slice(to, end), canonical.slice(newStart, newEnd));
163
+ }
164
+ return canonical.slice(newStart, newEnd).replaceAll('\n', lineEnding);
165
+ }
166
+ function positionalNode(tree, transformed) {
167
+ return (tree.children.find((node) => node.position?.start.offset === transformed.position?.start.offset &&
168
+ node.position?.end.offset === transformed.position?.end.offset) ?? transformed);
169
+ }
3
170
  export class PreservationError extends Error {
4
171
  constructor(message, options) {
5
172
  super(message, options);
@@ -16,6 +183,50 @@ function blockForm(ctx, node) {
16
183
  return '<br />';
17
184
  return form(serialize(ctx, node.type.schema.topNodeType.create(null, [node])));
18
185
  }
186
+ function semanticSignature(doc) {
187
+ const clean = (value) => {
188
+ const content = value.content;
189
+ if (!content)
190
+ return value;
191
+ const tokens = [];
192
+ for (const child of content) {
193
+ if (child.type !== 'text') {
194
+ tokens.push(clean(child));
195
+ continue;
196
+ }
197
+ const parts = /^(\s*)([\s\S]*?)(\s*)$/.exec(String(child.text));
198
+ for (const [index, text] of parts.slice(1).entries()) {
199
+ if (!text)
200
+ continue;
201
+ const token = {
202
+ ...child,
203
+ text,
204
+ marks: index === 1 ? child.marks : undefined,
205
+ };
206
+ const previous = tokens.at(-1);
207
+ if (previous?.type === 'text' &&
208
+ JSON.stringify(previous.marks) === JSON.stringify(token.marks))
209
+ previous.text = String(previous.text) + text;
210
+ else
211
+ tokens.push(token);
212
+ }
213
+ }
214
+ return { ...value, content: tokens };
215
+ };
216
+ const json = JSON.parse(JSON.stringify(doc.toJSON(), (key, value) =>
217
+ // Fresh list items default to loose even when their Markdown reopens tight.
218
+ [
219
+ 'referenceContent',
220
+ 'referenceType',
221
+ 'marker',
222
+ 'id',
223
+ 'label',
224
+ 'spread',
225
+ ].includes(key)
226
+ ? undefined
227
+ : value));
228
+ return JSON.stringify(clean(json));
229
+ }
19
230
  // Edit distance distinguishes replacements from insertions, so replacing a block
20
231
  // retains its surrounding whitespace without assigning it a neighbour's source.
21
232
  function correspondence(before, after) {
@@ -86,10 +297,11 @@ export class Preservation {
86
297
  const processor = ctx.get(remarkCtx);
87
298
  const offset = source.startsWith('\uFEFF') ? 1 : 0;
88
299
  const body = source.slice(offset);
89
- const tree = processor.runSync(processor.parse(source), {
300
+ const rawTree = processor.parse(source);
301
+ const tree = processor.runSync(structuredClone(rawTree), {
90
302
  value: body,
91
303
  });
92
- for (const node of tree.children) {
304
+ for (const [index, node] of tree.children.entries()) {
93
305
  let start = node.position?.start.offset;
94
306
  const end = node.position?.end.offset;
95
307
  if (start == null || end == null)
@@ -100,13 +312,16 @@ export class Preservation {
100
312
  if (/^[ \t]*$/.test(source.slice(firstColumn, start)))
101
313
  start = firstColumn;
102
314
  const text = source.slice(start, sourceEnd);
103
- const parsed = parse(text);
315
+ const parsed = this.baseline.childCount === tree.children.length
316
+ ? this.baseline.type.create(null, [this.baseline.child(index)])
317
+ : parse(text);
104
318
  this.blocks.push({
105
319
  start,
106
320
  end: sourceEnd,
107
321
  form: parsed.childCount === 1
108
322
  ? blockForm(ctx, parsed.firstChild)
109
323
  : form(serialize(ctx, parsed)),
324
+ ast: positionalNode(rawTree, node),
110
325
  });
111
326
  }
112
327
  }
@@ -135,6 +350,12 @@ export class Preservation {
135
350
  current.push(fingerprint);
136
351
  });
137
352
  const positions = correspondence(this.blocks.map((block) => block.form), current);
353
+ const processor = this.ctx.get(remarkCtx);
354
+ const currentRawTree = processor.parse(canonical);
355
+ const currentTree = processor.runSync(structuredClone(currentRawTree), {
356
+ value: canonical,
357
+ });
358
+ const body = this.source.replace(/^\uFEFF/, '');
138
359
  let output = '';
139
360
  let previous;
140
361
  current.forEach((text, index) => {
@@ -151,10 +372,13 @@ export class Preservation {
151
372
  }
152
373
  else
153
374
  output += this.lineEnding.repeat(2);
154
- output +=
155
- block && block.form === text
156
- ? this.source.slice(block.start, block.end)
157
- : text.replaceAll('\n', this.lineEnding);
375
+ if (block && block.form === text)
376
+ output += this.source.slice(block.start, block.end);
377
+ else if (block && currentTree.children.length === current.length) {
378
+ output += retainSource(block.ast, positionalNode(currentRawTree, currentTree.children[index]), body, canonical, this.lineEnding);
379
+ }
380
+ else
381
+ output += text.replaceAll('\n', this.lineEnding);
158
382
  previous = position;
159
383
  });
160
384
  if (previous === this.blocks.length - 1 && this.blocks.length)
@@ -162,7 +386,9 @@ export class Preservation {
162
386
  else if (output)
163
387
  output += this.lineEnding;
164
388
  const parse = this.ctx.get(parserCtx);
165
- if (serialize(this.ctx, parse(output)) === canonical)
389
+ const reopened = parse(output);
390
+ if (serialize(this.ctx, reopened) === canonical &&
391
+ semanticSignature(reopened) === semanticSignature(doc))
166
392
  return output;
167
393
  throw new PreservationError('The edited Markdown cannot be reopened without changing its content');
168
394
  }
@@ -0,0 +1,8 @@
1
+ import type { Ctx } from '@milkdown/kit/ctx';
2
+ import { Fragment, type Node } from '@milkdown/kit/prose/model';
3
+ export declare function selectionContent(doc: Node, content: Fragment): Fragment;
4
+ export declare function selectionMarkdown(ctx: Ctx, doc: Node, content: Fragment): string;
5
+ export declare function referenceMetadata(ctx: Ctx, doc: Node, content: Fragment): string | undefined;
6
+ export declare function avoidReferenceCollisions(incoming: Node, destination: Node): Node;
7
+ export declare function markdownFromHTML(html: string): string | undefined;
8
+ export declare function withReferenceMetadata(html: string, markdown: string): string;
@@ -0,0 +1,197 @@
1
+ import { Fragment } from '@milkdown/kit/prose/model';
2
+ import { serialize } from './dialect.js';
3
+ import { normaliseLabel } from './references.js';
4
+ function key(node) {
5
+ if (node.type.name === 'reference_definition')
6
+ return `link:${node.attrs.identifier}`;
7
+ if (node.type.name === 'footnote_definition')
8
+ return `footnote:${node.attrs.identifier}`;
9
+ return undefined;
10
+ }
11
+ function used(content) {
12
+ const result = new Set();
13
+ content.descendants((node) => {
14
+ if (node.type.name === 'footnote_reference')
15
+ result.add(`footnote:${node.attrs.identifier}`);
16
+ for (const mark of node.marks)
17
+ if (mark.type.name === 'link' && mark.attrs.identifier)
18
+ result.add(`link:${mark.attrs.identifier}`);
19
+ });
20
+ return result;
21
+ }
22
+ export function selectionContent(doc, content) {
23
+ const definitions = new Map();
24
+ doc.descendants((node) => {
25
+ const id = key(node);
26
+ if (id && !definitions.has(id))
27
+ definitions.set(id, node);
28
+ });
29
+ const required = used(content);
30
+ for (const id of required) {
31
+ const definition = definitions.get(id);
32
+ if (definition)
33
+ for (const dependency of used(definition.content))
34
+ required.add(dependency);
35
+ }
36
+ const complete = (node) => {
37
+ const id = key(node);
38
+ if (id && required.has(id) && definitions.has(id))
39
+ return definitions.get(id);
40
+ if (node.isText)
41
+ return node;
42
+ const children = [];
43
+ node.forEach((child) => children.push(complete(child)));
44
+ return node.copy(Fragment.fromArray(children));
45
+ };
46
+ const selected = [];
47
+ content.forEach((node) => selected.push(complete(node)));
48
+ content = Fragment.fromArray(selected);
49
+ const included = new Set();
50
+ content.descendants((node) => {
51
+ const id = key(node);
52
+ if (id)
53
+ included.add(id);
54
+ });
55
+ const extra = [];
56
+ for (const id of required) {
57
+ if (included.has(id))
58
+ continue;
59
+ const definition = definitions.get(id);
60
+ if (!definition)
61
+ continue;
62
+ included.add(id);
63
+ extra.push(definition);
64
+ for (const dependency of used(definition.content))
65
+ required.add(dependency);
66
+ }
67
+ return content.append(Fragment.fromArray(extra));
68
+ }
69
+ export function selectionMarkdown(ctx, doc, content) {
70
+ return serialize(ctx, doc.type.create(null, selectionContent(doc, content)));
71
+ }
72
+ export function referenceMetadata(ctx, doc, content) {
73
+ let references = false, index = 0;
74
+ const rewrite = (node) => {
75
+ if (key(node) ||
76
+ node.type.name === 'footnote_reference' ||
77
+ node.marks.some((mark) => mark.type.name === 'link' && mark.attrs.identifier))
78
+ references = true;
79
+ if (node.type.name === 'image')
80
+ return node.type.create({ ...node.attrs, src: `inkkit-clipboard-image:${index++}` }, null, node.marks);
81
+ if (node.isText)
82
+ return node;
83
+ const children = [];
84
+ node.forEach((child) => children.push(rewrite(child)));
85
+ return node.copy(Fragment.fromArray(children));
86
+ };
87
+ const valid = content.firstChild?.isInline
88
+ ? Fragment.from(doc.type.schema.nodes.paragraph.create(null, content))
89
+ : content;
90
+ const copied = rewrite(doc.type.create(null, selectionContent(doc, valid)));
91
+ return references ? serialize(ctx, copied) : undefined;
92
+ }
93
+ function allLabels(doc) {
94
+ const labels = new Map([
95
+ ['link', new Set()],
96
+ ['footnote', new Set()],
97
+ ]);
98
+ doc.descendants((node) => {
99
+ if (isCode(node))
100
+ return false;
101
+ const id = key(node);
102
+ if (id) {
103
+ const separator = id.indexOf(':');
104
+ labels.get(id.slice(0, separator)).add(id.slice(separator + 1));
105
+ }
106
+ if (node.type.name === 'footnote_reference')
107
+ labels.get('footnote').add(node.attrs.identifier);
108
+ for (const mark of node.marks)
109
+ if (mark.type.name === 'link' && mark.attrs.identifier)
110
+ labels.get('link').add(mark.attrs.identifier);
111
+ if (node.isText) {
112
+ for (const match of node.text.matchAll(rawReferences))
113
+ labels
114
+ .get(match[1].startsWith('^') ? 'footnote' : 'link')
115
+ .add(normaliseLabel(match[1].startsWith('^')
116
+ ? match[1].slice(1)
117
+ : match[2] || match[1]));
118
+ }
119
+ });
120
+ return labels;
121
+ }
122
+ const rawReferences = /(?<![\\!])\[([^\]]+)\](?:\[([^\]]*)\])?(?!\()/g;
123
+ function isCode(node) {
124
+ return (node.type.name === 'code_block' ||
125
+ node.marks.some((mark) => mark.type.name === 'inlineCode'));
126
+ }
127
+ // Rename only the incoming document. Definitions belong to the insertion's undo transaction.
128
+ export function avoidReferenceCollisions(incoming, destination) {
129
+ const occupied = allLabels(destination);
130
+ const incomingLabels = allLabels(incoming);
131
+ const replacements = new Map();
132
+ for (const [kind, labels] of incomingLabels) {
133
+ const reserved = new Set([...occupied.get(kind), ...labels]);
134
+ for (const identifier of labels) {
135
+ if (!occupied.get(kind).has(identifier))
136
+ continue;
137
+ let suffix = 2;
138
+ while (reserved.has(normaliseLabel(`${identifier}-${suffix}`)))
139
+ suffix++;
140
+ const label = `${identifier}-${suffix}`;
141
+ reserved.add(normaliseLabel(label));
142
+ replacements.set(`${kind}:${identifier}`, label);
143
+ }
144
+ }
145
+ if (!replacements.size)
146
+ return incoming;
147
+ const rewrite = (node) => {
148
+ if (isCode(node))
149
+ return node;
150
+ const kind = node.type.name.startsWith('footnote_') ? 'footnote' : 'link';
151
+ const label = replacements.get(`${kind}:${node.attrs.identifier}`);
152
+ const attrs = label
153
+ ? { ...node.attrs, label, identifier: normaliseLabel(label) }
154
+ : node.attrs;
155
+ const marks = node.marks.map((mark) => {
156
+ const replacement = replacements.get(`link:${mark.attrs.identifier}`);
157
+ return replacement && mark.type.name === 'link'
158
+ ? mark.type.create({
159
+ ...mark.attrs,
160
+ label: replacement,
161
+ identifier: normaliseLabel(replacement),
162
+ referenceType: 'full',
163
+ })
164
+ : mark;
165
+ });
166
+ if (node.isText) {
167
+ const text = node.text.replace(rawReferences, (raw, display, reference) => {
168
+ const footnote = display.startsWith('^')
169
+ ? display.slice(1)
170
+ : undefined;
171
+ const replacement = replacements.get(`${footnote ? 'footnote' : 'link'}:${normaliseLabel(footnote ?? (reference || display))}`);
172
+ return replacement
173
+ ? footnote
174
+ ? `[^${replacement}]`
175
+ : `[${display}][${replacement}]`
176
+ : raw;
177
+ });
178
+ return node.type.schema.text(text, marks);
179
+ }
180
+ const children = [];
181
+ node.forEach((child) => children.push(rewrite(child)));
182
+ return node.type.create(attrs, children, marks);
183
+ };
184
+ return rewrite(incoming);
185
+ }
186
+ export function markdownFromHTML(html) {
187
+ const template = document.createElement('template');
188
+ template.innerHTML = html;
189
+ const element = template.content.querySelector('[data-inkkit-markdown]');
190
+ return element?.getAttribute('data-inkkit-markdown') ?? undefined;
191
+ }
192
+ export function withReferenceMetadata(html, markdown) {
193
+ const container = document.createElement('div');
194
+ container.setAttribute('data-inkkit-markdown', markdown);
195
+ container.innerHTML = html;
196
+ return container.outerHTML;
197
+ }
@@ -0,0 +1,14 @@
1
+ import type { Node as ProseNode } from '@milkdown/kit/prose/model';
2
+ export declare function normaliseLabel(label: string): string;
3
+ export interface LocatedDefinition {
4
+ node: ProseNode;
5
+ pos: number;
6
+ }
7
+ export declare const referenceDefinitions: (doc: ProseNode) => Map<string, LocatedDefinition>;
8
+ export declare const footnoteDefinitions: (doc: ProseNode) => Map<string, LocatedDefinition>;
9
+ export declare const remarkReferencesPlugin: import("@milkdown/kit/utils").$Remark<"inkkitReferences", unknown>;
10
+ export declare const referenceLinks: import("@milkdown/kit/utils").$MarkSchema<"link">;
11
+ export declare const referenceDefinition: import("@milkdown/kit/utils").$Node;
12
+ export declare const footnoteReference: import("@milkdown/kit/utils").$Node;
13
+ export declare const footnoteDefinition: import("@milkdown/kit/utils").$Node;
14
+ export declare const references: (import("@milkdown/kit/utils").$Ctx<import("@milkdown/kit/utils").GetMarkSchema, "link"> | import("@milkdown/kit/utils").$Mark | import("@milkdown/kit/utils").$Node | import("@milkdown/kit/utils").$Prose)[];