@aicayzer/inkkit 0.0.2 → 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.
package/README.md CHANGED
@@ -33,10 +33,10 @@ editor.loadDocument({
33
33
  const snapshot = editor.snapshot(1)
34
34
  ```
35
35
 
36
- Markdown includes CommonMark, task lists, strikethrough, autolinks, GFM tables, editable footnotes and reference-style links. Unsupported constructs remain literal Markdown. TXT mode treats all text literally. Images require a host adapter. RTF files remain outside the package scope.
36
+ Markdown includes CommonMark, task lists, strikethrough, autolinks, GFM tables, editable footnotes and reference-style links, bounded callouts, highlights and author comments. Unsupported constructs remain literal Markdown. TXT mode treats all text literally. Images require a host adapter. RTF files remain outside the package scope.
37
37
 
38
- See [the host API](docs/api.md), [preservation and clipboard behavior](docs/preservation.md), [development](docs/development.md), and [releases](docs/releasing.md).
38
+ See [supported syntax](docs/supported-syntax.md), [the host API](docs/api.md), [preservation and clipboard behavior](docs/preservation.md), [development](docs/development.md), and [releases](docs/releasing.md).
39
39
 
40
- Planned work and release scope are tracked in the [InkKit Project](https://github.com/users/aicayzer/projects/3) and [milestones](https://github.com/aicayzer/inkkit/milestones). Releases 0.0.2 and 0.0.3 are authorised for sequential delivery; targets from 0.0.4 remain provisional.
40
+ Planned work and release scope are tracked in the [InkKit Project](https://github.com/users/aicayzer/projects/3) and [milestones](https://github.com/aicayzer/inkkit/milestones). Release 0.0.2 is published and verified; 0.0.3 delivery is authorised. Targets from 0.0.4 remain provisional.
41
41
 
42
42
  MIT licensed. InkKit builds on the public PadPad and Memos editors; see [NOTICE](NOTICE).
@@ -0,0 +1,24 @@
1
+ import type { Parent, RootContent } from 'mdast';
2
+ interface Callout extends Parent {
3
+ type: 'inkkitCallout';
4
+ kind: string;
5
+ marker: string;
6
+ fold: string;
7
+ title: string;
8
+ titleSource: string;
9
+ listPlaceholder: boolean;
10
+ children: RootContent[];
11
+ }
12
+ declare module 'mdast' {
13
+ interface BlockContentMap {
14
+ inkkitCallout: Callout;
15
+ }
16
+ interface RootContentMap {
17
+ inkkitCallout: Callout;
18
+ }
19
+ }
20
+ export declare const remarkCalloutsPlugin: import("@milkdown/kit/utils").$Remark<"inkkitCallouts", unknown>;
21
+ export declare const calloutSchema: import("@milkdown/kit/utils").$Node;
22
+ export declare const calloutView: import("@milkdown/kit/utils").$Prose;
23
+ export declare const callouts: (import("@milkdown/kit/utils").$Node | import("@milkdown/kit/utils").$Prose)[];
24
+ export {};
@@ -0,0 +1,296 @@
1
+ import { $node, $prose, $remark } from '@milkdown/kit/utils';
2
+ import { remarkCtx } from '@milkdown/kit/core';
3
+ import { Plugin, PluginKey } from '@milkdown/kit/prose/state';
4
+ import { defaultHandlers } from 'mdast-util-to-markdown';
5
+ const kinds = new Set(['NOTE', 'TIP', 'IMPORTANT', 'WARNING', 'CAUTION']);
6
+ function plainHeader(processor, source) {
7
+ const node = processor.parse(source).children[0];
8
+ return node?.type === 'paragraph' &&
9
+ node.children.length === 1 &&
10
+ node.children[0]?.type === 'text'
11
+ ? node.children[0].value
12
+ : undefined;
13
+ }
14
+ const calloutHandler = (untyped, parent, state, info) => {
15
+ const node = untyped;
16
+ const body = defaultHandlers.blockquote({ type: 'blockquote', children: node.children }, parent, state, info);
17
+ const title = node.titleSource ?? node.title;
18
+ return `> ${node.marker}${node.fold}${title ? ` ${title}` : ''}\n${body}`;
19
+ };
20
+ function remarkCallouts() {
21
+ const processor = this;
22
+ const data = this.data();
23
+ (data.toMarkdownExtensions ??= []).push({
24
+ handlers: { inkkitCallout: calloutHandler },
25
+ });
26
+ return (tree, file) => {
27
+ const source = String(file.value).replace(/^\uFEFF/, '');
28
+ const visit = (node, parent, index) => {
29
+ if (node.type === 'blockquote') {
30
+ const start = node.position?.start.offset, end = node.position?.end.offset;
31
+ const raw = start == null || end == null ? '' : source.slice(start, end);
32
+ const header = /^>[ \t]*([\s\S]*?)(?:\r?\n|$)/.exec(raw)?.[1] ?? '';
33
+ if (header.startsWith('[!')) {
34
+ const match = /^(\[!([A-Za-z]+)\])([+-]?)(?:[ \t]+([^\r\n]*))?$/.exec(header);
35
+ const first = node.children[0];
36
+ const decodedHeader = plainHeader(processor, header);
37
+ // A bounded header occupies one unformatted line; other variants stay source.
38
+ if (!match ||
39
+ decodedHeader == null ||
40
+ !kinds.has(match[2].toUpperCase()) ||
41
+ /^[+-](?:[ \t]|[+-])/.test(match[4] ?? '') ||
42
+ first?.type !== 'paragraph' ||
43
+ first.children[0]?.type !== 'text')
44
+ return {
45
+ type: 'inkkitLiteral',
46
+ value: raw,
47
+ position: node.position,
48
+ };
49
+ const text = first.children[0];
50
+ // Entities can decode to newlines without ending the authored header line.
51
+ const ending = /^\r?\n/.exec(text.value.slice(decodedHeader.length));
52
+ const lineEnd = ending ? decodedHeader.length : -1;
53
+ if (lineEnd < 0 && first.children.length > 1)
54
+ return {
55
+ type: 'inkkitLiteral',
56
+ value: raw,
57
+ position: node.position,
58
+ };
59
+ const children = [...node.children];
60
+ if (lineEnd >= 0) {
61
+ const remaining = text.value.slice(lineEnd + ending[0].length);
62
+ const firstStart = text.position?.start.offset;
63
+ const token = firstStart == null
64
+ ? ''
65
+ : source.slice(firstStart, text.position?.end.offset);
66
+ const boundary = /\r?\n[ \t]*>[ \t]?/.exec(token);
67
+ const bodyStart = firstStart == null || !boundary
68
+ ? undefined
69
+ : firstStart + boundary.index + boundary[0].length;
70
+ const point = bodyStart == null
71
+ ? undefined
72
+ : {
73
+ line: source.slice(0, bodyStart).split('\n').length,
74
+ column: bodyStart - source.lastIndexOf('\n', bodyStart - 1),
75
+ offset: bodyStart,
76
+ };
77
+ children[0] = {
78
+ ...first,
79
+ children: [
80
+ ...(remaining
81
+ ? [
82
+ {
83
+ ...text,
84
+ value: remaining,
85
+ position: point && text.position
86
+ ? { start: point, end: text.position.end }
87
+ : undefined,
88
+ },
89
+ ]
90
+ : []),
91
+ ...first.children.slice(1),
92
+ ],
93
+ };
94
+ }
95
+ else
96
+ children.shift();
97
+ if (!children.length)
98
+ children.push({ type: 'paragraph', children: [] });
99
+ return {
100
+ type: 'inkkitCallout',
101
+ kind: match[2].toUpperCase(),
102
+ marker: match[1],
103
+ fold: match[3],
104
+ title: decodedHeader
105
+ .slice(header.length - (match[4]?.length ?? 0))
106
+ .trimEnd(),
107
+ titleSource: match[4]?.trimEnd() ?? '',
108
+ listPlaceholder: parent?.type === 'listItem' && index === 0,
109
+ children: children.map((child) => visit(child)),
110
+ position: node.position,
111
+ };
112
+ }
113
+ }
114
+ if ('children' in node)
115
+ node.children = node.children.map((child, index) => visit(child, node, index));
116
+ return node;
117
+ };
118
+ tree.children = tree.children.map((node) => visit(node));
119
+ };
120
+ }
121
+ export const remarkCalloutsPlugin = $remark('inkkitCallouts', () => remarkCallouts);
122
+ export const calloutSchema = $node('inkkit_callout', (ctx) => ({
123
+ group: 'block',
124
+ content: 'block+',
125
+ defining: true,
126
+ attrs: {
127
+ kind: { default: 'NOTE' },
128
+ marker: { default: '[!NOTE]' },
129
+ fold: { default: '' },
130
+ title: { default: '' },
131
+ titleSource: { default: null },
132
+ listPlaceholder: { default: false },
133
+ },
134
+ parseDOM: [
135
+ {
136
+ tag: 'blockquote[data-inkkit-callout]',
137
+ priority: 100,
138
+ contentElement: '.inkkit-callout-body',
139
+ getAttrs: (dom) => {
140
+ const kind = (dom.getAttribute('data-inkkit-callout') ?? '').toUpperCase();
141
+ const marker = dom.getAttribute('data-inkkit-marker') ?? `[!${kind}]`;
142
+ const fold = dom.getAttribute('data-inkkit-fold') ?? '';
143
+ const titleSource = dom.getAttribute('data-inkkit-title');
144
+ const placeholder = dom.getAttribute('data-inkkit-list-placeholder');
145
+ const previous = dom.previousElementSibling;
146
+ const titleHeader = titleSource == null
147
+ ? undefined
148
+ : plainHeader(ctx.get(remarkCtx), `${marker} ${titleSource}`);
149
+ if (!kinds.has(kind) ||
150
+ !/^\[![A-Za-z]+\]$/.test(marker) ||
151
+ marker.slice(2, -1).toUpperCase() !== kind ||
152
+ !['', '+', '-'].includes(fold) ||
153
+ (titleSource != null &&
154
+ (/[\r\n]/.test(titleSource) || titleHeader == null)))
155
+ return false;
156
+ return {
157
+ kind,
158
+ marker,
159
+ fold,
160
+ title: titleHeader == null
161
+ ? (dom.querySelector('.inkkit-callout-title')?.textContent ?? '')
162
+ : titleHeader.slice(marker.length + 1).trimEnd(),
163
+ titleSource,
164
+ listPlaceholder: placeholder == null
165
+ ? dom.parentElement?.tagName === 'LI' &&
166
+ (!previous ||
167
+ (previous.tagName === 'P' &&
168
+ !previous.textContent &&
169
+ !previous.childElementCount))
170
+ : placeholder === 'true',
171
+ };
172
+ },
173
+ },
174
+ ],
175
+ toDOM: (node) => [
176
+ 'blockquote',
177
+ {
178
+ class: 'inkkit-callout',
179
+ 'data-inkkit-callout': node.attrs.kind,
180
+ 'data-inkkit-marker': node.attrs.marker,
181
+ 'data-inkkit-fold': node.attrs.fold,
182
+ 'data-inkkit-title': node.attrs.titleSource ?? node.attrs.title,
183
+ 'data-inkkit-list-placeholder': String(node.attrs.listPlaceholder),
184
+ },
185
+ [
186
+ 'strong',
187
+ { class: 'inkkit-callout-title' },
188
+ node.attrs.title ||
189
+ String(node.attrs.kind)
190
+ .toLowerCase()
191
+ .replace(/^./, (letter) => letter.toUpperCase()),
192
+ ],
193
+ ['div', { class: 'inkkit-callout-body' }, 0],
194
+ ],
195
+ parseMarkdown: {
196
+ match: (node) => node.type === 'inkkitCallout',
197
+ runner(state, node, type) {
198
+ state.openNode(type, {
199
+ kind: node.kind,
200
+ marker: node.marker,
201
+ fold: node.fold,
202
+ title: node.title,
203
+ titleSource: node.titleSource,
204
+ listPlaceholder: node.listPlaceholder,
205
+ });
206
+ state.next(node.children);
207
+ state.closeNode();
208
+ },
209
+ },
210
+ toMarkdown: {
211
+ match: (node) => node.type.name === 'inkkit_callout',
212
+ runner(state, node) {
213
+ const parent = state.top();
214
+ const first = parent?.children?.[0];
215
+ // Lists require a leading paragraph even when their authored first block is a callout.
216
+ if (node.attrs.listPlaceholder &&
217
+ parent?.type === 'listItem' &&
218
+ parent.children?.length === 1 &&
219
+ first?.type === 'paragraph' &&
220
+ first.children?.length === 1 &&
221
+ first.children[0]?.type === 'html' &&
222
+ first.children[0].value === '<br />')
223
+ parent.children = [];
224
+ state.openNode('inkkitCallout', undefined, { ...node.attrs });
225
+ state.next(node.content);
226
+ state.closeNode();
227
+ },
228
+ },
229
+ }));
230
+ export const calloutView = $prose(() => new Plugin({
231
+ key: new PluginKey('inkkitCalloutView'),
232
+ props: {
233
+ nodeViews: {
234
+ inkkit_callout(node) {
235
+ const dom = document.createElement('blockquote');
236
+ dom.className = 'inkkit-callout';
237
+ dom.setAttribute('data-inkkit-callout', String(node.attrs.kind));
238
+ const header = document.createElement('div');
239
+ header.className = 'inkkit-callout-header';
240
+ header.contentEditable = 'false';
241
+ const title = document.createElement('strong');
242
+ title.className = 'inkkit-callout-title';
243
+ const contentDOM = document.createElement('div');
244
+ contentDOM.className = 'inkkit-callout-body';
245
+ let folded = node.attrs.fold === '-';
246
+ const button = document.createElement('button');
247
+ button.type = 'button';
248
+ button.setAttribute('data-inkkit-callout-toggle', '');
249
+ const render = () => {
250
+ title.textContent =
251
+ node.attrs.title ||
252
+ String(node.attrs.kind)
253
+ .toLowerCase()
254
+ .replace(/^./, (letter) => letter.toUpperCase());
255
+ dom.setAttribute('data-inkkit-folded', String(folded));
256
+ button.hidden = !node.attrs.fold;
257
+ button.setAttribute('aria-expanded', String(!folded));
258
+ button.setAttribute('aria-label', `${folded ? 'Expand' : 'Collapse'} ${title.textContent}`);
259
+ button.textContent = folded ? 'Expand' : 'Collapse';
260
+ };
261
+ const toggle = () => {
262
+ folded = !folded;
263
+ render();
264
+ };
265
+ button.addEventListener('click', toggle);
266
+ button.addEventListener('keydown', (event) => {
267
+ if (event.key === 'Enter' || event.key === ' ') {
268
+ event.preventDefault();
269
+ event.stopPropagation();
270
+ toggle();
271
+ }
272
+ });
273
+ header.append(title, button);
274
+ dom.append(header, contentDOM);
275
+ render();
276
+ return {
277
+ dom,
278
+ contentDOM,
279
+ update(next) {
280
+ if (next.type !== node.type)
281
+ return false;
282
+ if (next.attrs.fold !== node.attrs.fold)
283
+ folded = next.attrs.fold === '-';
284
+ node = next;
285
+ render();
286
+ return true;
287
+ },
288
+ stopEvent: (event) => header.contains(event.target),
289
+ ignoreMutation: (mutation) => mutation.type !== 'selection' &&
290
+ !contentDOM.contains(mutation.target),
291
+ };
292
+ },
293
+ },
294
+ },
295
+ }));
296
+ export const callouts = [calloutSchema, calloutView].flat();
@@ -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
@@ -1,9 +1,12 @@
1
1
  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
+ import { shareableFragment } from './comments.js';
5
+ import { diagramImage } from './mermaid.js';
4
6
  // Clipboard text is the visible document, not Markdown serialization: escaping
5
7
  // punctuation or trailing spaces here leaks source syntax into other apps.
6
8
  export function clipboardText(content, missingImages = new Set()) {
9
+ content = shareableFragment(content);
7
10
  const blocks = [];
8
11
  content.forEach((node) => {
9
12
  if (node.type.name !== 'reference_definition')
@@ -13,6 +16,11 @@ export function clipboardText(content, missingImages = new Set()) {
13
16
  return blocks.join(inline ? '' : '\n\n');
14
17
  }
15
18
  function nodeText(node, missingImages) {
19
+ if (node.type.name === 'inkkit_callout')
20
+ return `${node.attrs.title ||
21
+ String(node.attrs.kind)
22
+ .toLowerCase()
23
+ .replace(/^./, (letter) => letter.toUpperCase())}\n${clipboardText(node.content, missingImages)}`;
16
24
  if (node.type.name === 'reference_definition')
17
25
  return '';
18
26
  if (node.type.name === 'footnote_reference')
@@ -67,6 +75,7 @@ function nodeText(node, missingImages) {
67
75
  return clipboardText(node.content, missingImages);
68
76
  }
69
77
  export function clipboardContent(content, schema) {
78
+ content = shareableFragment(content);
70
79
  const container = document.createElement('div');
71
80
  const serializer = DOMSerializer.fromSchema(schema);
72
81
  // Construct image elements without a source so detached clipboard DOM never
@@ -102,7 +111,8 @@ export const visibleClipboard = $prose(() => new Plugin({
102
111
  },
103
112
  }));
104
113
  /** Export a frozen document fragment; presentation URLs never enter the clipboard. */
105
- export async function portableClipboard(content, schema, markdown, adapter, context) {
114
+ export async function portableClipboard(content, schema, markdown, adapter, context, completeDiagrams) {
115
+ content = shareableFragment(content);
106
116
  const base = clipboardContent(content, schema);
107
117
  const container = document.createElement('div');
108
118
  container.innerHTML = base.html;
@@ -121,6 +131,7 @@ export async function portableClipboard(content, schema, markdown, adapter, cont
121
131
  });
122
132
  await Promise.all(captured.map(async ({ element, reference, alt }, index) => {
123
133
  element.setAttribute('data-inkkit-image-slot', String(index));
134
+ element.setAttribute('data-inkkit-authored-image-slot', String(index));
124
135
  try {
125
136
  if (!adapter)
126
137
  throw new Error('The image is unavailable.');
@@ -143,6 +154,55 @@ export async function portableClipboard(content, schema, markdown, adapter, cont
143
154
  images[index] = { reference, alt, error: message };
144
155
  }
145
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
+ }
146
206
  // Scriptable links and event attributes are not part of semantic rich text.
147
207
  for (const element of container.querySelectorAll('*')) {
148
208
  for (const attribute of [...element.attributes])
@@ -156,6 +216,7 @@ export async function portableClipboard(content, schema, markdown, adapter, cont
156
216
  text: clipboardText(content, missing),
157
217
  html: container.innerHTML,
158
218
  markdown,
159
- images,
219
+ images: [...orderedImages, ...images.filter((image) => image.error)],
220
+ diagrams,
160
221
  };
161
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
+ }