@atom-forge/brickee 0.0.2 → 0.0.3
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/CHANGELOG.md +40 -0
- package/README-AI.md +10 -7
- package/dist/Editor.svelte +691 -833
- package/dist/LinkPrompt.svelte +1 -14
- package/dist/Preview.svelte +27 -54
- package/dist/Preview.svelte.d.ts +2 -4
- package/dist/PropsBar.svelte +35 -219
- package/dist/code.js +1 -1
- package/dist/dom.d.ts +8 -0
- package/dist/dom.js +9 -0
- package/dist/editor/BlockMenu.svelte +102 -0
- package/dist/editor/BlockMenu.svelte.d.ts +32 -0
- package/dist/editor/FormatBar.svelte +41 -0
- package/dist/editor/FormatBar.svelte.d.ts +16 -0
- package/dist/editor/drag.svelte.d.ts +63 -0
- package/dist/editor/drag.svelte.js +95 -0
- package/dist/editor/history.d.ts +24 -0
- package/dist/editor/history.js +86 -0
- package/dist/editor/sweep.svelte.d.ts +21 -0
- package/dist/editor/sweep.svelte.js +56 -0
- package/dist/html.d.ts +12 -0
- package/dist/html.js +206 -0
- package/dist/i18n.js +1 -1
- package/dist/index.d.ts +2 -1
- package/dist/index.js +3 -2
- package/dist/kit/Empty.svelte +4 -15
- package/dist/kit/Frame.svelte +7 -37
- package/dist/locales/hu.js +4 -1
- package/dist/markdown.d.ts +20 -4
- package/dist/markdown.js +380 -85
- package/dist/model.js +2 -3
- package/dist/plugins/Gap.svelte +5 -8
- package/dist/plugins/GapPreview.svelte +4 -6
- package/dist/plugins/Map.svelte +2 -17
- package/dist/plugins/MapPreview.svelte +8 -10
- package/dist/plugins/Table.svelte +233 -256
- package/dist/plugins/TablePreview.svelte +28 -28
- package/dist/plugins/YouTube.svelte +3 -31
- package/dist/plugins/YouTubePreview.svelte +45 -43
- package/dist/plugins/index.js +3 -1
- package/dist/types.d.ts +21 -3
- package/dist/types.js +8 -1
- package/dist/view.js +1 -1
- package/docs/en/bundled-plugins.md +1 -1
- package/docs/en/documents-and-markdown.md +20 -14
- package/docs/en/editor-and-preview.md +6 -7
- package/docs/en/getting-started.md +8 -0
- package/docs/en/index.md +1 -1
- package/docs/en/recipes-and-pitfalls.md +2 -2
- package/docs/en/server-side.md +0 -17
- package/docs/en/upgrading.md +14 -0
- package/docs/en/writing-plugins.md +36 -10
- package/docs/hu/bundled-plugins.md +1 -1
- package/docs/hu/documents-and-markdown.md +20 -14
- package/docs/hu/editor-and-preview.md +6 -7
- package/docs/hu/getting-started.md +8 -0
- package/docs/hu/index.md +1 -1
- package/docs/hu/recipes-and-pitfalls.md +2 -2
- package/docs/hu/server-side.md +0 -17
- package/docs/hu/upgrading.md +14 -0
- package/docs/hu/writing-plugins.md +36 -10
- package/package.json +9 -1
package/dist/markdown.js
CHANGED
|
@@ -1,35 +1,133 @@
|
|
|
1
|
-
import { MAX_INDENT, MD, newBlock, numbering } from './types';
|
|
1
|
+
import { MAX_INDENT, MD, newBlock, numbering, olStart } from './types';
|
|
2
2
|
const esc = (s) => s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
3
|
-
/** Characters escaped in plain text so they never turn into inline markup. */
|
|
3
|
+
/** Characters escaped in plain text so they never turn into inline markup (`_` only at a word's edge, see walk()). */
|
|
4
4
|
const SPECIAL = /[\\`*~[\]<]/g;
|
|
5
|
+
/** A backslash escape: any ASCII punctuation (CommonMark). */
|
|
6
|
+
const ESCAPE = /\\([!-/:-@[-`{-~])/g;
|
|
5
7
|
const MARK = '\u0000';
|
|
8
|
+
const STASH = '\u0001';
|
|
9
|
+
/** Link targets kept as links: http(s), mailto, tel and site-relative paths; anything else stays text. */
|
|
10
|
+
const SAFE_URL = /^(?:https?:|mailto:|tel:|\/(?!\/))/i;
|
|
11
|
+
/** A link target: no spaces, parentheses only balanced (`…/Foo_(bar)`), then an optional title that is dropped. */
|
|
12
|
+
const DEST = String.raw `((?:[^()\s]|\([^()\s]{0,200}\)){1,2000})(?:\s+(?:"[^"]{0,500}"|'[^']{0,500}'|\([^()]{0,500}\)))?`;
|
|
13
|
+
/** A link or an image: the label has no brackets of its own and is bounded, so a run of `[` stays linear. */
|
|
14
|
+
const LINK = new RegExp(String.raw `(!?)\[([^\[\]]{0,1000})\]\(\s*${DEST}\s*\)`, 'g');
|
|
15
|
+
/** Word characters, for the edges of `_` emphasis (no intraword `_`, as in `snake_case`). */
|
|
16
|
+
const W = String.raw `\p{L}\p{N}_`;
|
|
17
|
+
/** The marks; a marked span is at most 2000 characters, so an unclosed `**` costs a bounded scan. */
|
|
18
|
+
const EMPHASIS = [
|
|
19
|
+
[/\*\*\*(?!\s)(.{1,2000}?)(?<!\s)\*\*\*/g, '<b><i>$1</i></b>'],
|
|
20
|
+
[new RegExp(String.raw `(?<![${W}])___(?!\s)(.{1,2000}?)(?<!\s)___(?![${W}])`, 'gu'), '<b><i>$1</i></b>'],
|
|
21
|
+
[/\*\*(?!\s)(.{1,2000}?)(?<!\s)\*\*/g, '<b>$1</b>'],
|
|
22
|
+
[new RegExp(String.raw `(?<![${W}])__(?!\s)(.{1,2000}?)(?<!\s)__(?![${W}])`, 'gu'), '<b>$1</b>'],
|
|
23
|
+
[/(?<!\*)\*(?![\s*])(.{1,2000}?)(?<![\s*])\*(?!\*)/g, '<i>$1</i>'],
|
|
24
|
+
[new RegExp(String.raw `(?<![${W}])_(?![\s_])(.{1,2000}?)(?<![\s_])_(?![${W}])`, 'gu'), '<i>$1</i>'],
|
|
25
|
+
[/~~(?!\s)(.{1,2000}?)(?<!\s)~~/g, '<strike>$1</strike>']
|
|
26
|
+
];
|
|
27
|
+
/** Inline HTML that maps onto the marks; any other tag is dropped (its text stays). */
|
|
28
|
+
const HTML_MARKS = { b: 'b', strong: 'b', i: 'i', em: 'i', s: 'strike', del: 'strike', strike: 'strike', u: 'u', sub: 'sub', sup: 'sup', code: 'code' };
|
|
29
|
+
/**
|
|
30
|
+
* One HTML tag in escaped text (opening, closing or self-closing), never a pair: its name and its attributes are
|
|
31
|
+
* bounded and cannot run past a `<` or `>`, so a failed match costs a bounded amount and the whole scan stays linear.
|
|
32
|
+
*/
|
|
33
|
+
const TAG = /<(\/?)([a-z][\w-]{0,30})(?:\s(?:[^&]|&(?!lt;|gt;)){0,500})?>/gi;
|
|
34
|
+
/**
|
|
35
|
+
* The replacer for TAG: pairs the marks with a stack, so the output is always balanced — a closing tag closes the
|
|
36
|
+
* marks opened inside it too, a stray closing tag is dropped, and the marks still open at the end are closed.
|
|
37
|
+
*/
|
|
38
|
+
function htmlTag(stash) {
|
|
39
|
+
const open = [];
|
|
40
|
+
const replace = (_, closing, name) => {
|
|
41
|
+
const tag = name.toLowerCase();
|
|
42
|
+
if (tag === 'br')
|
|
43
|
+
return stash('<br>');
|
|
44
|
+
const mark = HTML_MARKS[tag];
|
|
45
|
+
if (!mark)
|
|
46
|
+
return '';
|
|
47
|
+
if (!closing) {
|
|
48
|
+
open.push(mark);
|
|
49
|
+
return stash(`<${mark}>`);
|
|
50
|
+
}
|
|
51
|
+
const at = open.lastIndexOf(mark);
|
|
52
|
+
return at < 0 ? '' : open.splice(at).reverse().map((m) => stash(`</${m}>`)).join('');
|
|
53
|
+
};
|
|
54
|
+
/** The closing tags of the marks left open. */
|
|
55
|
+
replace.rest = () => open.splice(0).reverse().map((m) => stash(`</${m}>`)).join('');
|
|
56
|
+
return replace;
|
|
57
|
+
}
|
|
58
|
+
/** Code spans: a run of backticks up to the next run of the same length (`` a`b ``); an unmatched run is text. */
|
|
59
|
+
function codeSpans(s) {
|
|
60
|
+
const out = [];
|
|
61
|
+
const runs = /`+/g;
|
|
62
|
+
let last = 0;
|
|
63
|
+
for (let m = runs.exec(s); m; m = runs.exec(s)) {
|
|
64
|
+
const close = new RegExp(`(?<!\`)${m[0]}(?!\`)`, 'g');
|
|
65
|
+
close.lastIndex = m.index + m[0].length;
|
|
66
|
+
const c = close.exec(s);
|
|
67
|
+
if (!c)
|
|
68
|
+
continue;
|
|
69
|
+
let body = s.slice(m.index + m[0].length, c.index).replace(/\n/g, ' ');
|
|
70
|
+
if (body.length > 2 && body[0] === ' ' && body.at(-1) === ' ' && body.trim())
|
|
71
|
+
body = body.slice(1, -1);
|
|
72
|
+
out.push({ code: false, text: s.slice(last, m.index) }, { code: true, text: body });
|
|
73
|
+
last = runs.lastIndex = c.index + m[0].length;
|
|
74
|
+
}
|
|
75
|
+
out.push({ code: false, text: s.slice(last) });
|
|
76
|
+
return out;
|
|
77
|
+
}
|
|
6
78
|
/** Inline markdown -> inline html (b, i, u, sub, sup, strike, code, a, br). Backslash escapes are honoured; links are
|
|
7
|
-
* http(s), mailto, tel or site-relative only.
|
|
79
|
+
* http(s), mailto, tel or site-relative only. Images become links (the package has no notion of images), footnote
|
|
80
|
+
* references superscripts, and inline HTML its marks (other tags are dropped). */
|
|
8
81
|
export function inline(md) {
|
|
9
82
|
const kept = [];
|
|
10
|
-
const guarded = md.replace(
|
|
83
|
+
const guarded = md.replace(ESCAPE, (_, c) => `${MARK}${kept.push(c) - 1}${MARK}`);
|
|
11
84
|
const restore = (s, raw) => s.replace(/\u0000(\d+)\u0000/g, (_, n) => (raw ? '\\' : '') + esc(kept[+n]));
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
const
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
.
|
|
85
|
+
// Generated tags (code spans, links…) are stashed while the rest runs, so a `_` or `*` in a link target or in code
|
|
86
|
+
// stays as it is, and emphasis can wrap a code span (**`x`**).
|
|
87
|
+
const tags = [];
|
|
88
|
+
const stash = (tag) => `${STASH}${tags.push(tag) - 1}${STASH}`;
|
|
89
|
+
const link = (url, text) => {
|
|
90
|
+
const href = url.replace(/&/g, '&');
|
|
91
|
+
return SAFE_URL.test(href) ? stash(`<a href="${esc(href).replace(/"/g, '"')}">`) + (text || esc(href)) + stash('</a>') : null;
|
|
92
|
+
};
|
|
93
|
+
const htmlTags = htmlTag(stash);
|
|
94
|
+
let html = codeSpans(guarded)
|
|
95
|
+
.map(({ code, text }) => (code ? stash(`<code>${restore(esc(text), true)}</code>`) : esc(text)))
|
|
96
|
+
.join('')
|
|
97
|
+
.replace(LINK, (all, bang, label, url) => link(url, bang && !label ? '' : label) ?? all)
|
|
98
|
+
.replace(/<((?:https?:\/\/|mailto:)(?:[^\s&]|&){1,2000})>/gi, (all, url) => link(url, '') ?? all)
|
|
99
|
+
.replace(/<([^\s@&]{1,200}@[^\s@&]{1,200}\.[^\s&]{1,200})>/g, (all, mail) => link(`mailto:${mail}`, mail) ?? all)
|
|
100
|
+
.replace(/\[\^([^\]\s]{1,100})\]/g, (_, id) => stash('<sup>') + id + stash('</sup>'))
|
|
101
|
+
.replace(TAG, htmlTags);
|
|
102
|
+
html += htmlTags.rest();
|
|
103
|
+
for (const [re, to] of EMPHASIS)
|
|
104
|
+
html = html.replace(re, to);
|
|
105
|
+
html = html.replace(/\n/g, '<br>').replace(/\u0001(\d+)\u0001/g, (_, n) => tags[+n]);
|
|
106
|
+
return restore(html, false);
|
|
107
|
+
}
|
|
108
|
+
/** A link target as markdown reads it back: spaces and unbalanced parentheses encoded. */
|
|
109
|
+
export function mdUrl(href) {
|
|
110
|
+
let depth = 0;
|
|
111
|
+
const balanced = [...href].every((c) => (c === '(' ? ++depth : c === ')' ? --depth : depth) >= 0) && depth === 0;
|
|
112
|
+
return (balanced ? href : href.replace(/\(/g, '%28').replace(/\)/g, '%29')).replace(/\s/g, '%20');
|
|
113
|
+
}
|
|
114
|
+
/** Plain text as inline markdown: what would read as a mark is escaped (`_` only at a word's edge: snake_case stays). */
|
|
115
|
+
export function escText(t) {
|
|
116
|
+
return t
|
|
117
|
+
.replace(SPECIAL, '\\$&')
|
|
118
|
+
.replace(/_/g, (u, i, s) => (/[\p{L}\p{N}]/u.test(s[i - 1] ?? '') && /[\p{L}\p{N}]/u.test(s[i + 1] ?? '') ? u : '\\_'));
|
|
119
|
+
}
|
|
120
|
+
/** Inline markdown for a code span: a fence longer than any backtick run inside, padded when it starts or ends with one. */
|
|
121
|
+
export function codeSpan(text) {
|
|
122
|
+
const fence = '`'.repeat(Math.max(0, ...(text.match(/`+/g) ?? []).map((r) => r.length)) + 1);
|
|
123
|
+
const pad = /^`|`$/.test(text) ? ' ' : '';
|
|
124
|
+
return fence + pad + text + pad + fence;
|
|
27
125
|
}
|
|
28
126
|
function walk(n, code) {
|
|
29
127
|
if (n.nodeType === Node.TEXT_NODE) {
|
|
30
128
|
// Zero-width spaces only hold the caret outside a mark (see plainText()).
|
|
31
|
-
const t = (n.textContent ?? '').replace(
|
|
32
|
-
return code ? t : t
|
|
129
|
+
const t = (n.textContent ?? '').replace(//g, '');
|
|
130
|
+
return code ? t : escText(t);
|
|
33
131
|
}
|
|
34
132
|
if (!(n instanceof HTMLElement))
|
|
35
133
|
return '';
|
|
@@ -53,9 +151,9 @@ function walk(n, code) {
|
|
|
53
151
|
case 'DEL':
|
|
54
152
|
return inner ? `~~${inner}~~` : '';
|
|
55
153
|
case 'CODE':
|
|
56
|
-
return inner
|
|
154
|
+
return !inner || code ? inner : codeSpan(inner);
|
|
57
155
|
case 'A':
|
|
58
|
-
return `[${inner}](${n.getAttribute('href') ?? ''})`;
|
|
156
|
+
return `[${inner}](${mdUrl(n.getAttribute('href') ?? '')})`;
|
|
59
157
|
case 'BR':
|
|
60
158
|
return '\n';
|
|
61
159
|
default:
|
|
@@ -68,21 +166,48 @@ export function htmlToMarkdown(html, code = false) {
|
|
|
68
166
|
d.innerHTML = html;
|
|
69
167
|
return walk(d, code);
|
|
70
168
|
}
|
|
71
|
-
/** Line starts that markdown would read as a block
|
|
72
|
-
const BLOCK_START = /^(?:#{1,6}
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
const
|
|
169
|
+
/** Line starts that markdown would read as a block (or a setext underline); such a line of text gets escaped. */
|
|
170
|
+
const BLOCK_START = /^(?:#{1,6}(?:\s|$)|[-*+](?:\s|$)|\d{1,9}[.)](?:\s|$)|>|`{3}|~{3}|([-*_])(?:\s*\1){2,}\s*$|=+\s*$)/;
|
|
171
|
+
/** A thematic break: three or more -, * or _ (spaces between them allowed). */
|
|
172
|
+
const RULE = /^([-*_])(?:[ \t]*\1){2,}[ \t]*$/;
|
|
173
|
+
/** An opening code fence: three or more backticks or tildes, then an optional language. */
|
|
174
|
+
const FENCE = /^(`{3,}|~{3,})[ \t]*([^\s`]*)[^`\n]*$/;
|
|
175
|
+
/** An ATX heading (`## Title`, closing `#`s dropped), or null. Done by hand: a regex with a lazy text and an optional
|
|
176
|
+
* closing sequence is quadratic on long runs of spaces. */
|
|
177
|
+
function heading(line) {
|
|
178
|
+
const m = /^#{1,6}(?=[ \t]|$)/.exec(line);
|
|
179
|
+
if (!m)
|
|
180
|
+
return null;
|
|
181
|
+
let text = line.slice(m[0].length).trim();
|
|
182
|
+
let k = text.length;
|
|
183
|
+
while (k > 0 && text[k - 1] === '#')
|
|
184
|
+
k--;
|
|
185
|
+
if (k < text.length && (k === 0 || text[k - 1] === ' ' || text[k - 1] === '\t'))
|
|
186
|
+
text = text.slice(0, k).trimEnd();
|
|
187
|
+
return { level: m[0].length, text };
|
|
188
|
+
}
|
|
189
|
+
const ITEM = /^(?:([-*+])[ \t]+\[([ xX])\](?:[ \t]+|$)|([-*+])(?:[ \t]+|$)|(\d{1,9})([.)])(?:[ \t]+|$))/;
|
|
190
|
+
const SETEXT = /^(=+|-+)[ \t]*$/;
|
|
191
|
+
const TABLE_RULE = /^\|?[ \t]*:?-+:?[ \t]*(?:\|[ \t]*:?-+:?[ \t]*)*\|?[ \t]*$/;
|
|
192
|
+
/** A line of text, escaped where markdown would read it as a block start (`1. ` as `1\. `). */
|
|
193
|
+
export function escLine(t) {
|
|
194
|
+
if (!BLOCK_START.test(t))
|
|
195
|
+
return t;
|
|
196
|
+
const ol = /^(\d{1,9})([.)])/.exec(t);
|
|
197
|
+
return ol ? `${ol[1]}\\${t.slice(ol[1].length)}` : '\\' + t;
|
|
198
|
+
}
|
|
77
199
|
/** One block -> its markdown snippet, without indentation. `n` is the ordinal of a numbered item. */
|
|
78
200
|
export function blockToMd(b, n = 1) {
|
|
79
201
|
if (b.type === 'plugin')
|
|
80
202
|
return '';
|
|
81
203
|
if (b.type === 'hr')
|
|
82
204
|
return '---';
|
|
83
|
-
if (b.type === 'code')
|
|
84
|
-
|
|
85
|
-
|
|
205
|
+
if (b.type === 'code') {
|
|
206
|
+
const text = htmlToMarkdown(b.html, true);
|
|
207
|
+
const fence = '`'.repeat(Math.max(3, ...(text.match(/`+/g) ?? []).map((r) => r.length + 1)));
|
|
208
|
+
return fence + (b.props?.lang ?? '') + '\n' + text + '\n' + fence;
|
|
209
|
+
}
|
|
210
|
+
const lines = htmlToMarkdown(b.html).split('\n').map(escLine);
|
|
86
211
|
switch (b.type) {
|
|
87
212
|
case 'h1':
|
|
88
213
|
case 'h2':
|
|
@@ -90,45 +215,41 @@ export function blockToMd(b, n = 1) {
|
|
|
90
215
|
case 'h4':
|
|
91
216
|
case 'h5':
|
|
92
217
|
case 'h6':
|
|
93
|
-
return `${'#'.repeat(+b.type[1])} ${
|
|
218
|
+
return `${'#'.repeat(+b.type[1])} ${lines.join(' ')}`;
|
|
94
219
|
case 'ul':
|
|
95
|
-
return `- ${
|
|
220
|
+
return `- ${lines.join('\\\n ')}`;
|
|
96
221
|
case 'ol':
|
|
97
|
-
return `${n}. ${
|
|
222
|
+
return `${n}. ${lines.join('\\\n' + ' '.repeat(String(n).length + 2))}`;
|
|
98
223
|
case 'todo':
|
|
99
|
-
return `- [${b.checked ? 'x' : ' '}] ${
|
|
224
|
+
return `- [${b.checked ? 'x' : ' '}] ${lines.join('\\\n ')}`;
|
|
100
225
|
case 'quote':
|
|
101
|
-
return
|
|
226
|
+
return lines.map((l) => `${'> '.repeat(b.indent + 1)}${l}`).join('\\\n');
|
|
102
227
|
default:
|
|
103
|
-
return
|
|
228
|
+
return lines.join('\\\n');
|
|
104
229
|
}
|
|
105
230
|
}
|
|
106
|
-
/** One markdown
|
|
231
|
+
/** One markdown line -> block fields (a heading, a list item, a quote, a rule or a paragraph). */
|
|
107
232
|
export function mdToBlock(md) {
|
|
108
|
-
const fence = FENCE.exec(md);
|
|
109
|
-
if (fence) {
|
|
110
|
-
const body = md.slice(fence[0].length).replace(/^\n/, '').replace(/\n?```\s*$/, '');
|
|
111
|
-
return { type: 'code', html: codeHtml(body), ...(fence[1] ? { props: { lang: fence[1] } } : {}) };
|
|
112
|
-
}
|
|
113
233
|
if (RULE.test(md))
|
|
114
234
|
return { type: 'hr', html: '' };
|
|
115
|
-
const
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
235
|
+
const h = heading(md);
|
|
236
|
+
if (h)
|
|
237
|
+
return { type: `h${h.level}`, html: inline(h.text) };
|
|
238
|
+
const q = /^>[ \t]?/.exec(md);
|
|
239
|
+
if (q)
|
|
240
|
+
return { type: 'quote', html: inline(md.slice(q[0].length)) };
|
|
241
|
+
const m = ITEM.exec(md);
|
|
242
|
+
if (m) {
|
|
243
|
+
const text = md.slice(m[0].length);
|
|
244
|
+
if (m[2])
|
|
245
|
+
return { type: 'todo', html: inline(text), checked: m[2] !== ' ' };
|
|
246
|
+
if (m[3])
|
|
247
|
+
return { type: 'ul', html: inline(text) };
|
|
248
|
+
return { type: 'ol', html: inline(text), props: { start: +m[4] } };
|
|
249
|
+
}
|
|
250
|
+
// Undo a backslash an older export put before a block-like start ("\1. "); text escapes such as `\*` stay.
|
|
251
|
+
const text = md[0] === '\\' && /[^!-/:-@[-`{-~]/.test(md[1] ?? '\\') && BLOCK_START.test(md.slice(1)) ? md.slice(1) : md;
|
|
252
|
+
return { type: 'p', html: inline(text) };
|
|
132
253
|
}
|
|
133
254
|
const BUILT_IN = ['p', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'ul', 'ol', 'todo', 'quote', 'code', 'hr'];
|
|
134
255
|
const codeHtml = (text) => esc(text).replace(/\n/g, '<br>');
|
|
@@ -151,6 +272,8 @@ export function toStored(b) {
|
|
|
151
272
|
out.props = { checked: !!b.checked };
|
|
152
273
|
if (b.type === 'code' && b.props?.lang)
|
|
153
274
|
out.props = { lang: b.props.lang };
|
|
275
|
+
if (b.type === 'ol' && b.props?.start != null && olStart(b.props) !== 1)
|
|
276
|
+
out.props = { start: olStart(b.props) };
|
|
154
277
|
return out;
|
|
155
278
|
}
|
|
156
279
|
/** Stored block -> editor block with a fresh id. Unknown types (md: or plugin) are kept as they are. */
|
|
@@ -163,30 +286,177 @@ export function fromStored(s) {
|
|
|
163
286
|
return newBlock(kind, html, {
|
|
164
287
|
indent: s.indent,
|
|
165
288
|
...(kind === 'todo' ? { checked: !!s.props?.checked } : {}),
|
|
166
|
-
...(kind === 'code' && s.props?.lang ? { props: { lang: s.props.lang } } : {})
|
|
289
|
+
...(kind === 'code' && s.props?.lang ? { props: { lang: s.props.lang } } : {}),
|
|
290
|
+
...(kind === 'ol' && s.props?.start != null ? { props: { start: olStart(s.props) } } : {})
|
|
167
291
|
});
|
|
168
292
|
}
|
|
169
|
-
/**
|
|
170
|
-
|
|
293
|
+
/** The cells of a table row: split at unescaped pipes, the outer pipes dropped, `\|` unescaped. */
|
|
294
|
+
function cells(line) {
|
|
295
|
+
const t = line.trim().replace(/^\|/, '').replace(/(?<!\\)\|$/, '');
|
|
296
|
+
return t.split(/(?<!\\)\|/).map((c) => c.trim().replace(/\\\|/g, '|'));
|
|
297
|
+
}
|
|
298
|
+
/** The width of a line's leading whitespace (a tab counts 4). */
|
|
299
|
+
const leadOf = (line) => /^[ \t]*/.exec(line)[0].replace(/\t/g, ' ').length;
|
|
300
|
+
/** A line without `n` columns of leading whitespace (as far as it has them). */
|
|
301
|
+
const dedent = (line, n) => line.replace(/\t/g, ' ').replace(new RegExp(`^ {0,${n}}`), '');
|
|
302
|
+
/** A line ending in a hard break (two spaces or a backslash) or not, without that ending. */
|
|
303
|
+
const hardEnd = (line) => {
|
|
304
|
+
let k = line.length;
|
|
305
|
+
while (k > 0 && line[k - 1] === '\\')
|
|
306
|
+
k--;
|
|
307
|
+
// An odd run of backslashes at the end: the last one is the break, the others escape each other.
|
|
308
|
+
return ((line.length - k) % 2 ? line.slice(0, -1) : line).trimEnd();
|
|
309
|
+
};
|
|
310
|
+
/**
|
|
311
|
+
* Markdown text -> blocks (CommonMark and GFM, as far as blocks can hold them): headings (also setext), lists with
|
|
312
|
+
* their nesting and start number, to-dos, quotes (also nested), fenced and indented code, rules, and paragraphs whose
|
|
313
|
+
* lines are kept as line breaks. Reference links are resolved, footnotes become superscripts. A GFM table goes to the
|
|
314
|
+
* first of `plugins` that takes it (`fromTable`), or stays text.
|
|
315
|
+
*/
|
|
316
|
+
export function fromMarkdown(src, opts = {}) {
|
|
317
|
+
const all = src.replace(/\r\n?/g, '\n').split('\n');
|
|
318
|
+
// Reference definitions ([id]: url) are taken out and used by the links; footnote definitions stay as text.
|
|
319
|
+
const refs = new Map();
|
|
320
|
+
const lines = all.filter((l) => {
|
|
321
|
+
const d = /^ {0,3}\[([^\]^][^\]]*)\]:[ \t]*<?([^\s>]+)>?(?:[ \t]+(?:"[^"]*"|'[^']*'|\([^)]*\)))?[ \t]*$/.exec(l);
|
|
322
|
+
if (d)
|
|
323
|
+
refs.set(d[1].trim().toLowerCase(), d[2]);
|
|
324
|
+
return !d;
|
|
325
|
+
});
|
|
326
|
+
const resolve = (t) => !refs.size
|
|
327
|
+
? t
|
|
328
|
+
: t
|
|
329
|
+
.replace(/(?<!\\)\[([^[\]]{1,1000})\]\[([^[\]]{0,1000})\]/g, (all, text, id) => {
|
|
330
|
+
const url = refs.get((id || text).trim().toLowerCase());
|
|
331
|
+
return url ? `[${text}](${url})` : all;
|
|
332
|
+
})
|
|
333
|
+
.replace(/(?<![\\\]!])\[([^[\]^][^[\]]{0,1000})\](?![([:])/g, (all, text) => {
|
|
334
|
+
const url = refs.get(text.trim().toLowerCase());
|
|
335
|
+
return url ? `[${text}](${url})` : all;
|
|
336
|
+
});
|
|
337
|
+
const tableTaker = opts.plugins?.find((p) => p.fromTable);
|
|
171
338
|
const out = [];
|
|
172
|
-
|
|
339
|
+
// The open list items, by the column of their marker: a line indented past an item's marker is inside it.
|
|
340
|
+
const items = [];
|
|
341
|
+
const depth = (lead) => {
|
|
342
|
+
while (items.length && lead <= items[items.length - 1])
|
|
343
|
+
items.pop();
|
|
344
|
+
return Math.min(MAX_INDENT, items.length);
|
|
345
|
+
};
|
|
346
|
+
// The block the next plain line continues (a paragraph, a list item, a quote), and the quote's depth.
|
|
347
|
+
let open = null;
|
|
348
|
+
const push = (b) => {
|
|
349
|
+
out.push(b);
|
|
350
|
+
return b;
|
|
351
|
+
};
|
|
173
352
|
for (let i = 0; i < lines.length; i++) {
|
|
174
|
-
const
|
|
175
|
-
const
|
|
353
|
+
const raw = lines[i];
|
|
354
|
+
const lead = leadOf(raw);
|
|
355
|
+
const line = raw.trim();
|
|
356
|
+
if (!line) {
|
|
357
|
+
open = null;
|
|
358
|
+
continue;
|
|
359
|
+
}
|
|
360
|
+
// Fenced code: closed by a fence of the same character, at least as long.
|
|
361
|
+
const fence = lead < 4 || items.length ? FENCE.exec(line) : null;
|
|
176
362
|
if (fence) {
|
|
177
|
-
const
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
363
|
+
const indent = depth(lead);
|
|
364
|
+
const body = [];
|
|
365
|
+
const close = new RegExp(`^${fence[1][0] === '`' ? '`' : '~'}{${fence[1].length},}[ \\t]*$`);
|
|
366
|
+
for (i++; i < lines.length && !close.test(lines[i].trim()); i++)
|
|
367
|
+
body.push(dedent(lines[i], lead));
|
|
368
|
+
const lang = fence[2].replace(/[^\w+#.-]/g, '');
|
|
369
|
+
push(newBlock('code', codeHtml(body.join('\n')), { indent, ...(lang ? { props: { lang } } : {}) }));
|
|
370
|
+
open = null;
|
|
371
|
+
continue;
|
|
372
|
+
}
|
|
373
|
+
// Indented code: four spaces, outside lists and paragraphs.
|
|
374
|
+
if (lead >= 4 && !open && !items.length) {
|
|
375
|
+
const body = [];
|
|
376
|
+
for (; i < lines.length && (leadOf(lines[i]) >= 4 || !lines[i].trim()); i++)
|
|
377
|
+
body.push(dedent(lines[i], 4));
|
|
378
|
+
i--;
|
|
379
|
+
while (body.length && !body[body.length - 1].trim())
|
|
380
|
+
body.pop();
|
|
381
|
+
push(newBlock('code', codeHtml(body.join('\n'))));
|
|
382
|
+
open = null;
|
|
383
|
+
continue;
|
|
384
|
+
}
|
|
385
|
+
// Setext heading: a paragraph underlined with = or -.
|
|
386
|
+
if (open?.type === 'p' && !open.indent && SETEXT.test(line) && lead < 4) {
|
|
387
|
+
open.type = line[0] === '=' ? 'h1' : 'h2';
|
|
388
|
+
open = null;
|
|
389
|
+
continue;
|
|
390
|
+
}
|
|
391
|
+
// GFM table: a header row, then a delimiter row.
|
|
392
|
+
if (tableTaker && line.includes('|') && i + 1 < lines.length && TABLE_RULE.test(lines[i + 1].trim()) && lines[i + 1].includes('-')) {
|
|
393
|
+
const head = cells(line);
|
|
394
|
+
const align = cells(lines[i + 1]).map((c) => (c.startsWith(':') && c.endsWith(':') ? 'center' : c.endsWith(':') ? 'right' : 'left'));
|
|
395
|
+
const rows = [head];
|
|
396
|
+
for (i += 2; i < lines.length && lines[i].trim() && lines[i].includes('|'); i++)
|
|
397
|
+
rows.push(cells(lines[i]));
|
|
398
|
+
i--;
|
|
399
|
+
const width = Math.max(...rows.map((r) => r.length));
|
|
400
|
+
const square = rows.map((r) => [...r, ...Array(width - r.length).fill('')].slice(0, width).map(resolve));
|
|
401
|
+
const props = tableTaker.fromTable({ rows: square, align: [...align, ...Array(width).fill('left')].slice(0, width) });
|
|
402
|
+
push(newBlock('plugin', '', { plugin: tableTaker.type, props, indent: depth(lead) }));
|
|
403
|
+
open = null;
|
|
182
404
|
continue;
|
|
183
405
|
}
|
|
184
|
-
|
|
406
|
+
// Quotes: one block per paragraph, nested quotes one step deeper; `>` alone ends a paragraph.
|
|
407
|
+
const q = /^(?:>[ \t]?)+/.exec(line);
|
|
408
|
+
if (q) {
|
|
409
|
+
const level = Math.min(MAX_INDENT, (q[0].match(/>/g) ?? []).length - 1);
|
|
410
|
+
const text = line.slice(q[0].length);
|
|
411
|
+
if (!text.trim()) {
|
|
412
|
+
open = null;
|
|
413
|
+
continue;
|
|
414
|
+
}
|
|
415
|
+
if (open?.type === 'quote' && open.indent === level) {
|
|
416
|
+
open.html += '<br>' + inline(resolve(hardEnd(text)));
|
|
417
|
+
continue;
|
|
418
|
+
}
|
|
419
|
+
open = push(newBlock('quote', inline(resolve(hardEnd(text))), { indent: level }));
|
|
185
420
|
continue;
|
|
186
|
-
|
|
187
|
-
const
|
|
188
|
-
const
|
|
189
|
-
|
|
421
|
+
}
|
|
422
|
+
const item = ITEM.exec(line);
|
|
423
|
+
const block = item || RULE.test(line) || heading(line);
|
|
424
|
+
// A plain line right after a paragraph, a list item or a quote continues it (with a line break).
|
|
425
|
+
if (open && !block) {
|
|
426
|
+
const more = inline(resolve(hardEnd(line)));
|
|
427
|
+
if (more.trim())
|
|
428
|
+
open.html += '<br>' + more;
|
|
429
|
+
continue;
|
|
430
|
+
}
|
|
431
|
+
const indent = depth(lead);
|
|
432
|
+
const { type, html, ...extra } = mdToBlock(resolve(hardEnd(line)));
|
|
433
|
+
if (item)
|
|
434
|
+
items.push(lead);
|
|
435
|
+
else if (!indent)
|
|
436
|
+
items.length = 0;
|
|
437
|
+
// A footnote definition ([^1]: …) reads as the note after its number.
|
|
438
|
+
const note = type === 'p' ? /^\[\^([^\]\s]+)\]:\s*/.exec(line) : null;
|
|
439
|
+
const b = newBlock(type, note ? `<sup>${esc(note[1])}</sup> ${inline(resolve(hardEnd(line.slice(note[0].length))))}` : html, {
|
|
440
|
+
indent: type === 'p' || item ? indent : 0,
|
|
441
|
+
...extra
|
|
442
|
+
});
|
|
443
|
+
if (type === 'p' && !b.html.replace(/<br>/g, '').trim())
|
|
444
|
+
continue;
|
|
445
|
+
push(b);
|
|
446
|
+
open = type === 'p' || item ? b : null;
|
|
447
|
+
}
|
|
448
|
+
// A numbered list keeps its start number on its first item only (and only when it is not 1); as numbering() counts.
|
|
449
|
+
const counters = [];
|
|
450
|
+
for (const b of out) {
|
|
451
|
+
if (b.type !== 'ol') {
|
|
452
|
+
counters.length = Math.min(counters.length, b.indent);
|
|
453
|
+
continue;
|
|
454
|
+
}
|
|
455
|
+
const first = counters[b.indent] === undefined;
|
|
456
|
+
counters.length = b.indent + 1;
|
|
457
|
+
counters[b.indent] = 1;
|
|
458
|
+
if (!first || olStart(b.props) === 1)
|
|
459
|
+
delete b.props;
|
|
190
460
|
}
|
|
191
461
|
return out;
|
|
192
462
|
}
|
|
@@ -195,14 +465,32 @@ export function fromMarkdown(src) {
|
|
|
195
465
|
export function toMarkdown(blocks, opts = {}) {
|
|
196
466
|
const nums = numbering(blocks);
|
|
197
467
|
const listy = (t) => t === 'ul' || t === 'ol' || t === 'todo';
|
|
468
|
+
// The content column of the last list item at each level: what a nested block is indented by.
|
|
469
|
+
const cols = [];
|
|
198
470
|
const md = (b) => {
|
|
199
|
-
if (b.type
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
471
|
+
if (b.type === 'plugin') {
|
|
472
|
+
const plugin = opts.plugins?.find((p) => p.type === b.plugin);
|
|
473
|
+
if (!plugin?.markdown)
|
|
474
|
+
return '';
|
|
475
|
+
const inner = b.blocks?.length ? toMarkdown(b.blocks.map(fromStored), opts) : '';
|
|
476
|
+
return plugin.markdown(b.props ?? {}, { inner }).trim();
|
|
477
|
+
}
|
|
478
|
+
const text = blockToMd(b, nums[b.id]);
|
|
479
|
+
if (!text.trim() || (b.type === 'quote' && !htmlToMarkdown(b.html).trim()))
|
|
203
480
|
return '';
|
|
204
|
-
|
|
205
|
-
|
|
481
|
+
// Inside a list (a nested item, a paragraph or code under an item): indented to the item's content.
|
|
482
|
+
const nested = (listy(b.type) || b.type === 'p' || b.type === 'code') && b.indent > 0 && cols.length > 0;
|
|
483
|
+
const pad = nested ? ' '.repeat(cols[Math.min(b.indent, cols.length) - 1]) : '';
|
|
484
|
+
if (listy(b.type)) {
|
|
485
|
+
cols.length = Math.min(cols.length, b.indent);
|
|
486
|
+
cols[b.indent] = pad.length + (b.type === 'ol' ? String(nums[b.id]).length + 2 : 2);
|
|
487
|
+
}
|
|
488
|
+
else if (!nested)
|
|
489
|
+
cols.length = 0;
|
|
490
|
+
return text
|
|
491
|
+
.split('\n')
|
|
492
|
+
.map((l) => (l ? pad + l : l))
|
|
493
|
+
.join('\n');
|
|
206
494
|
};
|
|
207
495
|
let out = '';
|
|
208
496
|
let prev;
|
|
@@ -210,8 +498,15 @@ export function toMarkdown(blocks, opts = {}) {
|
|
|
210
498
|
const text = md(b);
|
|
211
499
|
if (!text)
|
|
212
500
|
continue;
|
|
213
|
-
if (prev)
|
|
214
|
-
|
|
501
|
+
if (prev) {
|
|
502
|
+
if (listy(b.type) && listy(prev.type))
|
|
503
|
+
out += '\n';
|
|
504
|
+
// Quotes in a row stay one quote, a `>` line between their paragraphs.
|
|
505
|
+
else if (b.type === 'quote' && prev.type === 'quote')
|
|
506
|
+
out += '\n' + '>'.repeat(Math.min(b.indent, prev.indent) + 1) + '\n';
|
|
507
|
+
else
|
|
508
|
+
out += '\n\n';
|
|
509
|
+
}
|
|
215
510
|
out += text;
|
|
216
511
|
prev = b;
|
|
217
512
|
}
|
package/dist/model.js
CHANGED
|
@@ -1,6 +1,5 @@
|
|
|
1
|
-
//
|
|
2
|
-
//
|
|
3
|
-
// belongs to an editor plugin, with its own props and, for some, nested blocks.
|
|
1
|
+
// The stored document: what the editor writes and an app saves. Built-in blocks are `md:<kind>` with inline markdown text
|
|
2
|
+
// (raw text for md:code); every other type belongs to a plugin, with its own props and, for containers, nested blocks.
|
|
4
3
|
export const MD = 'md:';
|
|
5
4
|
export const MAX_INDENT = 6;
|
|
6
5
|
/** Plugins with nested blocks hold one level only, so a document is at most this deep. */
|
package/dist/plugins/Gap.svelte
CHANGED
|
@@ -18,11 +18,8 @@
|
|
|
18
18
|
let { props = $bindable() }: PluginViewProps<GapProps> = $props();
|
|
19
19
|
</script>
|
|
20
20
|
|
|
21
|
-
<div
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
background: repeating-linear-gradient(-45deg, transparent 0 6px, color-mix(in srgb, currentColor 7%, transparent) 6px 12px);
|
|
27
|
-
}
|
|
28
|
-
</style>
|
|
21
|
+
<div
|
|
22
|
+
class="rounded-md bg-[repeating-linear-gradient(-45deg,transparent_0_6px,color-mix(in_srgb,currentColor_7%,transparent)_6px_12px)]"
|
|
23
|
+
style:height="{gapLines(props.lines)}lh"
|
|
24
|
+
aria-hidden="true"
|
|
25
|
+
></div>
|
package/dist/plugins/Map.svelte
CHANGED
|
@@ -23,25 +23,10 @@
|
|
|
23
23
|
</script>
|
|
24
24
|
|
|
25
25
|
{#if src}
|
|
26
|
-
<div class="
|
|
26
|
+
<div class="overflow-hidden rounded-[10px] border border-frame">
|
|
27
27
|
<!-- Not interactive inside the editor, so selecting and dragging the block keep working. -->
|
|
28
|
-
<iframe title={tr('Map: {place}', { place: String(props.q ?? '') })} {src} loading="lazy"></iframe>
|
|
28
|
+
<iframe class="pointer-events-none block h-[260px] w-full border-0" title={tr('Map: {place}', { place: String(props.q ?? '') })} {src} loading="lazy"></iframe>
|
|
29
29
|
</div>
|
|
30
30
|
{:else}
|
|
31
31
|
<Empty icon={MapPin} text="Set a place or an address above" />
|
|
32
32
|
{/if}
|
|
33
|
-
|
|
34
|
-
<style>
|
|
35
|
-
.map {
|
|
36
|
-
border: 1px solid var(--color-frame, #e5e5ea);
|
|
37
|
-
border-radius: 10px;
|
|
38
|
-
overflow: hidden;
|
|
39
|
-
}
|
|
40
|
-
iframe {
|
|
41
|
-
display: block;
|
|
42
|
-
width: 100%;
|
|
43
|
-
height: 260px;
|
|
44
|
-
border: 0;
|
|
45
|
-
pointer-events: none;
|
|
46
|
-
}
|
|
47
|
-
</style>
|