@kernhq/module-quire 0.13.1 → 0.15.0

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 (123) hide show
  1. package/dist/contract/models.d.ts +394 -0
  2. package/dist/contract/models.d.ts.map +1 -1
  3. package/dist/contract/models.js +284 -0
  4. package/dist/contract/models.js.map +1 -1
  5. package/dist/contract/permissions.d.ts +14 -0
  6. package/dist/contract/permissions.d.ts.map +1 -1
  7. package/dist/contract/permissions.js +114 -0
  8. package/dist/contract/permissions.js.map +1 -1
  9. package/dist/contract/properties.d.ts +7 -7
  10. package/dist/contract/router.d.ts +1124 -96
  11. package/dist/contract/router.d.ts.map +1 -1
  12. package/dist/contract/router.js +343 -1
  13. package/dist/contract/router.js.map +1 -1
  14. package/dist/server/_impl.d.ts +983 -1168
  15. package/dist/server/_impl.d.ts.map +1 -1
  16. package/dist/server/_impl.js +308 -0
  17. package/dist/server/_impl.js.map +1 -1
  18. package/dist/server/export/html.d.ts +67 -0
  19. package/dist/server/export/html.d.ts.map +1 -0
  20. package/dist/server/export/html.js +206 -0
  21. package/dist/server/export/html.js.map +1 -0
  22. package/dist/server/export/markdown.d.ts +51 -0
  23. package/dist/server/export/markdown.d.ts.map +1 -0
  24. package/dist/server/export/markdown.js +351 -0
  25. package/dist/server/export/markdown.js.map +1 -0
  26. package/dist/server/export/pdf.d.ts +20 -0
  27. package/dist/server/export/pdf.d.ts.map +1 -0
  28. package/dist/server/export/pdf.js +91 -0
  29. package/dist/server/export/pdf.js.map +1 -0
  30. package/dist/server/export/zip.d.ts +31 -0
  31. package/dist/server/export/zip.d.ts.map +1 -0
  32. package/dist/server/export/zip.js +158 -0
  33. package/dist/server/export/zip.js.map +1 -0
  34. package/dist/server/import/csv.d.ts +77 -0
  35. package/dist/server/import/csv.d.ts.map +1 -0
  36. package/dist/server/import/csv.js +263 -0
  37. package/dist/server/import/csv.js.map +1 -0
  38. package/dist/server/import/html.d.ts +52 -0
  39. package/dist/server/import/html.d.ts.map +1 -0
  40. package/dist/server/import/html.js +472 -0
  41. package/dist/server/import/html.js.map +1 -0
  42. package/dist/server/import/markdown.d.ts +63 -0
  43. package/dist/server/import/markdown.d.ts.map +1 -0
  44. package/dist/server/import/markdown.js +692 -0
  45. package/dist/server/import/markdown.js.map +1 -0
  46. package/dist/server/import/plan.d.ts +70 -0
  47. package/dist/server/import/plan.d.ts.map +1 -0
  48. package/dist/server/import/plan.js +761 -0
  49. package/dist/server/import/plan.js.map +1 -0
  50. package/dist/server/import/ydoc.d.ts +35 -0
  51. package/dist/server/import/ydoc.d.ts.map +1 -0
  52. package/dist/server/import/ydoc.js +91 -0
  53. package/dist/server/import/ydoc.js.map +1 -0
  54. package/dist/server/import/zip.d.ts +63 -0
  55. package/dist/server/import/zip.d.ts.map +1 -0
  56. package/dist/server/import/zip.js +308 -0
  57. package/dist/server/import/zip.js.map +1 -0
  58. package/dist/server/index.d.ts.map +1 -1
  59. package/dist/server/index.js +3 -1
  60. package/dist/server/index.js.map +1 -1
  61. package/dist/server/render.d.ts +158 -0
  62. package/dist/server/render.d.ts.map +1 -1
  63. package/dist/server/render.js +240 -0
  64. package/dist/server/render.js.map +1 -1
  65. package/dist/server/schema.d.ts +704 -1
  66. package/dist/server/schema.d.ts.map +1 -1
  67. package/dist/server/schema.js +254 -1
  68. package/dist/server/schema.js.map +1 -1
  69. package/dist/server/services/databases.d.ts +10 -10
  70. package/dist/server/services/export.d.ts +176 -0
  71. package/dist/server/services/export.d.ts.map +1 -0
  72. package/dist/server/services/export.js +822 -0
  73. package/dist/server/services/export.js.map +1 -0
  74. package/dist/server/services/import.d.ts +109 -0
  75. package/dist/server/services/import.d.ts.map +1 -0
  76. package/dist/server/services/import.js +570 -0
  77. package/dist/server/services/import.js.map +1 -0
  78. package/dist/server/services/index.d.ts +32 -1
  79. package/dist/server/services/index.d.ts.map +1 -1
  80. package/dist/server/services/index.js +87 -4
  81. package/dist/server/services/index.js.map +1 -1
  82. package/dist/server/services/macros.d.ts +83 -0
  83. package/dist/server/services/macros.d.ts.map +1 -0
  84. package/dist/server/services/macros.js +488 -0
  85. package/dist/server/services/macros.js.map +1 -0
  86. package/dist/server/services/publications.d.ts +2 -1
  87. package/dist/server/services/publications.d.ts.map +1 -1
  88. package/dist/server/services/publications.js +43 -4
  89. package/dist/server/services/publications.js.map +1 -1
  90. package/dist/server/services/templates.d.ts +135 -0
  91. package/dist/server/services/templates.d.ts.map +1 -0
  92. package/dist/server/services/templates.js +897 -0
  93. package/dist/server/services/templates.js.map +1 -0
  94. package/dist/server/services/versions.d.ts +13 -1
  95. package/dist/server/services/versions.d.ts.map +1 -1
  96. package/dist/server/services/versions.js +3 -1
  97. package/dist/server/services/versions.js.map +1 -1
  98. package/migrations/0010_transfers.sql +154 -0
  99. package/migrations/0011_templates.sql +157 -0
  100. package/migrations/meta/_journal.json +14 -0
  101. package/package.json +5 -5
  102. package/src/client/components/ExportDialog.svelte +685 -0
  103. package/src/client/components/ImportDialog.svelte +702 -0
  104. package/src/client/components/ImportReport.svelte +310 -0
  105. package/src/client/components/NewSpaceDialog.svelte +77 -8
  106. package/src/client/components/PageEditor.svelte +80 -0
  107. package/src/client/components/PagePicker.svelte +264 -0
  108. package/src/client/components/SaveAsTemplateDialog.svelte +502 -0
  109. package/src/client/components/SidebarSpaces.svelte +112 -1
  110. package/src/client/components/TemplatePicker.svelte +437 -0
  111. package/src/client/i18n.ts +941 -0
  112. package/src/client/index.ts +51 -0
  113. package/src/client/mock.ts +592 -0
  114. package/src/client/module.ts +36 -0
  115. package/src/client/pages/PageView.svelte +86 -0
  116. package/src/client/pages/SpacePage.svelte +20 -4
  117. package/src/client/pages/TransfersPage.svelte +570 -0
  118. package/src/client/permissions.ts +11 -0
  119. package/src/client/query.ts +40 -0
  120. package/src/client/transfers.ts +142 -0
  121. package/src/contract/models.ts +343 -0
  122. package/src/contract/permissions.ts +117 -0
  123. package/src/contract/router.ts +375 -0
