@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.
- package/dist/contract/models.d.ts +394 -0
- package/dist/contract/models.d.ts.map +1 -1
- package/dist/contract/models.js +284 -0
- package/dist/contract/models.js.map +1 -1
- package/dist/contract/permissions.d.ts +14 -0
- package/dist/contract/permissions.d.ts.map +1 -1
- package/dist/contract/permissions.js +114 -0
- package/dist/contract/permissions.js.map +1 -1
- package/dist/contract/properties.d.ts +7 -7
- package/dist/contract/router.d.ts +1124 -96
- package/dist/contract/router.d.ts.map +1 -1
- package/dist/contract/router.js +343 -1
- package/dist/contract/router.js.map +1 -1
- package/dist/server/_impl.d.ts +983 -1168
- package/dist/server/_impl.d.ts.map +1 -1
- package/dist/server/_impl.js +308 -0
- package/dist/server/_impl.js.map +1 -1
- package/dist/server/export/html.d.ts +67 -0
- package/dist/server/export/html.d.ts.map +1 -0
- package/dist/server/export/html.js +206 -0
- package/dist/server/export/html.js.map +1 -0
- package/dist/server/export/markdown.d.ts +51 -0
- package/dist/server/export/markdown.d.ts.map +1 -0
- package/dist/server/export/markdown.js +351 -0
- package/dist/server/export/markdown.js.map +1 -0
- package/dist/server/export/pdf.d.ts +20 -0
- package/dist/server/export/pdf.d.ts.map +1 -0
- package/dist/server/export/pdf.js +91 -0
- package/dist/server/export/pdf.js.map +1 -0
- package/dist/server/export/zip.d.ts +31 -0
- package/dist/server/export/zip.d.ts.map +1 -0
- package/dist/server/export/zip.js +158 -0
- package/dist/server/export/zip.js.map +1 -0
- package/dist/server/import/csv.d.ts +77 -0
- package/dist/server/import/csv.d.ts.map +1 -0
- package/dist/server/import/csv.js +263 -0
- package/dist/server/import/csv.js.map +1 -0
- package/dist/server/import/html.d.ts +52 -0
- package/dist/server/import/html.d.ts.map +1 -0
- package/dist/server/import/html.js +472 -0
- package/dist/server/import/html.js.map +1 -0
- package/dist/server/import/markdown.d.ts +63 -0
- package/dist/server/import/markdown.d.ts.map +1 -0
- package/dist/server/import/markdown.js +692 -0
- package/dist/server/import/markdown.js.map +1 -0
- package/dist/server/import/plan.d.ts +70 -0
- package/dist/server/import/plan.d.ts.map +1 -0
- package/dist/server/import/plan.js +761 -0
- package/dist/server/import/plan.js.map +1 -0
- package/dist/server/import/ydoc.d.ts +35 -0
- package/dist/server/import/ydoc.d.ts.map +1 -0
- package/dist/server/import/ydoc.js +91 -0
- package/dist/server/import/ydoc.js.map +1 -0
- package/dist/server/import/zip.d.ts +63 -0
- package/dist/server/import/zip.d.ts.map +1 -0
- package/dist/server/import/zip.js +308 -0
- package/dist/server/import/zip.js.map +1 -0
- package/dist/server/index.d.ts.map +1 -1
- package/dist/server/index.js +3 -1
- package/dist/server/index.js.map +1 -1
- package/dist/server/render.d.ts +158 -0
- package/dist/server/render.d.ts.map +1 -1
- package/dist/server/render.js +240 -0
- package/dist/server/render.js.map +1 -1
- package/dist/server/schema.d.ts +704 -1
- package/dist/server/schema.d.ts.map +1 -1
- package/dist/server/schema.js +254 -1
- package/dist/server/schema.js.map +1 -1
- package/dist/server/services/databases.d.ts +10 -10
- package/dist/server/services/export.d.ts +176 -0
- package/dist/server/services/export.d.ts.map +1 -0
- package/dist/server/services/export.js +822 -0
- package/dist/server/services/export.js.map +1 -0
- package/dist/server/services/import.d.ts +109 -0
- package/dist/server/services/import.d.ts.map +1 -0
- package/dist/server/services/import.js +570 -0
- package/dist/server/services/import.js.map +1 -0
- package/dist/server/services/index.d.ts +32 -1
- package/dist/server/services/index.d.ts.map +1 -1
- package/dist/server/services/index.js +87 -4
- package/dist/server/services/index.js.map +1 -1
- package/dist/server/services/macros.d.ts +83 -0
- package/dist/server/services/macros.d.ts.map +1 -0
- package/dist/server/services/macros.js +488 -0
- package/dist/server/services/macros.js.map +1 -0
- package/dist/server/services/publications.d.ts +2 -1
- package/dist/server/services/publications.d.ts.map +1 -1
- package/dist/server/services/publications.js +43 -4
- package/dist/server/services/publications.js.map +1 -1
- package/dist/server/services/templates.d.ts +135 -0
- package/dist/server/services/templates.d.ts.map +1 -0
- package/dist/server/services/templates.js +897 -0
- package/dist/server/services/templates.js.map +1 -0
- package/dist/server/services/versions.d.ts +13 -1
- package/dist/server/services/versions.d.ts.map +1 -1
- package/dist/server/services/versions.js +3 -1
- package/dist/server/services/versions.js.map +1 -1
- package/migrations/0010_transfers.sql +154 -0
- package/migrations/0011_templates.sql +157 -0
- package/migrations/meta/_journal.json +14 -0
- package/package.json +5 -5
- package/src/client/components/ExportDialog.svelte +685 -0
- package/src/client/components/ImportDialog.svelte +702 -0
- package/src/client/components/ImportReport.svelte +310 -0
- package/src/client/components/NewSpaceDialog.svelte +77 -8
- package/src/client/components/PageEditor.svelte +80 -0
- package/src/client/components/PagePicker.svelte +264 -0
- package/src/client/components/SaveAsTemplateDialog.svelte +502 -0
- package/src/client/components/SidebarSpaces.svelte +112 -1
- package/src/client/components/TemplatePicker.svelte +437 -0
- package/src/client/i18n.ts +941 -0
- package/src/client/index.ts +51 -0
- package/src/client/mock.ts +592 -0
- package/src/client/module.ts +36 -0
- package/src/client/pages/PageView.svelte +86 -0
- package/src/client/pages/SpacePage.svelte +20 -4
- package/src/client/pages/TransfersPage.svelte +570 -0
- package/src/client/permissions.ts +11 -0
- package/src/client/query.ts +40 -0
- package/src/client/transfers.ts +142 -0
- package/src/contract/models.ts +343 -0
- package/src/contract/permissions.ts +117 -0
- 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
|