@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.
Files changed (62) hide show
  1. package/CHANGELOG.md +40 -0
  2. package/README-AI.md +10 -7
  3. package/dist/Editor.svelte +691 -833
  4. package/dist/LinkPrompt.svelte +1 -14
  5. package/dist/Preview.svelte +27 -54
  6. package/dist/Preview.svelte.d.ts +2 -4
  7. package/dist/PropsBar.svelte +35 -219
  8. package/dist/code.js +1 -1
  9. package/dist/dom.d.ts +8 -0
  10. package/dist/dom.js +9 -0
  11. package/dist/editor/BlockMenu.svelte +102 -0
  12. package/dist/editor/BlockMenu.svelte.d.ts +32 -0
  13. package/dist/editor/FormatBar.svelte +41 -0
  14. package/dist/editor/FormatBar.svelte.d.ts +16 -0
  15. package/dist/editor/drag.svelte.d.ts +63 -0
  16. package/dist/editor/drag.svelte.js +95 -0
  17. package/dist/editor/history.d.ts +24 -0
  18. package/dist/editor/history.js +86 -0
  19. package/dist/editor/sweep.svelte.d.ts +21 -0
  20. package/dist/editor/sweep.svelte.js +56 -0
  21. package/dist/html.d.ts +12 -0
  22. package/dist/html.js +206 -0
  23. package/dist/i18n.js +1 -1
  24. package/dist/index.d.ts +2 -1
  25. package/dist/index.js +3 -2
  26. package/dist/kit/Empty.svelte +4 -15
  27. package/dist/kit/Frame.svelte +7 -37
  28. package/dist/locales/hu.js +4 -1
  29. package/dist/markdown.d.ts +20 -4
  30. package/dist/markdown.js +380 -85
  31. package/dist/model.js +2 -3
  32. package/dist/plugins/Gap.svelte +5 -8
  33. package/dist/plugins/GapPreview.svelte +4 -6
  34. package/dist/plugins/Map.svelte +2 -17
  35. package/dist/plugins/MapPreview.svelte +8 -10
  36. package/dist/plugins/Table.svelte +233 -256
  37. package/dist/plugins/TablePreview.svelte +28 -28
  38. package/dist/plugins/YouTube.svelte +3 -31
  39. package/dist/plugins/YouTubePreview.svelte +45 -43
  40. package/dist/plugins/index.js +3 -1
  41. package/dist/types.d.ts +21 -3
  42. package/dist/types.js +8 -1
  43. package/dist/view.js +1 -1
  44. package/docs/en/bundled-plugins.md +1 -1
  45. package/docs/en/documents-and-markdown.md +20 -14
  46. package/docs/en/editor-and-preview.md +6 -7
  47. package/docs/en/getting-started.md +8 -0
  48. package/docs/en/index.md +1 -1
  49. package/docs/en/recipes-and-pitfalls.md +2 -2
  50. package/docs/en/server-side.md +0 -17
  51. package/docs/en/upgrading.md +14 -0
  52. package/docs/en/writing-plugins.md +36 -10
  53. package/docs/hu/bundled-plugins.md +1 -1
  54. package/docs/hu/documents-and-markdown.md +20 -14
  55. package/docs/hu/editor-and-preview.md +6 -7
  56. package/docs/hu/getting-started.md +8 -0
  57. package/docs/hu/index.md +1 -1
  58. package/docs/hu/recipes-and-pitfalls.md +2 -2
  59. package/docs/hu/server-side.md +0 -17
  60. package/docs/hu/upgrading.md +14 -0
  61. package/docs/hu/writing-plugins.md +36 -10
  62. 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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
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 = /&lt;(\/?)([a-z][\w-]{0,30})(?:\s(?:[^&]|&(?!lt;|gt;)){0,500})?&gt;/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(/\\([\\`*~[\]<>#+.!_-])/g, (_, c) => `${MARK}${kept.push(c) - 1}${MARK}`);
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
- return guarded
13
- .split(/(`[^`]+`)/)
14
- .map((seg, i) => {
15
- if (i % 2)
16
- return `<code>${restore(esc(seg.slice(1, -1)), true)}</code>`;
17
- const html = esc(seg)
18
- .replace(/\*\*(.+?)\*\*/g, '<b>$1</b>')
19
- .replace(/(?<!\*)\*(?!\s)(.+?)(?<!\s)\*(?!\*)/g, '<i>$1</i>')
20
- .replace(/~~(.+?)~~/g, '<strike>$1</strike>')
21
- .replace(/&lt;(u|sub|sup)&gt;(.+?)&lt;\/\1&gt;/g, '<$1>$2</$1>')
22
- .replace(/\[(.+?)\]\(((?:https?:|mailto:|tel:|\/(?!\/))[^)\s]+)\)/g, '<a href="$2">$1</a>')
23
- .replace(/\n/g, '<br>');
24
- return restore(html, false);
25
- })
26
- .join('');
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(/&amp;/g, '&');
91
+ return SAFE_URL.test(href) ? stash(`<a href="${esc(href).replace(/"/g, '&quot;')}">`) + (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(/&lt;((?:https?:\/\/|mailto:)(?:[^\s&]|&amp;){1,2000})&gt;/gi, (all, url) => link(url, '') ?? all)
99
+ .replace(/&lt;([^\s@&]{1,200}@[^\s@&]{1,200}\.[^\s&]{1,200})&gt;/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(/\u200B/g, '');
32
- return code ? t : t.replace(SPECIAL, '\\$&');
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 && !code ? `\`${inner}\`` : 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; a paragraph starting like this gets a leading backslash. */
72
- const BLOCK_START = /^(?:#{1,6} |[-*+] |\d{1,3}[.)] |>|```|([-*_])\1{2,}\s*$)/;
73
- const RULE = /^([-*_])\1{2,}\s*$/;
74
- /** Opening code fence with an optional language: ```ts, ```c++, ```objective-c… */
75
- const FENCE = /^```([\w+#.-]*)[^\S\n]*/;
76
- const LINE = /^(?:(#{1,6}) |[-*+] \[([ xX])\] |([-*+]) |(\d{1,3})[.)] |(>) ?)?([\s\S]*)$/;
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
- return '```' + (b.props?.lang ?? '') + '\n' + htmlToMarkdown(b.html, true) + '\n```';
85
- const t = htmlToMarkdown(b.html);
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])} ${t}`;
218
+ return `${'#'.repeat(+b.type[1])} ${lines.join(' ')}`;
94
219
  case 'ul':
95
- return `- ${t}`;
220
+ return `- ${lines.join('\\\n ')}`;
96
221
  case 'ol':
97
- return `${n}. ${t}`;
222
+ return `${n}. ${lines.join('\\\n' + ' '.repeat(String(n).length + 2))}`;
98
223
  case 'todo':
99
- return `- [${b.checked ? 'x' : ' '}] ${t}`;
224
+ return `- [${b.checked ? 'x' : ' '}] ${lines.join('\\\n ')}`;
100
225
  case 'quote':
101
- return `> ${t}`;
226
+ return lines.map((l) => `${'> '.repeat(b.indent + 1)}${l}`).join('\\\n');
102
227
  default:
103
- return BLOCK_START.test(t) ? '\\' + t : t;
228
+ return lines.join('\\\n');
104
229
  }
105
230
  }
106
- /** One markdown snippet -> block fields. */
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 m = LINE.exec(md);
116
- const type = m[1]
117
- ? `h${m[1].length}`
118
- : m[2]
119
- ? 'todo'
120
- : m[3]
121
- ? 'ul'
122
- : m[4]
123
- ? 'ol'
124
- : m[5]
125
- ? 'quote'
126
- : 'p';
127
- let text = m[6];
128
- // Undo the backslash blockToMd put before a block-like start (text escapes such as `\*` stay).
129
- if (type === 'p' && text[0] === '\\' && !/[\\`*~[\]<]/.test(text[1] ?? '\\') && BLOCK_START.test(text.slice(1)))
130
- text = text.slice(1);
131
- return { type, html: inline(text), ...(type === 'todo' ? { checked: m[2].toLowerCase() === 'x' } : {}) };
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
- /** Markdown text -> blocks. Supports headings, lists, todos, quotes, fenced code, hr and basic inline marks. */
170
- export function fromMarkdown(src) {
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
- const lines = src.replace(/\r/g, '').split('\n');
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 line = lines[i];
175
- const fence = FENCE.exec(line.trimStart());
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 code = [];
178
- i++;
179
- while (i < lines.length && !/^\s*```/.test(lines[i]))
180
- code.push(lines[i++]);
181
- out.push(newBlock('code', codeHtml(code.join('\n')), fence[1] ? { props: { lang: fence[1] } } : {}));
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
- if (!line.trim())
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
- const lead = /^\s*/.exec(line)[0];
187
- const indent = Math.min(MAX_INDENT, Math.floor(lead.replace(/\t/g, ' ').length / 2));
188
- const { type, html, ...extra } = mdToBlock(line.slice(lead.length));
189
- out.push(newBlock(type, html, { indent, ...extra }));
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 !== 'plugin')
200
- return (' ').repeat(listy(b.type) || b.type === 'p' ? b.indent : 0) + blockToMd(b, nums[b.id]);
201
- const plugin = opts.plugins?.find((p) => p.type === b.plugin);
202
- if (!plugin?.markdown)
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
- const inner = b.blocks?.length ? toMarkdown(b.blocks.map(fromStored), opts) : '';
205
- return plugin.markdown(b.props ?? {}, { inner }).trim();
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
- out += listy(b.type) && listy(prev.type) ? '\n' : '\n\n';
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
- // Block content: what the block editor (src/routes/(+lib)/blocks) writes into the `content` column of pages, news posts
2
- // and promo boxes. Built-in blocks are `md:<kind>` with inline markdown text (raw text for md:code); every other type
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. */
@@ -18,11 +18,8 @@
18
18
  let { props = $bindable() }: PluginViewProps<GapProps> = $props();
19
19
  </script>
20
20
 
21
- <div class="gap" style:height="{gapLines(props.lines)}lh" aria-hidden="true"></div>
22
-
23
- <style>
24
- .gap {
25
- border-radius: 6px;
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>
@@ -9,9 +9,7 @@
9
9
 
10
10
  <div class="gap" style:height="{gapLines(props.lines)}lh" aria-hidden="true"></div>
11
11
 
12
- <style>
13
- .gap {
14
- clear: both;
15
- margin: 0;
16
- }
17
- </style>
12
+ <style>.gap {
13
+ clear: both;
14
+ margin: 0;
15
+ }</style>
@@ -23,25 +23,10 @@
23
23
  </script>
24
24
 
25
25
  {#if src}
26
- <div class="map">
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>