@@ -0,0 +1,692 @@
1
+ const text = (value, marks) => marks && marks.length > 0 ? { type: 'text', text: value, marks } : { type: 'text', text: value };
2
+ const element = (type, content, attrs) => attrs ? { type, attrs, content } : { type, content };
3
+ const paragraph = (content) => ({ type: 'paragraph', content });
4
+ /** `info` | `note` | `success` | `warning` | `danger` — the writer's set, and the renderer's. */
5
+ const CALLOUT_TONES = new Set(['info', 'note', 'success', 'warning', 'danger']);
6
+ /**
7
+ * GitHub's own five alert names, mapped onto Quire's tones.
8
+ *
9
+ * The writer emits Quire's names, so `[!SUCCESS]` round-trips exactly; these are the ones a file
10
+ * written on GitHub carries, and mapping them is the difference between a coloured callout and a
11
+ * blockquote with `[!TIP]` sitting at the top of it as literal text.
12
+ */
13
+ const GITHUB_ALERTS = {
14
+ note: 'note',
15
+ tip: 'success',
16
+ important: 'info',
17
+ warning: 'warning',
18
+ caution: 'danger',
19
+ };
20
+ const RE_FENCE = /^ {0,3}(`{3,}|~{3,})[ \t]*(.*)$/;
21
+ const RE_ATX = /^ {0,3}(#{1,6})(?:[ \t]+(.*))?$/;
22
+ /**
23
+ * A heading's optional closing sequence, which **must be preceded by a space** — CommonMark 4.2.
24
+ *
25
+ * It used to be part of `RE_ATX` as a bare `#*[ \t]*$`, which ate a hash that was ordinary text:
26
+ * `# Sharp C#` came back one character short, on any hand-written file. Two things follow from
27
+ * getting it right. The first is that hash — text now, as the spec says. The second is that a
28
+ * *deliberate* closing sequence is the only case left, so the writer can escape its way past it:
29
+ * `# Roadmap \#` is not a closing sequence here — the run is preceded by a backslash rather than a
30
+ * space — and `unescapePunctuation` hands the hash back. That is how a page called `Roadmap #`
31
+ * keeps its name, and it does not work without this line. `markdownTitleLine` in
32
+ * `services/export.ts` is the other half.
33
+ */
34
+ const RE_ATX_CLOSING = /(^|[ \t])#+[ \t]*$/;
35
+ const atxContent = (raw) => (raw ?? '').replace(RE_ATX_CLOSING, '$1').trim();
36
+ const RE_RULE = /^ {0,3}(?:(?:\*[ \t]*){3,}|(?:-[ \t]*){3,}|(?:_[ \t]*){3,})$/;
37
+ const RE_QUOTE = /^ {0,3}>[ \t]?/;
38
+ const RE_ITEM = /^([ \t]*)([-*+]|\d{1,9}[.)])([ \t]+)(.*)$/;
39
+ const RE_EMPTY_ITEM = /^([ \t]*)([-*+]|\d{1,9}[.)])[ \t]*$/;
40
+ const RE_TABLE_DELIMITER = /^ {0,3}\|?[ \t]*:?-+:?[ \t]*(\|[ \t]*:?-+:?[ \t]*)*\|?[ \t]*$/;
41
+ const RE_SETEXT = /^ {0,3}(=+|-+)[ \t]*$/;
42
+ const RE_TASK = /^\[([ xX])\][ \t]+(.*)$/;
43
+ const RE_BREAK_TAG = /^<br\s*\/?>/i;
44
+ /** Tabs are four columns here, which is what every writer in this repository emits and reads. */
45
+ function indentOf(line) {
46
+ let width = 0;
47
+ for (const ch of line) {
48
+ if (ch === ' ')
49
+ width += 1;
50
+ else if (ch === '\t')
51
+ width += 4 - (width % 4);
52
+ else
53
+ break;
54
+ }
55
+ return width;
56
+ }
57
+ function dedent(line, width) {
58
+ let seen = 0;
59
+ let at = 0;
60
+ while (at < line.length && seen < width) {
61
+ const ch = line[at];
62
+ if (ch === ' ')
63
+ seen += 1;
64
+ else if (ch === '\t')
65
+ seen += 4 - (seen % 4);
66
+ else
67
+ break;
68
+ at++;
69
+ }
70
+ return line.slice(at);
71
+ }
72
+ const isBlank = (line) => line.trim().length === 0;
73
+ const INLINE_TAGS = { u: 'underline', mark: 'highlight' };
74
+ function findOpener(source, from) {
75
+ for (let i = from; i < source.length; i++) {
76
+ const ch = source[i];
77
+ if (ch === '\\') {
78
+ i++;
79
+ continue;
80
+ }
81
+ if (ch === '`') {
82
+ let run = i;
83
+ while (run < source.length && source[run] === '`')
84
+ run++;
85
+ return { at: i, kind: 'code', run: source.slice(i, run) };
86
+ }
87
+ if (ch === '!' && source[i + 1] === '[')
88
+ return { at: i, kind: 'image' };
89
+ if (ch === '[')
90
+ return { at: i, kind: 'link' };
91
+ if (ch === '<') {
92
+ const rest = source.slice(i);
93
+ if (RE_BREAK_TAG.test(rest))
94
+ return { at: i, kind: 'break' };
95
+ const tag = /^<(\/?)([a-zA-Z][a-zA-Z0-9]*)\b[^>]*>/.exec(rest);
96
+ if (tag && tag[1] === '' && INLINE_TAGS[tag[2].toLowerCase()])
97
+ return { at: i, kind: 'tag', tag: tag[2].toLowerCase() };
98
+ if (/^<[a-zA-Z][a-zA-Z0-9+.-]*:[^\s<>]*>/.test(rest) || /^<[^\s<>@]+@[^\s<>]+>/.test(rest))
99
+ return { at: i, kind: 'autolink' };
100
+ continue;
101
+ }
102
+ if (ch === '~' && source[i + 1] === '~')
103
+ return { at: i, kind: 'strike' };
104
+ if (ch === '*' || ch === '_') {
105
+ let run = i;
106
+ while (run < source.length && source[run] === ch)
107
+ run++;
108
+ // Three or more is bold-and-italic; the nesting falls out of parsing the inner run again.
109
+ return { at: i, kind: 'emphasis', run: source.slice(i, Math.min(run, i + 3)) };
110
+ }
111
+ }
112
+ return null;
113
+ }
114
+ /** Undo `\x` for every punctuation character a writer may have escaped. */
115
+ const unescapePunctuation = (value) => value.replace(/\\([\\`*_[\]<>|~#+.!()\-{}"'$&/:;=?@^])/g, '$1');
116
+ /** The matching `]` for a `[` at `from`, honouring nesting and escapes. */
117
+ function closingBracket(source, from) {
118
+ let depth = 0;
119
+ for (let i = from; i < source.length; i++) {
120
+ const ch = source[i];
121
+ if (ch === '\\') {
122
+ i++;
123
+ continue;
124
+ }
125
+ if (ch === '[')
126
+ depth++;
127
+ else if (ch === ']') {
128
+ depth--;
129
+ if (depth === 0)
130
+ return i;
131
+ }
132
+ }
133
+ return -1;
134
+ }
135
+ /** The `(target "title")` after a link or image label, or null when there is none. */
136
+ function linkTarget(source, from) {
137
+ if (source[from] !== '(')
138
+ return null;
139
+ let depth = 0;
140
+ let end = -1;
141
+ for (let i = from; i < source.length; i++) {
142
+ const ch = source[i];
143
+ if (ch === '\\') {
144
+ i++;
145
+ continue;
146
+ }
147
+ if (ch === '(')
148
+ depth++;
149
+ else if (ch === ')') {
150
+ depth--;
151
+ if (depth === 0) {
152
+ end = i;
153
+ break;
154
+ }
155
+ }
156
+ }
157
+ if (end < 0)
158
+ return null;
159
+ const inner = source.slice(from + 1, end).trim();
160
+ // `<a target with spaces>` is the angle form the writer uses whenever the target needs it.
161
+ const angled = /^<([^>]*)>[ \t]*([\s\S]*)$/.exec(inner);
162
+ const href = angled ? angled[1] : (/^\S*/.exec(inner)?.[0] ?? '');
163
+ const tail = (angled ? angled[2] : inner.slice(href.length)).trim();
164
+ const title = /^"([\s\S]*)"$|^'([\s\S]*)'$/.exec(tail);
165
+ return {
166
+ href: unescapePunctuation(href),
167
+ title: title ? (title[1] ?? title[2] ?? null) : null,
168
+ end: end + 1,
169
+ };
170
+ }
171
+ function withMark(nodes, mark) {
172
+ return nodes.map((node) => typeof node.text === 'string'
173
+ ? // A mark already present wins: `**[a](b)**` must not carry two `link` marks.
174
+ { ...node, marks: [...(node.marks ?? []).filter((m) => m.type !== mark.type), mark] }
175
+ : node);
176
+ }
177
+ /**
178
+ * One paragraph (or one table cell) of prose as inline nodes.
179
+ *
180
+ * A `\n` still in `source` is a soft wrap and becomes the space a reader would have rendered; a hard
181
+ * break arrived as `<br>`, which `findOpener` sees like any other inline construct.
182
+ */
183
+ export function inlineNodes(source) {
184
+ const out = [];
185
+ const push = (nodes) => {
186
+ for (const node of nodes) {
187
+ const last = out.at(-1);
188
+ // Merge adjacent text carrying identical marks, so `a\*b` is one node rather than three.
189
+ if (last &&
190
+ typeof last.text === 'string' &&
191
+ typeof node.text === 'string' &&
192
+ JSON.stringify(last.marks ?? null) === JSON.stringify(node.marks ?? null))
193
+ last.text += node.text;
194
+ else
195
+ out.push(node);
196
+ }
197
+ };
198
+ const plain = (value) => {
199
+ const body = unescapePunctuation(value).replace(/\n/g, ' ');
200
+ if (body.length > 0)
201
+ push([text(body)]);
202
+ };
203
+ let at = 0;
204
+ while (at < source.length) {
205
+ const opener = findOpener(source, at);
206
+ if (!opener)
207
+ break;
208
+ const consumed = consume(source, opener);
209
+ if (!consumed) {
210
+ // Not a construct after all — `[` with no `]`, an unmatched `*`. Take the character as text.
211
+ plain(source.slice(at, opener.at + 1));
212
+ at = opener.at + 1;
213
+ continue;
214
+ }
215
+ plain(source.slice(at, opener.at));
216
+ push(consumed.nodes);
217
+ at = consumed.end;
218
+ }
219
+ plain(source.slice(at));
220
+ return out;
221
+ }
222
+ function consume(source, opener) {
223
+ const { at, kind } = opener;
224
+ if (kind === 'break') {
225
+ const match = RE_BREAK_TAG.exec(source.slice(at));
226
+ if (!match)
227
+ return null;
228
+ return { nodes: [{ type: 'hardBreak' }], end: at + match[0].length };
229
+ }
230
+ if (kind === 'code') {
231
+ const fence = opener.run;
232
+ const close = source.indexOf(fence, at + fence.length);
233
+ if (close < 0)
234
+ return null;
235
+ let body = source.slice(at + fence.length, close);
236
+ // A code span padded with one space at both ends had it added so the content could start or end
237
+ // with a backtick; the writer next door adds it for exactly that, and nothing else does.
238
+ if (body.length > 2 && body.startsWith(' ') && body.endsWith(' '))
239
+ body = body.slice(1, -1);
240
+ // Code is literal, so a wrap inside one is a space rather than a break somebody asked for.
241
+ return { nodes: [text(body.replace(/\n/g, ' '), [{ type: 'code' }])], end: close + fence.length };
242
+ }
243
+ if (kind === 'autolink') {
244
+ const match = /^<([^\s<>]+)>/.exec(source.slice(at));
245
+ if (!match)
246
+ return null;
247
+ const href = match[1];
248
+ const target = href.includes('@') && !href.includes(':') ? `mailto:${href}` : href;
249
+ return { nodes: [text(href, [{ type: 'link', attrs: { href: target } }])], end: at + match[0].length };
250
+ }
251
+ if (kind === 'tag') {
252
+ const name = opener.tag;
253
+ const open = /^<[a-zA-Z][a-zA-Z0-9]*\b[^>]*>/.exec(source.slice(at));
254
+ if (!open)
255
+ return null;
256
+ const closeAt = source.toLowerCase().indexOf(`</${name}>`, at + open[0].length);
257
+ if (closeAt < 0)
258
+ return null;
259
+ const inner = source.slice(at + open[0].length, closeAt);
260
+ return {
261
+ nodes: withMark(inlineNodes(inner), { type: INLINE_TAGS[name] }),
262
+ end: closeAt + name.length + 3,
263
+ };
264
+ }
265
+ if (kind === 'image' || kind === 'link') {
266
+ const labelStart = kind === 'image' ? at + 1 : at;
267
+ const labelEnd = closingBracket(source, labelStart);
268
+ if (labelEnd < 0)
269
+ return null;
270
+ const target = linkTarget(source, labelEnd + 1);
271
+ if (!target)
272
+ return null;
273
+ const label = source.slice(labelStart + 1, labelEnd);
274
+ if (kind === 'image') {
275
+ const attrs = {
276
+ src: target.href,
277
+ alt: unescapePunctuation(label).replace(/\s+/g, ' '),
278
+ };
279
+ if (target.title)
280
+ attrs.title = target.title;
281
+ return { nodes: [{ type: 'image', attrs }], end: target.end };
282
+ }
283
+ const mark = { type: 'link', attrs: { href: target.href } };
284
+ const inner = inlineNodes(label);
285
+ return { nodes: withMark(inner.length > 0 ? inner : [text(label)], mark), end: target.end };
286
+ }
287
+ if (kind === 'strike') {
288
+ const close = source.indexOf('~~', at + 2);
289
+ if (close < 0)
290
+ return null;
291
+ return { nodes: withMark(inlineNodes(source.slice(at + 2, close)), { type: 'strike' }), end: close + 2 };
292
+ }
293
+ // Emphasis. The run length decides which marks go on, and the closer is the same character.
294
+ const run = opener.run;
295
+ const ch = run[0];
296
+ for (let length = run.length; length >= 1; length--) {
297
+ const close = findEmphasisClose(source, at + run.length, ch, length);
298
+ if (close < 0)
299
+ continue;
300
+ let nodes = inlineNodes(source.slice(at + length, close));
301
+ if (length >= 2)
302
+ nodes = withMark(nodes, { type: 'bold' });
303
+ if (length === 1 || length === 3)
304
+ nodes = withMark(nodes, { type: 'italic' });
305
+ return { nodes, end: close + length };
306
+ }
307
+ return null;
308
+ }
309
+ /** The next run of at least `length` of `ch` that is neither escaped nor inside a code span. */
310
+ function findEmphasisClose(source, from, ch, length) {
311
+ for (let i = from; i < source.length; i++) {
312
+ if (source[i] === '\\') {
313
+ i++;
314
+ continue;
315
+ }
316
+ if (source[i] === '`') {
317
+ let run = i;
318
+ while (run < source.length && source[run] === '`')
319
+ run++;
320
+ const fence = source.slice(i, run);
321
+ const close = source.indexOf(fence, run);
322
+ i = close < 0 ? source.length : close + fence.length - 1;
323
+ continue;
324
+ }
325
+ if (source[i] !== ch)
326
+ continue;
327
+ let run = i;
328
+ while (run < source.length && source[run] === ch)
329
+ run++;
330
+ if (run - i >= length)
331
+ return i;
332
+ i = run - 1;
333
+ }
334
+ return -1;
335
+ }
336
+ // ------------------------------------------------------------------------------------------------
337
+ // Blocks
338
+ // ------------------------------------------------------------------------------------------------
339
+ /**
340
+ * A paragraph's lines joined into one string, with every hard break spelled `<br>`.
341
+ *
342
+ * Joining first is what lets an emphasis run or a link label wrap across a line, which the writer
343
+ * never does and a person writing by hand does constantly.
344
+ */
345
+ function paragraphFrom(lines) {
346
+ const joined = lines
347
+ .map((line, index) => {
348
+ const trimmed = line.replace(/[ \t]+$/, '');
349
+ const last = index === lines.length - 1;
350
+ const hard = !last && (/ {2,}$/.test(line) || trimmed.endsWith('\\'));
351
+ const body = hard && trimmed.endsWith('\\') ? trimmed.slice(0, -1) : trimmed;
352
+ return last ? body : `${body}${hard ? '<br>' : '\n'}`;
353
+ })
354
+ .join('');
355
+ const content = inlineNodes(joined);
356
+ return content.length > 0 ? paragraph(content) : null;
357
+ }
358
+ /**
359
+ * A run of list items at one indent, grouped by the kind of marker they carry.
360
+ *
361
+ * Grouping rather than one kind per run is what makes the writer round-trip: a `taskList` is written
362
+ * as `- [x] …` items, so a document holding a bullet list and a task list one after the other comes
363
+ * back through the same run of lines and has to split into two nodes again.
364
+ */
365
+ function parseListRun(lines, from) {
366
+ const items = [];
367
+ const baseIndent = indentOf(lines[from]);
368
+ let at = from;
369
+ while (at < lines.length) {
370
+ const line = lines[at];
371
+ if (isBlank(line)) {
372
+ // A blank line ends the list unless what follows continues an item or starts the next one.
373
+ let ahead = at;
374
+ while (ahead < lines.length && isBlank(lines[ahead]))
375
+ ahead++;
376
+ if (ahead >= lines.length)
377
+ break;
378
+ const nextIndent = indentOf(lines[ahead]);
379
+ const startsItem = RE_ITEM.test(lines[ahead]) || RE_EMPTY_ITEM.test(lines[ahead]);
380
+ if (!(nextIndent > baseIndent || (startsItem && nextIndent === baseIndent)))
381
+ break;
382
+ const current = items.at(-1);
383
+ if (current)
384
+ for (let i = at; i < ahead; i++)
385
+ current.lines.push('');
386
+ at = ahead;
387
+ continue;
388
+ }
389
+ const match = RE_ITEM.exec(line) ?? RE_EMPTY_ITEM.exec(line);
390
+ const indent = indentOf(line);
391
+ if (match && indent === baseIndent) {
392
+ const marker = match[2];
393
+ const rest = match[4] ?? '';
394
+ const task = RE_TASK.exec(rest);
395
+ const ordered = /^\d/.test(marker);
396
+ items.push({
397
+ kind: task ? 'task' : ordered ? 'ordered' : 'bullet',
398
+ checked: task ? task[1].toLowerCase() === 'x' : false,
399
+ start: ordered ? Number.parseInt(marker, 10) : 1,
400
+ lines: [task ? task[2] : rest],
401
+ });
402
+ at++;
403
+ continue;
404
+ }
405
+ if (indent <= baseIndent)
406
+ break;
407
+ // A continuation line: kept, dedented towards the item's own content column.
408
+ const current = items.at(-1);
409
+ if (!current)
410
+ break;
411
+ current.lines.push(dedent(line, baseIndent + 2));
412
+ at++;
413
+ }
414
+ // Trailing blank lines belong to the document, not to the last item.
415
+ const last = items.at(-1);
416
+ while (last && last.lines.length > 0 && isBlank(last.lines.at(-1)))
417
+ last.lines.pop();
418
+ const nodes = [];
419
+ let run = [];
420
+ const flush = () => {
421
+ if (run.length === 0)
422
+ return;
423
+ const kind = run[0].kind;
424
+ if (kind === 'task')
425
+ nodes.push(element('taskList', run.map((item) => element('taskItem', blocksOf(item.lines), { checked: item.checked }))));
426
+ else if (kind === 'ordered')
427
+ nodes.push(element('orderedList', run.map((item) => element('listItem', blocksOf(item.lines))), { start: run[0].start }));
428
+ else
429
+ nodes.push(element('bulletList', run.map((item) => element('listItem', blocksOf(item.lines)))));
430
+ run = [];
431
+ };
432
+ for (const item of items) {
433
+ if (run.length > 0 && run[0].kind !== item.kind)
434
+ flush();
435
+ run.push(item);
436
+ }
437
+ flush();
438
+ return { nodes, next: at };
439
+ }
440
+ /** A GFM table: a header row, a delimiter row, and every row after them that still holds a pipe. */
441
+ function parseTable(lines, from) {
442
+ const cellsOf = (line) => {
443
+ const trimmed = line.trim().replace(/^\|/, '').replace(/\|$/, '');
444
+ const out = [];
445
+ let cell = '';
446
+ for (let i = 0; i < trimmed.length; i++) {
447
+ const ch = trimmed[i];
448
+ if (ch === '\\' && trimmed[i + 1] === '|') {
449
+ cell += '|';
450
+ i++;
451
+ continue;
452
+ }
453
+ if (ch === '\\' && trimmed[i + 1] !== undefined) {
454
+ cell += ch + trimmed[i + 1];
455
+ i++;
456
+ continue;
457
+ }
458
+ if (ch === '|') {
459
+ out.push(cell);
460
+ cell = '';
461
+ continue;
462
+ }
463
+ cell += ch;
464
+ }
465
+ out.push(cell);
466
+ return out.map((value) => value.trim());
467
+ };
468
+ const header = cellsOf(lines[from]);
469
+ const width = header.length;
470
+ const bodyRows = [];
471
+ let at = from + 2;
472
+ while (at < lines.length && !isBlank(lines[at]) && lines[at].includes('|')) {
473
+ bodyRows.push(cellsOf(lines[at]));
474
+ at++;
475
+ }
476
+ // A cell holds inline content only — GFM cannot spell a second paragraph — so the `<br>` pairs the
477
+ // writer put there come back as hard breaks, which `inlineNodes` already knows how to read.
478
+ const cell = (type, value) => element(type, [paragraph(inlineNodes(value))]);
479
+ const row = (cells, type) => {
480
+ const padded = [...cells];
481
+ while (padded.length < width)
482
+ padded.push('');
483
+ return element('tableRow', padded.slice(0, width).map((value) => cell(type, value)));
484
+ };
485
+ return {
486
+ node: element('table', [row(header, 'tableHeader'), ...bodyRows.map((cells) => row(cells, 'tableCell'))]),
487
+ next: at,
488
+ };
489
+ }
490
+ /** `<details><summary>…</summary> … </details>`, which is how the writer spells a toggle. */
491
+ function parseDetails(lines, from) {
492
+ const body = [];
493
+ let depth = 0;
494
+ let at = from;
495
+ for (; at < lines.length; at++) {
496
+ const line = lines[at];
497
+ depth += (line.match(/<details\b/gi) ?? []).length - (line.match(/<\/details>/gi) ?? []).length;
498
+ body.push(line);
499
+ if (depth <= 0) {
500
+ at++;
501
+ break;
502
+ }
503
+ }
504
+ const joined = body.join('\n');
505
+ const inner = /<details\b[^>]*>([\s\S]*)<\/details>/i.exec(joined)?.[1] ?? '';
506
+ const summary = /<summary\b[^>]*>([\s\S]*?)<\/summary>/i.exec(inner);
507
+ const rest = summary ? inner.replace(summary[0], '') : inner;
508
+ const content = blocksOf(rest.split('\n'));
509
+ return {
510
+ node: element('details', [
511
+ element('detailsSummary', inlineNodes((summary?.[1] ?? '').trim())),
512
+ element('detailsContent', content.length > 0 ? content : [paragraph([])]),
513
+ ]),
514
+ next: at,
515
+ };
516
+ }
517
+ /** A blockquote, or the callout a GitHub alert marker turns it into. */
518
+ function parseQuote(lines, from) {
519
+ const inner = [];
520
+ let at = from;
521
+ while (at < lines.length && (RE_QUOTE.test(lines[at]) || (inner.length > 0 && !isBlank(lines[at])))) {
522
+ // A lazy continuation — a wrapped line with no `>` — belongs to the quote it continues.
523
+ inner.push(RE_QUOTE.test(lines[at]) ? lines[at].replace(RE_QUOTE, '') : lines[at]);
524
+ at++;
525
+ }
526
+ const alert = /^\[!([A-Za-z]+)\][ \t]*$/.exec(inner[0]?.trim() ?? '');
527
+ if (alert) {
528
+ const name = alert[1].toLowerCase();
529
+ const tone = CALLOUT_TONES.has(name) ? name : (GITHUB_ALERTS[name] ?? 'info');
530
+ const body = blocksOf(inner.slice(1));
531
+ return { node: element('callout', body.length > 0 ? body : [paragraph([])], { tone }), next: at };
532
+ }
533
+ const body = blocksOf(inner);
534
+ return { node: element('blockquote', body.length > 0 ? body : [paragraph([])]), next: at };
535
+ }
536
+ /**
537
+ * Everything in one run of lines, as blocks.
538
+ *
539
+ * Recursive: a list item, a blockquote and a toggle each re-enter here with their own dedented lines,
540
+ * which is what makes nesting work without a parser per depth.
541
+ */
542
+ function blocksOf(lines) {
543
+ const out = [];
544
+ let paragraphLines = [];
545
+ const flushParagraph = () => {
546
+ if (paragraphLines.length === 0)
547
+ return;
548
+ const node = paragraphFrom(paragraphLines);
549
+ if (node)
550
+ out.push(node);
551
+ paragraphLines = [];
552
+ };
553
+ for (let at = 0; at < lines.length;) {
554
+ const line = lines[at];
555
+ if (isBlank(line)) {
556
+ flushParagraph();
557
+ at++;
558
+ continue;
559
+ }
560
+ const fence = RE_FENCE.exec(line);
561
+ if (fence) {
562
+ flushParagraph();
563
+ const marker = fence[1];
564
+ const info = fence[2].trim();
565
+ const closer = new RegExp(`^ {0,3}\\${marker[0]}{${marker.length},}[ \\t]*$`);
566
+ const body = [];
567
+ at++;
568
+ while (at < lines.length && !closer.test(lines[at])) {
569
+ body.push(lines[at]);
570
+ at++;
571
+ }
572
+ // A fence that never closes still holds a code block; the alternative is losing the snippet.
573
+ if (at < lines.length)
574
+ at++;
575
+ const language = /^[a-z0-9#+.-]{1,24}$/i.test(info) ? info : '';
576
+ out.push(element('codeBlock', body.length > 0 ? [text(body.join('\n'))] : [], language ? { language } : undefined));
577
+ continue;
578
+ }
579
+ const atx = RE_ATX.exec(line);
580
+ if (atx) {
581
+ flushParagraph();
582
+ out.push(element('heading', inlineNodes(atxContent(atx[2])), { level: atx[1].length }));
583
+ at++;
584
+ continue;
585
+ }
586
+ /*
587
+ * A setext underline turns the paragraph above it into a heading, and `---` is a thematic rule
588
+ * only when there is no paragraph above it. That is the one place where two block rules genuinely
589
+ * overlap, and getting it the wrong way round turns `Heading\n---` into a rule and loses the
590
+ * heading — plenty of hand-written Markdown is spelled that way, so it is not hypothetical.
591
+ */
592
+ const setext = RE_SETEXT.exec(line);
593
+ if (setext && paragraphLines.length > 0) {
594
+ const level = setext[1].startsWith('=') ? 1 : 2;
595
+ const heading = paragraphLines.join(' ').trim();
596
+ paragraphLines = [];
597
+ out.push(element('heading', inlineNodes(heading), { level }));
598
+ at++;
599
+ continue;
600
+ }
601
+ if (RE_RULE.test(line)) {
602
+ flushParagraph();
603
+ out.push({ type: 'horizontalRule' });
604
+ at++;
605
+ continue;
606
+ }
607
+ if (RE_QUOTE.test(line)) {
608
+ flushParagraph();
609
+ const quote = parseQuote(lines, at);
610
+ out.push(quote.node);
611
+ at = quote.next;
612
+ continue;
613
+ }
614
+ if (/^ {0,3}<details\b/i.test(line)) {
615
+ flushParagraph();
616
+ const details = parseDetails(lines, at);
617
+ out.push(details.node);
618
+ at = details.next;
619
+ continue;
620
+ }
621
+ if (RE_ITEM.test(line) || RE_EMPTY_ITEM.test(line)) {
622
+ flushParagraph();
623
+ const list = parseListRun(lines, at);
624
+ // A run that consumed nothing would loop for ever; taking the line as prose is the safe exit.
625
+ if (list.next > at) {
626
+ out.push(...list.nodes);
627
+ at = list.next;
628
+ continue;
629
+ }
630
+ paragraphLines.push(line);
631
+ at++;
632
+ continue;
633
+ }
634
+ if (line.includes('|') && at + 1 < lines.length && RE_TABLE_DELIMITER.test(lines[at + 1])) {
635
+ flushParagraph();
636
+ const table = parseTable(lines, at);
637
+ out.push(table.node);
638
+ at = table.next;
639
+ continue;
640
+ }
641
+ paragraphLines.push(line);
642
+ at++;
643
+ }
644
+ flushParagraph();
645
+ return out;
646
+ }
647
+ /** Inline nodes flattened, for a title — where marks are meaningless and the characters are not. */
648
+ function plainText(nodes) {
649
+ return nodes
650
+ .map((node) => (typeof node.text === 'string' ? node.text : plainText(node.content ?? [])))
651
+ .join('');
652
+ }
653
+ /**
654
+ * A page's title and its body, split.
655
+ *
656
+ * A title is a **column** in Quire, not a node in its document, so the `# Title` the writer puts at
657
+ * the top of every exported file has to come back off the top rather than becoming an H1 inside the
658
+ * page. Notion writes the same shape, and Notion's is the one that matters: its filename has been
659
+ * through a sanitiser that turns `Q3/Q4 Handover` into `Q3Q4 Handover`, while the heading still says
660
+ * what the page is called. Taking the title from the heading is the difference between importing a
661
+ * page under its own name and importing it under a name nobody chose.
662
+ *
663
+ * Only a level-1 heading, and only as the first non-blank line: a file that opens with prose keeps
664
+ * its prose, and a file whose first heading is `##` is one whose author did not mean it as a title.
665
+ */
666
+ export function splitTitle(source) {
667
+ const lines = source.split(/\r\n|\r|\n/);
668
+ let at = 0;
669
+ while (at < lines.length && isBlank(lines[at]))
670
+ at++;
671
+ const atx = RE_ATX.exec(lines[at] ?? '');
672
+ if (atx && atx[1].length === 1) {
673
+ const title = plainText(inlineNodes(atxContent(atx[2]))).trim();
674
+ return { title: title || null, body: lines.slice(at + 1).join('\n') };
675
+ }
676
+ const underline = RE_SETEXT.exec(lines[at + 1] ?? '');
677
+ if (underline?.[1]?.startsWith('=') && !isBlank(lines[at] ?? '')) {
678
+ const title = plainText(inlineNodes(lines[at].trim())).trim();
679
+ return { title: title || null, body: lines.slice(at + 2).join('\n') };
680
+ }
681
+ return { title: null, body: source };
682
+ }
683
+ /**
684
+ * Markdown as a page document.
685
+ *
686
+ * Always a `doc`, never null: a file holding nothing but its own title is an empty page, and an empty
687
+ * page is a correct import of an empty page.
688
+ */
689
+ export function markdownToPageDoc(source) {
690
+ return { type: 'doc', content: blocksOf(source.replace(/\r\n|\r/g, '\n').split('\n')) };
691
+ }
692
+ //# sourceMappingURL=markdown.js.map