@aicayzer/inkkit 0.0.15 → 0.0.17

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/NOTICE CHANGED
@@ -1,7 +1,3 @@
1
- InkKit includes editor behavior and source from PadPad and Memos.
2
- Copyright (c) 2026 August Cayzer. Licensed under the MIT License.
3
- Sources: https://github.com/aicayzer/padpad and https://github.com/aicayzer/memos
4
-
5
1
  The scoped table styles in src/style.css are adapted from prosemirror-tables.
6
2
  Source: https://github.com/ProseMirror/prosemirror-tables
7
3
 
package/README.md CHANGED
@@ -1,33 +1,31 @@
1
1
  # InkKit
2
2
 
3
- InkKit is a reusable Markdown editor for simple writing tools and richer document apps. It provides formatted editing, source preservation, portable clipboard content and optional host-managed images, while your app controls its appearance, files and interface.
3
+ A Markdown editor for the web and for apps that host a web view. It edits formatted text and keeps your Markdown as you wrote it: a document you open and leave is saved unchanged, and an edit rewrites only the blocks it touches.
4
4
 
5
- Write in formatted or source mode with shared undo/redo. Edit tables, task lists, footnotes, reference links, highlights and callouts. Preview bounded Mermaid diagrams offline. Copy readable text and semantic HTML, explicitly copy Markdown source, or capture a complete printable document. Native hosts can use scoped readable-text ranges and demand-driven geometry with their own search controls and viewport insets. Unsupported syntax remains editable literal Markdown.
5
+ - Formatted and source editing, with one undo history
6
+ - Tables, task lists, footnotes, callouts, highlights and reference links
7
+ - Mermaid diagram previews, offline
8
+ - Copy as readable text or HTML, or copy the Markdown itself
9
+ - Images and files through your own storage adapter
10
+ - Your app owns the toolbar, menus, files and appearance
6
11
 
7
- Host controls, read-only and text-input policies, command availability, scoped appearance, labels and shortcuts are available from 0.0.8. Check the [releases](https://github.com/aicayzer/inkkit/releases) for published versions and verification evidence.
12
+ ## Install
8
13
 
9
14
  ```sh
10
15
  npm install @aicayzer/inkkit
11
16
  ```
12
17
 
18
+ ## Use
19
+
13
20
  ```ts
14
21
  import { InkKitEditor } from '@aicayzer/inkkit'
15
22
  import '@aicayzer/inkkit/style.css'
16
23
 
17
- const root = document.getElementById('editor')!
18
- const editor = await InkKitEditor.mount(root, {
19
- changed(text, generation) {
20
- // Notify the host; capture a fresh snapshot when saving.
21
- },
22
- stateChanged(state) {
23
- // Update the host's formatting controls.
24
- },
25
- openLink(href) {
26
- // Open through the host.
27
- },
28
- copy(text) {
29
- // Write code-block text to the clipboard.
30
- },
24
+ const editor = await InkKitEditor.mount(document.getElementById('editor')!, {
25
+ changed(markdown, generation) {},
26
+ stateChanged(state) {},
27
+ openLink(href) {},
28
+ copy(text) {},
31
29
  })
32
30
 
33
31
  editor.loadDocument({
@@ -36,17 +34,22 @@ editor.loadDocument({
36
34
  format: 'md',
37
35
  text: '# Hello\n\nStart writing.\n',
38
36
  })
39
- const snapshot = editor.snapshot(1)
37
+
38
+ const { text } = editor.snapshot(1)
40
39
  ```
41
40
 
42
- InkKit runs on the web and in offline web views. Hosts use its public TypeScript facade and own toolbars, menus, persistence, clipboard bridges, Print/PDF actions and image storage. Images require an adapter; private references stay opaque. TXT stays literal. RTF document editing is outside current support.
41
+ ## Built with InkKit
43
42
 
44
- Start with the [feature guide](docs/supported-syntax.md) and [simple and rich integration examples](docs/integration.md). The [documentation index](docs/README.md) links to precise API, preservation, playground, verification and publication guidance.
43
+ - [Memos](https://github.com/aicayzer/memos): a small Mac app for memos, in one floating window
44
+ - [PadPad](https://github.com/aicayzer/padpad): a small Mac app for quick text and files
45
45
 
46
- Scope and release status live in the [InkKit Project](https://github.com/users/aicayzer/projects/3) and [version milestones](https://github.com/aicayzer/inkkit/milestones). Only documented, released features form the supported package contract.
46
+ ## Learn more
47
47
 
48
- MIT licensed. InkKit builds on the public PadPad and Memos editors; see [NOTICE](NOTICE).
48
+ - [Supported syntax](docs/supported-syntax.md)
49
+ - [Integration guide](docs/integration.md)
50
+ - [API reference](docs/api.md)
51
+ - [All documentation](docs/README.md)
49
52
 
50
- Optional wiki links and a host-owned asynchronous file adapter support named/path images, audio, video, PDFs and file cards. Authored source and sizing are preserved; portable copy/print uses validated images or documented descriptive fallbacks. See [linked syntax](docs/supported-syntax.md#linked-syntax) and [adapter contracts](docs/api.md#optional-wiki-links-and-files).
53
+ ## License
51
54
 
52
- From 0.0.11, minimal hosts can mount with `rendering: { codeHighlighting: false, diagramPreview: false }` to skip optional presentation work. Source, shared history and explicit portable exports retain their contracts. Mermaid loads on first actual render. Single-file offline bundles still contain the rendering code; see [performance measurements](docs/performance.md).
55
+ [MIT](LICENSE). Third-party notices are in [NOTICE](NOTICE).
package/dist/dialect.js CHANGED
@@ -99,6 +99,16 @@ function remarkDialect() {
99
99
  ? value.replace(/^[ \t]+|[ \t]+$/g, (spaces) => spaces.replaceAll(' ', ' ').replaceAll('\t', '	'))
100
100
  : value;
101
101
  },
102
+ thematicBreak(node, parent, state) {
103
+ const value = defaultHandlers.thematicBreak(node, parent, state);
104
+ // A document opening with `---` reads back as front matter when
105
+ // another `---` line follows.
106
+ return parent?.type === 'root' &&
107
+ parent.children.length > 1 &&
108
+ parent.children[0] === node
109
+ ? value.replaceAll('-', '*')
110
+ : value;
111
+ },
102
112
  },
103
113
  });
104
114
  add('toMarkdownExtensions', { handlers: { link, code: fencedCodeHandler } });
package/dist/highlight.js CHANGED
@@ -5,41 +5,128 @@ import { $prose } from '@milkdown/kit/utils';
5
5
  const lowlight = createLowlight(common);
6
6
  // Colors are decorations over the plain text, so the markdown is untouched;
7
7
  // a block without a known language stays plain.
8
+ function blockDecorations(node, pos) {
9
+ const language = String(node.attrs.language ?? '').toLowerCase();
10
+ const found = [];
11
+ if (!language || !lowlight.registered(language))
12
+ return found;
13
+ let offset = pos + 1;
14
+ const walk = (children, classes) => {
15
+ for (const child of children) {
16
+ if (child.type === 'text') {
17
+ const end = offset + child.value.length;
18
+ if (classes.length > 0)
19
+ found.push(Decoration.inline(offset, end, { class: classes.join(' ') }));
20
+ offset = end;
21
+ }
22
+ else if (child.type === 'element') {
23
+ const own = child.properties?.className ?? [];
24
+ walk(child.children, [...classes, ...own]);
25
+ }
26
+ }
27
+ };
28
+ walk(lowlight.highlight(language, node.textContent).children, []);
29
+ return found;
30
+ }
8
31
  function decorations(doc) {
9
32
  const found = [];
10
33
  doc.descendants((node, pos) => {
11
- if (node.type.name !== 'code_block')
34
+ if (!node.isTextblock)
12
35
  return true;
13
- const language = String(node.attrs.language ?? '').toLowerCase();
14
- if (!language || !lowlight.registered(language))
15
- return false;
16
- let offset = pos + 1;
17
- const walk = (children, classes) => {
18
- for (const child of children) {
19
- if (child.type === 'text') {
20
- const end = offset + child.value.length;
21
- if (classes.length > 0)
22
- found.push(Decoration.inline(offset, end, { class: classes.join(' ') }));
23
- offset = end;
24
- }
25
- else if (child.type === 'element') {
26
- const own = child.properties?.className ?? [];
27
- walk(child.children, [...classes, ...own]);
28
- }
29
- }
30
- };
31
- walk(lowlight.highlight(language, node.textContent).children, []);
36
+ if (node.type.name === 'code_block')
37
+ for (const decoration of blockDecorations(node, pos))
38
+ found.push(decoration);
32
39
  return false;
33
40
  });
34
41
  return DecorationSet.create(doc, found);
35
42
  }
43
+ // Every range of the new document an update may have touched: each step's own
44
+ // range, plus the whole span where the two documents differ, which also covers
45
+ // steps that change a node without changing its size.
46
+ function touched(tr) {
47
+ const ranges = [];
48
+ tr.mapping.maps.forEach((map, index) => {
49
+ const rest = tr.mapping.slice(index + 1);
50
+ map.forEach((_, __, from, to) => ranges.push([rest.map(from, -1), rest.map(to, 1)]));
51
+ });
52
+ const start = tr.before.content.findDiffStart(tr.doc.content);
53
+ if (start != null) {
54
+ const end = tr.before.content.findDiffEnd(tr.doc.content);
55
+ // The scans overlap when inserted or deleted text repeats its neighbours.
56
+ if (end)
57
+ ranges.push([start, end.b + Math.max(0, start - Math.min(end.a, end.b))]);
58
+ }
59
+ return ranges;
60
+ }
61
+ // Mapping carries colors of unchanged blocks to their new positions without
62
+ // reading them, but leaves stale colors inside any block that was edited. Those
63
+ // blocks are highlighted again; no other block is. Stale colors are dropped by
64
+ // rebuilding the set: DecorationSet.remove compares every removed decoration
65
+ // with every other in its block, which is quadratic in a large block.
66
+ function update(mapped, tr) {
67
+ const stale = [];
68
+ const fresh = [];
69
+ const seen = new Set();
70
+ for (const [from, to] of touched(tr)) {
71
+ let start = from;
72
+ let end = to;
73
+ const blocks = [];
74
+ tr.doc.nodesBetween(from, to, (node, pos) => {
75
+ if (!node.isTextblock)
76
+ return true;
77
+ start = Math.min(start, pos);
78
+ end = Math.max(end, pos + node.nodeSize);
79
+ if (node.type.name === 'code_block' && !seen.has(pos)) {
80
+ seen.add(pos);
81
+ blocks.push([node, pos]);
82
+ }
83
+ return false;
84
+ });
85
+ stale.push([start, end]);
86
+ for (const [node, pos] of blocks)
87
+ for (const decoration of blockDecorations(node, pos))
88
+ fresh.push(decoration);
89
+ }
90
+ const found = [];
91
+ for (const [start, end] of stale)
92
+ for (const decoration of mapped.find(start, end))
93
+ found.push(decoration);
94
+ if (found.length === 0)
95
+ return mapped.add(tr.doc, fresh);
96
+ // remove is quadratic in the decorations it removes, so only a large block takes the rebuild below.
97
+ if (found.length <= 256)
98
+ return mapped.remove(found).add(tr.doc, fresh);
99
+ stale.sort((a, b) => a[0] - b[0]);
100
+ const merged = [];
101
+ for (const range of stale) {
102
+ const last = merged.at(-1);
103
+ if (last && range[0] <= last[1])
104
+ last[1] = Math.max(last[1], range[1]);
105
+ else
106
+ merged.push([range[0], range[1]]);
107
+ }
108
+ const kept = mapped.find().filter((decoration) => {
109
+ let low = 0;
110
+ let high = merged.length;
111
+ while (low < high) {
112
+ const middle = (low + high) >> 1;
113
+ if (merged[middle][0] < decoration.to)
114
+ low = middle + 1;
115
+ else
116
+ high = middle;
117
+ }
118
+ const range = merged[low - 1];
119
+ return !range || range[1] <= decoration.from;
120
+ });
121
+ return DecorationSet.create(tr.doc, kept.concat(fresh));
122
+ }
36
123
  export const highlightPlugin = $prose(() => new Plugin({
37
124
  key: new PluginKey('highlight'),
38
125
  state: {
39
126
  init: (_, state) => decorations(state.doc),
40
127
  apply: (tr, previous) => tr.docChanged
41
- ? decorations(tr.doc)
42
- : previous.map(tr.mapping, tr.doc),
128
+ ? update(previous.map(tr.mapping, tr.doc), tr)
129
+ : previous,
43
130
  },
44
131
  props: {
45
132
  decorations(state) {
package/dist/mermaid.js CHANGED
@@ -7,15 +7,107 @@ import { InkKitError } from './types.js';
7
7
  export function isMermaid(node) {
8
8
  return node.type.name === 'code_block' && node.attrs.language === 'mermaid';
9
9
  }
10
+ const refusal = 'HTML, styles, actions, resources and diagram configuration are disabled. The source is retained.';
11
+ // CSS functions that can load an image or display another element. Mermaid's
12
+ // own styles use none of them apart from url() for in-document markers.
13
+ const cssResource = /url\s*\(\s*[^#\s]|(?:image-set|cross-fade|image|element|src)\s*\(/i;
10
14
  export function validateDiagram(source) {
11
15
  if (source.length > 30000)
12
16
  throw new Error('The diagram exceeds the 30,000 character rendering limit.');
13
17
  if (!/^\s*(?:%%[^\n]*\n\s*)*(?:flowchart|graph|sequenceDiagram|classDiagram|stateDiagram(?:-v2)?|erDiagram|pie)\b/.test(source))
14
18
  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
+ // Mermaid can load image bytes before sanitising its result, so resource and
20
+ // configuration syntax is refused anywhere in the source, labels included.
21
+ // Statements are checked separately, in Mermaid's parse result.
22
+ if (/%%\{|^\s*---|<\s*[a-z!/?]|&(?:#\w+|[a-z]+);|@\{|\$\$|(?:https?|data|file|javascript|vbscript|blob):|\/\/|url\s*\(|image-set\s*\(|cross-fade\s*\(|@import|\\/im.test(source))
23
+ throw new Error(refusal);
24
+ }
25
+ const entries = (value) => value instanceof Map ? [...value.values()] : Array.isArray(value) ? value : [];
26
+ const filled = (value) => value instanceof Map
27
+ ? value.size > 0
28
+ : Array.isArray(value)
29
+ ? value.length > 0
30
+ : typeof value === 'object' && value !== null
31
+ ? Object.keys(value).length > 0
32
+ : Boolean(value);
33
+ // In strict mode a flowchart records a click handler only as this class.
34
+ const clickable = (classes) => (Array.isArray(classes) ? classes : String(classes ?? '').split(/\s+/))
35
+ .map(String)
36
+ .includes('clickable');
37
+ function statementIn(node, kinds, seen = new Set()) {
38
+ if (typeof node !== 'object' || node === null || seen.has(node))
39
+ return false;
40
+ seen.add(node);
41
+ if (kinds.includes(String(node.stmt)))
42
+ return true;
43
+ return Object.values(node).some((value) => statementIn(value, kinds, seen));
44
+ }
45
+ // Refuses styles, class definitions, link styles, links, click handlers,
46
+ // tooltips, icons and images wherever the grammar accepted them. The names are
47
+ // those of the pinned Mermaid's diagram databases; if one is missing, the
48
+ // diagram is refused rather than passed unchecked.
49
+ function refuseDirectives({ type, db }) {
50
+ const store = db;
51
+ const field = (name) => {
52
+ if (!(name in store))
53
+ throw new Error('The diagram could not be checked. The source is retained.');
54
+ return store[name];
55
+ };
56
+ const read = (name) => {
57
+ const accessor = field(name);
58
+ if (typeof accessor !== 'function')
59
+ throw new Error('The diagram could not be checked. The source is retained.');
60
+ return accessor.call(db);
61
+ };
62
+ let found;
63
+ switch (type) {
64
+ case 'flowchart-v2': {
65
+ const edges = read('getEdges');
66
+ found =
67
+ filled(read('getClasses')) ||
68
+ filled(field('tooltips')) ||
69
+ filled(edges.defaultStyle) ||
70
+ edges.defaultInterpolate !== undefined ||
71
+ entries(read('getVertices')).some((vertex) => filled(vertex.styles) ||
72
+ Boolean(vertex.link || vertex.haveCallback || vertex.icon || vertex.img) ||
73
+ clickable(vertex.classes)) ||
74
+ entries(edges).some((edge) => filled(edge.style) ||
75
+ edge.interpolate !== undefined ||
76
+ clickable(edge.classes)) ||
77
+ entries(read('getSubGraphs')).some((graph) => clickable(graph.classes));
78
+ break;
79
+ }
80
+ case 'stateDiagram':
81
+ found =
82
+ statementIn(field('rootDoc'), ['style', 'classDef', 'click']) ||
83
+ filled(read('getClasses')) ||
84
+ filled(read('getLinks')) ||
85
+ entries(read('getStates')).some((state) => filled(state.styles) || filled(state.textStyles));
86
+ break;
87
+ case 'classDiagram':
88
+ found =
89
+ filled(field('styleClasses')) ||
90
+ entries(read('getClasses')).some((node) => filled(node.styles) ||
91
+ Boolean(node.link || node.haveCallback || node.tooltip) ||
92
+ clickable(node.cssClasses));
93
+ break;
94
+ case 'er':
95
+ found =
96
+ filled(read('getClasses')) ||
97
+ entries(read('getEntities')).some((entity) => filled(entity.cssStyles)) ||
98
+ entries(read('getSubGraphs')).some((graph) => filled(graph.cssStyles));
99
+ break;
100
+ case 'sequence':
101
+ found = entries(read('getActors')).some((actor) => filled(actor.links) || filled(actor.properties));
102
+ break;
103
+ case 'pie':
104
+ found = false;
105
+ break;
106
+ default:
107
+ throw new Error('This diagram type is not supported. The source is retained.');
108
+ }
109
+ if (found)
110
+ throw new Error(refusal);
19
111
  }
20
112
  let sequence = 0;
21
113
  let queue = Promise.resolve();
@@ -34,11 +126,13 @@ export function safeDiagramSVG(svg) {
34
126
  for (const element of [root, ...root.querySelectorAll('*')]) {
35
127
  for (const attribute of [...element.attributes]) {
36
128
  if (/^on/i.test(attribute.name) ||
37
- /(?:https?|data|file|javascript|blob):|@import|url\s*\(\s*[^#]|\\/i.test(attribute.value))
129
+ /(?:https?|data|file|javascript|blob):|@import|\\/i.test(attribute.value) ||
130
+ cssResource.test(attribute.value))
38
131
  element.removeAttribute(attribute.name);
39
132
  }
40
133
  if (element.tagName.toLowerCase() === 'style' &&
41
- /@import|(?:https?|data|file):|url\s*\(\s*[^#]|\\/i.test(element.textContent ?? ''))
134
+ (/@import|(?:https?|data|file):|\\/i.test(element.textContent ?? '') ||
135
+ cssResource.test(element.textContent ?? '')))
42
136
  element.remove();
43
137
  }
44
138
  root.setAttribute('xmlns', 'http://www.w3.org/2000/svg');
@@ -98,6 +192,14 @@ export function renderDiagram(source) {
98
192
  'suppressErrorRendering',
99
193
  ],
100
194
  });
195
+ // getDiagramFromText is the pinned Mermaid's own parser, which render runs
196
+ // again on the same text; the public parse does not return the diagram. It
197
+ // skips render's configuration step, so the two parse alike only because
198
+ // the source check refuses %%{ directives and front matter.
199
+ refuseDirectives(await mermaid.mermaidAPI.getDiagramFromText(source));
200
+ // Mermaid measures text while laying out, so the holder must be attached
201
+ // and rendered. Such a holder can load CSS resources before sanitising,
202
+ // which is why the source and the parse result are checked first.
101
203
  const holder = document.createElement('div');
102
204
  holder.style.cssText =
103
205
  'position:fixed;left:-100000px;top:0;width:1200px;pointer-events:none';
@@ -0,0 +1,23 @@
1
+ export declare function isRichElement(element: Element): boolean;
2
+ /** Whether normalisation enters the element, so its content may be rewritten. */
3
+ export declare function isRewrittenElement(element: Element): boolean;
4
+ /**
5
+ * Whether the element's own inline style makes a browser keep the whitespace
6
+ * of its content, as copied editor code does with `white-space: pre`. The
7
+ * declarations are parsed, so case, spacing, comments, quoted text and
8
+ * `!important` do not matter. Style sheets are not consulted.
9
+ */
10
+ export declare function keepsWhitespace(element: Element): boolean;
11
+ /**
12
+ * Rewrites common web-page elements to ones the importer already understands.
13
+ * Elements that will stay literal source are not entered, so their content is
14
+ * preserved exactly.
15
+ */
16
+ export declare function normalizeClipboardHTML(root: Node): void;
17
+ /**
18
+ * Collapses source whitespace the way a browser renders it: runs become one
19
+ * space, and none remains at the edges of a block or beside a line break.
20
+ * `pre`, `code`, elements that stay literal source and elements whose style
21
+ * keeps whitespace are not entered.
22
+ */
23
+ export declare function collapseClipboardWhitespace(root: Node): void;