domma-cms 1.8.32 → 1.8.34

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 (110) hide show
  1. package/CHANGELOG.md +9 -0
  2. package/docs/DommaFeatures.md +13 -1
  3. package/package.json +1 -1
  4. package/plugins/_lib/admin/games/games.css +1 -1
  5. package/plugins/_lib/admin/games/kit.js +329 -124
  6. package/plugins/_lib/games/http.js +11 -1
  7. package/plugins/_lib/games/plays.js +28 -7
  8. package/plugins/crossword/CLAUDE.md +37 -0
  9. package/plugins/crossword/admin/css/index.css +18 -1
  10. package/plugins/crossword/admin/views/crossword.js +337 -205
  11. package/plugins/crossword/docs/guide.md +53 -0
  12. package/plugins/crossword/inject/body.html +1 -1
  13. package/plugins/crossword/plugin.js +38 -5
  14. package/plugins/crossword/plugin.json +4 -3
  15. package/plugins/crossword/public/crossword.css +36 -1
  16. package/plugins/crossword/public/crossword.js +468 -6
  17. package/plugins/docs/CLAUDE.md +318 -0
  18. package/plugins/docs/admin/css/docs.css +783 -0
  19. package/plugins/docs/admin/lib/api.js +340 -0
  20. package/plugins/docs/admin/lib/convert.js +482 -0
  21. package/plugins/docs/admin/lib/drafts.js +115 -0
  22. package/plugins/docs/admin/lib/editor.js +851 -0
  23. package/plugins/docs/admin/lib/find-replace.js +426 -0
  24. package/plugins/docs/admin/lib/folders.js +203 -0
  25. package/plugins/docs/admin/lib/panel-guard.js +73 -0
  26. package/plugins/docs/admin/lib/pdf-view.js +126 -0
  27. package/plugins/docs/admin/lib/send.js +156 -0
  28. package/plugins/docs/admin/lib/settings.js +142 -0
  29. package/plugins/docs/admin/lib/share.js +323 -0
  30. package/plugins/docs/admin/lib/shortcuts.js +44 -0
  31. package/plugins/docs/admin/lib/templates.js +194 -0
  32. package/plugins/docs/admin/lib/versions.js +192 -0
  33. package/plugins/docs/admin/templates/docs.html +92 -0
  34. package/plugins/docs/admin/templates/editor.html +86 -0
  35. package/plugins/docs/admin/views/docs.js +777 -0
  36. package/plugins/docs/admin/views/editor.js +978 -0
  37. package/plugins/docs/config.js +24 -0
  38. package/plugins/docs/docs/guide.md +270 -0
  39. package/plugins/docs/lib/extensions.js +213 -0
  40. package/plugins/docs/lib/importer.js +72 -0
  41. package/plugins/docs/lib/pdf-worker.js +48 -0
  42. package/plugins/docs/lib/pdf.js +222 -0
  43. package/plugins/docs/lib/store.js +748 -0
  44. package/plugins/docs/plugin.js +1108 -0
  45. package/plugins/docs/plugin.json +89 -0
  46. package/plugins/docs/plugin.public.js +114 -0
  47. package/plugins/docs/templates/public-doc.html +36 -0
  48. package/plugins/docs/tests/_env.js +10 -0
  49. package/plugins/docs/tests/_pdf.js +73 -0
  50. package/plugins/docs/tests/convert.test.js +95 -0
  51. package/plugins/docs/tests/drafts.test.js +82 -0
  52. package/plugins/docs/tests/pdf.test.js +86 -0
  53. package/plugins/docs/tests/plugin.test.js +816 -0
  54. package/plugins/docs/tests/shortcuts.test.js +30 -0
  55. package/plugins/free-tier.lock.json +147 -1
  56. package/plugins/garage/CLAUDE.md +51 -0
  57. package/plugins/garage/admin/css/garage.css +73 -0
  58. package/plugins/garage/admin/lib/garage.js +236 -0
  59. package/plugins/garage/admin/templates/garage.html +86 -0
  60. package/plugins/garage/admin/views/garage.js +581 -0
  61. package/plugins/garage/collections/garage-vehicles/schema.json +101 -0
  62. package/plugins/garage/config.js +18 -0
  63. package/plugins/garage/docs/guide.md +118 -0
  64. package/plugins/garage/plugin.js +471 -0
  65. package/plugins/garage/plugin.json +68 -0
  66. package/plugins/garage/tests/access.test.js +134 -0
  67. package/plugins/garage/tests/garage.test.js +106 -0
  68. package/plugins/messages/CLAUDE.md +230 -0
  69. package/plugins/messages/admin/css/messages.css +117 -0
  70. package/plugins/messages/admin/lib/messages.js +452 -0
  71. package/plugins/messages/admin/templates/messages.html +76 -0
  72. package/plugins/messages/admin/views/messages.js +1219 -0
  73. package/plugins/messages/collections/user-message-threads/schema.json +22 -0
  74. package/plugins/messages/collections/user-messages/schema.json +23 -0
  75. package/plugins/messages/config.js +14 -0
  76. package/plugins/messages/docs/guide.md +176 -0
  77. package/plugins/messages/lib/call.js +138 -0
  78. package/plugins/messages/plugin.js +994 -0
  79. package/plugins/messages/plugin.json +59 -0
  80. package/plugins/messages/plugin.public.js +52 -0
  81. package/plugins/messages/templates/call.html +6 -0
  82. package/plugins/messages/tests/after-send.test.js +22 -0
  83. package/plugins/messages/tests/api.test.js +187 -0
  84. package/plugins/messages/tests/call.test.js +497 -0
  85. package/plugins/messages/tests/drafts.test.js +25 -0
  86. package/plugins/messages/tests/messages.test.js +167 -0
  87. package/plugins/quiz/CLAUDE.md +43 -0
  88. package/plugins/quiz/admin/css/index.css +13 -1
  89. package/plugins/quiz/admin/views/quiz.js +423 -245
  90. package/plugins/quiz/docs/guide.md +61 -0
  91. package/plugins/quiz/inject/body.html +1 -1
  92. package/plugins/quiz/plugin.js +39 -5
  93. package/plugins/quiz/plugin.json +4 -3
  94. package/plugins/quiz/public/quiz.css +36 -1
  95. package/plugins/quiz/public/quiz.js +298 -6
  96. package/plugins/theme-roller/CLAUDE.md +65 -0
  97. package/plugins/theme-roller/admin/css/theme-roller.css +43 -0
  98. package/plugins/theme-roller/admin/lib/prompt.js +22 -0
  99. package/plugins/theme-roller/admin/lib/themes.js +196 -0
  100. package/plugins/theme-roller/admin/lib/view.js +88 -0
  101. package/plugins/theme-roller/admin/templates/theme-roller.html +73 -0
  102. package/plugins/theme-roller/admin/views/theme-roller-view.js +639 -0
  103. package/plugins/theme-roller/config.js +1 -0
  104. package/plugins/theme-roller/docs/guide.md +104 -0
  105. package/plugins/theme-roller/plugin.js +263 -0
  106. package/plugins/theme-roller/plugin.json +42 -0
  107. package/plugins/theme-roller/tests/api.test.js +161 -0
  108. package/plugins/theme-roller/tests/prompt-values.test.js +22 -0
  109. package/plugins/theme-roller/tests/themes.test.js +77 -0
  110. package/plugins/theme-roller/tests/view.test.js +55 -0
@@ -0,0 +1,482 @@
1
+ /**
2
+ * Documents in and out: HTML to Markdown, HTML to plain text, excerpts and
3
+ * word counts.
4
+ *
5
+ * Pure and DOM-free on purpose. The admin screen uses it for exports, the
6
+ * server uses it for list excerpts, word counts and the public page's
7
+ * downloads, and the tests run it under plain Node - so it carries its own
8
+ * small HTML parser rather than leaning on DOMParser.
9
+ *
10
+ * The parser only has to be good for what a document can hold: HTML that
11
+ * came out of the editor (a contenteditable) and then through a sanitiser.
12
+ * It is forgiving - unknown close tags are ignored, a new block closes an
13
+ * open paragraph, a new list item closes the last one - but it is not a
14
+ * browser, and it does not try to be.
15
+ *
16
+ * @module docs/admin/lib/convert
17
+ */
18
+
19
+ const VOID = new Set(['area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input', 'link', 'meta', 'source', 'track', 'wbr']);
20
+ const SKIP = new Set(['script', 'style', 'template', 'noscript', 'head', 'title']);
21
+ const BLOCK = new Set(['address', 'article', 'aside', 'blockquote', 'div', 'dl', 'fieldset', 'figure', 'footer',
22
+ 'form', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'header', 'hr', 'main', 'nav', 'ol', 'p', 'pre', 'section',
23
+ 'table', 'ul', 'li', 'tr', 'td', 'th', 'thead', 'tbody', 'tfoot', 'caption', 'figcaption', 'dd', 'dt']);
24
+
25
+ const NAMED = {amp: '&', lt: '<', gt: '>', quot: '"', apos: "'", nbsp: ' ', copy: '©', reg: '®',
26
+ trade: '™', hellip: '…', mdash: '-', ndash: '-', lsquo: '‘', rsquo: '’', ldquo: '“',
27
+ rdquo: '”', bull: '•', middot: '·', pound: '£', euro: '€', times: '×', deg: '°'};
28
+
29
+ /**
30
+ * @param {string} s
31
+ * @returns {string}
32
+ */
33
+ export function decodeEntities(s) {
34
+ return String(s).replace(/&(#x[0-9a-f]+|#\d+|[a-z]+);/gi, (m, e) => {
35
+ if (e[0] === '#') {
36
+ const code = e[1] === 'x' || e[1] === 'X' ? parseInt(e.slice(2), 16) : parseInt(e.slice(1), 10);
37
+ try { return String.fromCodePoint(code); } catch { return m; }
38
+ }
39
+ const v = NAMED[e.toLowerCase()];
40
+ return v === undefined ? m : v;
41
+ });
42
+ }
43
+
44
+ /**
45
+ * @param {string} s
46
+ * @returns {string}
47
+ */
48
+ export function escapeHtml(s) {
49
+ return String(s ?? '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
50
+ .replace(/"/g, '&quot;').replace(/'/g, '&#39;');
51
+ }
52
+
53
+ /**
54
+ * Parse HTML into a plain tree: `{type:'element', name, attrs, children}`
55
+ * and `{type:'text', text}` (entities decoded).
56
+ *
57
+ * @param {string} html
58
+ * @returns {{type:'root', children:Array}}
59
+ */
60
+ export function parseHtml(html) {
61
+ const root = {type: 'root', name: '#root', attrs: {}, children: []};
62
+ const stack = [root];
63
+ const top = () => stack[stack.length - 1];
64
+ const src = String(html ?? '');
65
+ const tagRe = /<!--[\s\S]*?(?:-->|$)|<!\[CDATA\[[\s\S]*?\]\]>|<![^>]*>|<\?[^>]*>|<\/?([a-zA-Z][\w:-]*)((?:\s+[^\s"'>/=]+(?:\s*=\s*(?:"[^"]*"|'[^']*'|[^\s"'>]+))?)*)\s*(\/?)>/g;
66
+ let last = 0;
67
+ let m;
68
+
69
+ const closeTo = (name) => {
70
+ for (let i = stack.length - 1; i > 0; i--) {
71
+ if (stack[i].name === name) { stack.length = i; return true; }
72
+ }
73
+ return false;
74
+ };
75
+ const text = (t) => { if (t) top().children.push({type: 'text', text: decodeEntities(t)}); };
76
+
77
+ while ((m = tagRe.exec(src))) {
78
+ text(src.slice(last, m.index));
79
+ last = tagRe.lastIndex;
80
+ const raw = m[0];
81
+ if (!m[1]) continue; // comment, doctype, CDATA, PI
82
+ const name = m[1].toLowerCase();
83
+ if (raw[1] === '/') { closeTo(name); continue; }
84
+
85
+ if (SKIP.has(name)) {
86
+ // Swallow everything up to the matching close tag.
87
+ const end = src.toLowerCase().indexOf(`</${name}`, last);
88
+ if (end === -1) { last = src.length; break; }
89
+ const gt = src.indexOf('>', end);
90
+ last = gt === -1 ? src.length : gt + 1;
91
+ tagRe.lastIndex = last;
92
+ continue;
93
+ }
94
+
95
+ // Implied closes that editors and pasted HTML rely on.
96
+ if (BLOCK.has(name) && name !== 'li' && top().name === 'p') stack.pop();
97
+ if (name === 'li') {
98
+ for (let i = stack.length - 1; i > 0; i--) {
99
+ const n = stack[i].name;
100
+ if (n === 'ul' || n === 'ol') break;
101
+ if (n === 'li') { stack.length = i; break; }
102
+ }
103
+ }
104
+ if ((name === 'td' || name === 'th')) {
105
+ for (let i = stack.length - 1; i > 0; i--) {
106
+ const n = stack[i].name;
107
+ if (n === 'tr' || n === 'table') break;
108
+ if (n === 'td' || n === 'th') { stack.length = i; break; }
109
+ }
110
+ }
111
+ if (name === 'tr') {
112
+ for (let i = stack.length - 1; i > 0; i--) {
113
+ const n = stack[i].name;
114
+ if (n === 'table' || n === 'tbody' || n === 'thead' || n === 'tfoot') break;
115
+ if (n === 'tr') { stack.length = i; break; }
116
+ }
117
+ }
118
+
119
+ const attrs = {};
120
+ const attrRe = /([^\s"'>/=]+)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'>]+)))?/g;
121
+ let a;
122
+ while ((a = attrRe.exec(m[2] || ''))) {
123
+ attrs[a[1].toLowerCase()] = decodeEntities(a[2] ?? a[3] ?? a[4] ?? '');
124
+ }
125
+ const node = {type: 'element', name, attrs, children: []};
126
+ top().children.push(node);
127
+ if (!VOID.has(name) && !m[3]) stack.push(node);
128
+ }
129
+ text(src.slice(last));
130
+ return root;
131
+ }
132
+
133
+ /**
134
+ * All the text under a node, as it would read (no layout).
135
+ *
136
+ * @param {object} node
137
+ * @returns {string}
138
+ */
139
+ function textOf(node) {
140
+ if (node.type === 'text') return node.text;
141
+ if (node.name === 'br') return '\n';
142
+ return (node.children || []).map(textOf).join('');
143
+ }
144
+
145
+ // --------------------------------------------------------------- plain text
146
+
147
+ /**
148
+ * The document as plain text: paragraphs separated by blank lines, list
149
+ * items on their own lines, table cells separated by tabs.
150
+ *
151
+ * @param {string} html
152
+ * @returns {string}
153
+ */
154
+ export function htmlToText(html) {
155
+ const out = [];
156
+ const walk = (node) => {
157
+ if (node.type === 'text') { out.push(node.text.replace(/\s+/g, ' ')); return; }
158
+ const n = node.name;
159
+ if (n === 'br') { out.push('\n'); return; }
160
+ if (n === 'hr') { out.push('\n\n'); return; }
161
+ if (n === 'pre') { out.push('\n\n' + textOf(node) + '\n\n'); return; }
162
+ if (n === 'img') { if (node.attrs.alt) out.push(node.attrs.alt); return; }
163
+ const block = BLOCK.has(n);
164
+ if (n === 'li') out.push('\n');
165
+ else if (n === 'tr') out.push('\n');
166
+ else if (n === 'td' || n === 'th') out.push('\t');
167
+ else if (block) out.push('\n\n');
168
+ (node.children || []).forEach(walk);
169
+ if (block && n !== 'li' && n !== 'td' && n !== 'th' && n !== 'tr') out.push('\n\n');
170
+ };
171
+ walk(parseHtml(html));
172
+ return out.join('')
173
+ .replace(/[  ]+\n/g, '\n')
174
+ .replace(/\n[  ]+/g, '\n')
175
+ .replace(/\n\t/g, '\n')
176
+ .replace(/\n{3,}/g, '\n\n')
177
+ .trim();
178
+ }
179
+
180
+ /**
181
+ * @param {string} html
182
+ * @returns {number}
183
+ */
184
+ export function countWords(html) {
185
+ const text = htmlToText(html).replace(/\s+/g, ' ').trim();
186
+ return text ? text.split(' ').length : 0;
187
+ }
188
+
189
+ /**
190
+ * The start of a document as one line of text, for list rows and cards.
191
+ *
192
+ * @param {string} html
193
+ * @param {number} [max=240]
194
+ * @returns {string}
195
+ */
196
+ export function excerpt(html, max = 240) {
197
+ const text = htmlToText(html).replace(/\s+/g, ' ').trim();
198
+ return text.length > max ? text.slice(0, max - 1).trimEnd() + '…' : text;
199
+ }
200
+
201
+ /**
202
+ * The start of a document with its line breaks, for the page-shaped preview
203
+ * on a card (shown with white-space: pre-line).
204
+ *
205
+ * @param {string} html
206
+ * @param {number} [max=400]
207
+ * @returns {string}
208
+ */
209
+ export function preview(html, max = 400) {
210
+ const text = htmlToText(html).replace(/\t/g, ' ').replace(/\n{2,}/g, '\n').trim();
211
+ return text.length > max ? text.slice(0, max - 1).trimEnd() + '\u2026' : text;
212
+ }
213
+
214
+ /**
215
+ * The headings of a document, in order - the Outline.
216
+ *
217
+ * @param {string} html
218
+ * @returns {Array<{level:number, text:string}>}
219
+ */
220
+ export function outline(html) {
221
+ const out = [];
222
+ const walk = (node) => {
223
+ if (node.type !== 'element' && node.type !== 'root') return;
224
+ const h = /^h([1-6])$/.exec(node.name || '');
225
+ if (h) {
226
+ const text = textOf(node).replace(/\s+/g, ' ').trim();
227
+ if (text) out.push({level: Number(h[1]), text});
228
+ return;
229
+ }
230
+ (node.children || []).forEach(walk);
231
+ };
232
+ walk(parseHtml(html));
233
+ return out;
234
+ }
235
+
236
+ // ----------------------------------------------------------------- markdown
237
+
238
+ /** Characters that would start Markdown syntax if left bare in text. */
239
+ function escapeMd(text) {
240
+ return text.replace(/([\\`*_[\]])/g, '\\$1');
241
+ }
242
+
243
+ /** Escape what would read as a block marker at the start of a line. */
244
+ function escapeLineStart(line) {
245
+ return line
246
+ .replace(/^(\s*)([#>+])/, '$1\\$2')
247
+ .replace(/^(\s*)-(\s|$)/, '$1\\-$2')
248
+ .replace(/^(\s*)(\d+)\.(\s|$)/, '$1$2\\.$3');
249
+ }
250
+
251
+ /**
252
+ * Inline content of a node as Markdown, on one logical line (hard breaks
253
+ * become a backslash line end).
254
+ *
255
+ * @param {object} node
256
+ * @returns {string}
257
+ */
258
+ function inline(node) {
259
+ if (node.type === 'text') return escapeMd(node.text.replace(/\s+/g, ' '));
260
+ const kids = () => (node.children || []).map(inline).join('');
261
+ const wrap = (mark) => {
262
+ const inner = kids();
263
+ const trimmed = inner.trim();
264
+ if (!trimmed) return inner;
265
+ const lead = inner.match(/^\s*/)[0];
266
+ const tail = inner.match(/\s*$/)[0];
267
+ return `${lead}${mark}${trimmed}${mark}${tail}`;
268
+ };
269
+ switch (node.name) {
270
+ case 'br': return '\\\n';
271
+ case 'strong': case 'b': return wrap('**');
272
+ case 'em': case 'i': return wrap('*');
273
+ case 's': case 'del': case 'strike': return wrap('~~');
274
+ case 'code': {
275
+ const t = textOf(node);
276
+ const ticks = t.includes('`') ? '``' : '`';
277
+ return t ? `${ticks}${ticks.length > 1 ? ' ' : ''}${t}${ticks.length > 1 ? ' ' : ''}${ticks}` : '';
278
+ }
279
+ case 'a': {
280
+ const label = kids().trim() || escapeMd(node.attrs.href || '');
281
+ const href = (node.attrs.href || '').replace(/\s/g, '%20').replace(/\)/g, '%29');
282
+ return href ? `[${label}](${href})` : label;
283
+ }
284
+ case 'img': {
285
+ const alt = escapeMd(node.attrs.alt || '');
286
+ const src = (node.attrs.src || '').replace(/\s/g, '%20').replace(/\)/g, '%29');
287
+ const title = node.attrs.title ? ` "${node.attrs.title.replace(/"/g, '\\"')}"` : '';
288
+ return src ? `![${alt}](${src}${title})` : '';
289
+ }
290
+ case 'u': case 'mark': case 'sub': case 'sup': case 'kbd': {
291
+ const inner = kids();
292
+ return inner.trim() ? `<${node.name}>${inner}</${node.name}>` : inner;
293
+ }
294
+ default: return kids();
295
+ }
296
+ }
297
+
298
+ /**
299
+ * Split children into runs: consecutive inline nodes are one paragraph,
300
+ * each block element stands alone. Loose text inside a div or list item is
301
+ * not lost this way.
302
+ */
303
+ function runs(children) {
304
+ const out = [];
305
+ let current = null;
306
+ for (const c of children || []) {
307
+ if (c.type === 'element' && BLOCK.has(c.name)) {
308
+ current = null;
309
+ out.push(c);
310
+ } else {
311
+ if (!current) { current = {type: 'element', name: '#inline', attrs: {}, children: []}; out.push(current); }
312
+ current.children.push(c);
313
+ }
314
+ }
315
+ return out;
316
+ }
317
+
318
+ /** A paragraph's inline Markdown, each line escaped at its start. */
319
+ function para(node) {
320
+ const text = inline(node).replace(/[ \t]+/g, ' ').trim();
321
+ return text.split('\n').map((l) => escapeLineStart(l.trim())).join('\n');
322
+ }
323
+
324
+ function isChecklist(list) {
325
+ return list.attrs['data-type'] === 'checklist' || list.attrs.class?.split(/\s+/).includes('docs-checklist');
326
+ }
327
+
328
+ /**
329
+ * Blocks as Markdown, joined by blank lines.
330
+ *
331
+ * @param {Array} children
332
+ * @returns {string}
333
+ */
334
+ function blocks(children) {
335
+ const parts = [];
336
+ for (const node of runs(children)) {
337
+ const md = block(node);
338
+ if (md && md.trim()) parts.push(md.replace(/\s+$/, ''));
339
+ }
340
+ return parts.join('\n\n');
341
+ }
342
+
343
+ function indent(text, pad) {
344
+ return text.split('\n').map((l, i) => (i === 0 || !l ? l : pad + l)).join('\n');
345
+ }
346
+
347
+ function list(node) {
348
+ const ordered = node.name === 'ol';
349
+ const check = isChecklist(node);
350
+ let n = Number(node.attrs.start) || 1;
351
+ const items = [];
352
+ for (const li of (node.children || []).filter(c => c.type === 'element' && c.name === 'li')) {
353
+ let marker = ordered ? `${n++}.` : '-';
354
+ if (check || li.attrs['data-checked'] !== undefined) {
355
+ const on = li.attrs['data-checked'] === 'true';
356
+ marker = `- [${on ? 'x' : ' '}]`;
357
+ }
358
+ const body = blocks(li.children) || '';
359
+ items.push(`${marker} ${indent(body, ' '.repeat(marker.length + 1))}`);
360
+ }
361
+ return items.join('\n');
362
+ }
363
+
364
+ function table(node) {
365
+ const rows = [];
366
+ const collect = (n) => (n.children || []).forEach((c) => {
367
+ if (c.type !== 'element') return;
368
+ if (c.name === 'tr') rows.push(c);
369
+ else if (['thead', 'tbody', 'tfoot'].includes(c.name)) collect(c);
370
+ });
371
+ collect(node);
372
+ if (!rows.length) return '';
373
+ const cells = rows.map(r => (r.children || [])
374
+ .filter(c => c.type === 'element' && (c.name === 'td' || c.name === 'th'))
375
+ .map(c => inline(c).replace(/\\\n/g, ' ').replace(/\s+/g, ' ').trim().replace(/\|/g, '\\|')));
376
+ const width = Math.max(1, ...cells.map(r => r.length));
377
+ const line = (r) => `| ${Array.from({length: width}, (_, i) => r[i] ?? '').join(' | ')} |`;
378
+ const out = [line(cells[0]), `| ${Array(width).fill('---').join(' | ')} |`];
379
+ cells.slice(1).forEach(r => out.push(line(r)));
380
+ return out.join('\n');
381
+ }
382
+
383
+ function block(node) {
384
+ if (node.name === '#inline') return para(node);
385
+ const n = node.name;
386
+ const h = /^h([1-6])$/.exec(n);
387
+ if (h) return `${'#'.repeat(Number(h[1]))} ${inline(node).replace(/\\\n/g, ' ').replace(/\s+/g, ' ').trim()}`;
388
+ switch (n) {
389
+ case 'p': return para(node);
390
+ case 'hr': return '---';
391
+ case 'ul': case 'ol': return list(node);
392
+ case 'table': return table(node);
393
+ case 'pre': {
394
+ const code = (node.children || []).find(c => c.type === 'element' && c.name === 'code');
395
+ const lang = /language-([\w-]+)/.exec(code?.attrs?.class || '')?.[1] || '';
396
+ const text = textOf(node).replace(/\n$/, '');
397
+ const fence = text.includes('```') ? '~~~' : '```';
398
+ return `${fence}${lang}\n${text}\n${fence}`;
399
+ }
400
+ case 'blockquote': return blocks(node.children).split('\n').map(l => (l ? `> ${l}` : '>')).join('\n');
401
+ default: return blocks(node.children);
402
+ }
403
+ }
404
+
405
+ /**
406
+ * A document as GitHub-flavoured Markdown. Underline, highlight and
407
+ * sub/superscript have no Markdown form, so they stay as inline HTML - which
408
+ * Markdown allows and every renderer shows.
409
+ *
410
+ * @param {string} html
411
+ * @returns {string}
412
+ */
413
+ export function htmlToMarkdown(html) {
414
+ const md = blocks(parseHtml(html).children);
415
+ return md.replace(/\n{3,}/g, '\n\n').trim() + '\n';
416
+ }
417
+
418
+ // ---------------------------------------------------------- standalone file
419
+
420
+ /** Styles for an exported or printed document - readable anywhere, no theme. */
421
+ export const DOCUMENT_CSS = `
422
+ body{font:16px/1.6 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;color:#1f2328;max-width:760px;margin:2rem auto;padding:0 1.25rem}
423
+ h1,h2,h3{line-height:1.25;margin:1.6em 0 .6em}h1{font-size:2em}h2{font-size:1.5em}h3{font-size:1.25em}
424
+ p{margin:0 0 1em}a{color:#0b61c4}img{max-width:100%;height:auto}
425
+ blockquote{margin:0 0 1em;padding:.25em 1em;border-left:4px solid #d0d7de;color:#57606a}
426
+ pre{background:#f6f8fa;padding:1em;border-radius:6px;overflow:auto;font-size:.9em}
427
+ code{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:.9em}
428
+ table{border-collapse:collapse;margin:0 0 1em;width:100%}td,th{border:1px solid #d0d7de;padding:.4em .6em;text-align:left;vertical-align:top}th{background:#f6f8fa}
429
+ ul[data-type=checklist]{list-style:none;padding-left:.25em}ul[data-type=checklist] li::before{content:"\\2610";margin-right:.5em}ul[data-type=checklist] li[data-checked=true]::before{content:"\\2611"}
430
+ hr{border:0;border-top:1px solid #d0d7de;margin:2em 0}mark{background:#fff3a3}
431
+ @media print{body{margin:0;max-width:none}a{color:inherit}}
432
+ `.trim();
433
+
434
+ /**
435
+ * True when a document's body already opens with its title as a heading -
436
+ * then a page built from it must not print the title twice.
437
+ *
438
+ * @param {string} title
439
+ * @param {string} html
440
+ * @returns {boolean}
441
+ */
442
+ export function startsWithTitle(title, html) {
443
+ const m = /^\s*<h[12][^>]*>([\s\S]*?)<\/h[12]>/i.exec(String(html || ''));
444
+ if (!m) return false;
445
+ const norm = (t) => decodeEntities(String(t).replace(/<[^>]+>/g, '')).replace(/\s+/g, ' ').trim().toLowerCase();
446
+ return norm(m[1]) === norm(title || '');
447
+ }
448
+
449
+ /**
450
+ * A complete HTML file for one document.
451
+ *
452
+ * @param {string} title
453
+ * @param {string} html the (already sanitised) body
454
+ * @returns {string}
455
+ */
456
+ export function standaloneHtml(title, html) {
457
+ return `<!doctype html>
458
+ <html lang="en">
459
+ <head>
460
+ <meta charset="utf-8">
461
+ <meta name="viewport" content="width=device-width, initial-scale=1">
462
+ <title>${escapeHtml(title || 'Untitled')}</title>
463
+ <style>${DOCUMENT_CSS}</style>
464
+ </head>
465
+ <body>
466
+ ${startsWithTitle(title, html) ? '' : `<h1>${escapeHtml(title || 'Untitled')}</h1>\n`}${html || ''}
467
+ </body>
468
+ </html>
469
+ `;
470
+ }
471
+
472
+ /**
473
+ * A safe file name for a download, keeping the title readable.
474
+ *
475
+ * @param {string} title
476
+ * @param {string} ext without the dot
477
+ * @returns {string}
478
+ */
479
+ export function fileName(title, ext) {
480
+ const base = String(title || 'Untitled').replace(/[\\/:*?"<>|\u0000-\u001f]+/g, ' ').replace(/\s+/g, ' ').trim().slice(0, 120) || 'Untitled';
481
+ return `${base}.${ext}`;
482
+ }
@@ -0,0 +1,115 @@
1
+ /**
2
+ * Docs - the draft an editor keeps in this browser for work left unsaved
3
+ * (pure, tested in tests/drafts.test.js).
4
+ *
5
+ * A draft is `{owner, title, content, at, was}`, kept per signed-in person
6
+ * (`docs.draft.<userId>.<docId>`): documents are shared, and a browser can be
7
+ * shared too, so one person's unsaved work is never offered to another - the
8
+ * key keeps them apart and `owner` is checked on the way back in. 2.9 kept
9
+ * `docs.draft.<docId>` with no owner: whose it was cannot be known, so it is
10
+ * removed, never offered (legacyDraftKey).
11
+ *
12
+ * `was` is the document's updatedAt when the edits it holds began (moved on by
13
+ * each save of the editor's own; a draft picked back up keeps the `was` it came
14
+ * with until it is saved). `base`, an earlier name for it, is read as `was`.
15
+ *
16
+ * The restore question follows the admin's convention: E.confirm answers
17
+ * `false` for Cancel, Escape AND a backdrop click alike, so the DESTRUCTIVE
18
+ * answer is OK ("Throw away") and every other way out keeps the changes - put
19
+ * back in the document, or (`copy`: the document is gone) made into a new one.
20
+ *
21
+ * @module docs/admin/lib/drafts
22
+ */
23
+
24
+ /** The storage key of one person's draft of one document ('' with nobody signed in: no draft is kept). */
25
+ export const draftKey = (id, owner) => (owner ? `docs.draft.${owner}.${id}` : '');
26
+ /** 2.9's key, with no owner - removed when met, never read. */
27
+ export const legacyDraftKey = (id) => `docs.draft.${id}`;
28
+
29
+ const escHtml = (s) => String(s ?? '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
30
+ .replace(/"/g, '&quot;').replace(/'/g, '&#39;');
31
+
32
+ /**
33
+ * A stored value as a draft, or null. Whatever an older build (or a person
34
+ * with devtools) left there is whitelisted, never trusted.
35
+ *
36
+ * @param {*} raw - S.get's answer (an object, or a JSON string)
37
+ * @param {string} owner - the signed-in person's id: a draft that is not theirs is no draft
38
+ * @returns {{owner: string, title: string, content: string, at: string, was: string}|null}
39
+ */
40
+ export function normaliseDraft(raw, owner) {
41
+ let v = raw;
42
+ if (typeof v === 'string') { try { v = JSON.parse(v); } catch { return null; } }
43
+ if (!v || typeof v !== 'object' || typeof v.content !== 'string') return null;
44
+ if (!owner || v.owner !== owner) return null;
45
+ const str = (x) => (typeof x === 'string' ? x : '');
46
+ return {owner, title: str(v.title), content: v.content, at: str(v.at), was: str(v.was) || str(v.base)};
47
+ }
48
+
49
+ /** A draft to keep: `was` is when its edits began, never "now". */
50
+ export function makeDraft({owner, title, content, was, at = new Date().toISOString()}) {
51
+ return {owner: String(owner || ''), title: String(title ?? ''), content: String(content ?? ''), at, was: String(was || '')};
52
+ }
53
+
54
+ /** Does the draft hold anything the stored document does not? */
55
+ export function draftDiffers(draft, doc) {
56
+ if (!draft) return false;
57
+ if (!doc) return true;
58
+ return draft.content !== String(doc.content || '') || Boolean(draft.title && draft.title !== doc.title);
59
+ }
60
+
61
+ /**
62
+ * What became of the document since the draft's edits began.
63
+ *
64
+ * @param {{was?: string, at?: string}} draft
65
+ * @param {object|null} doc - null: deleted, or no longer shared with this person
66
+ * @returns {'deleted'|'trashed'|'changed'|''}
67
+ */
68
+ export function draftFate(draft, doc) {
69
+ if (!doc) return 'deleted';
70
+ if (doc.trashedAt) return 'trashed';
71
+ if (!doc.updatedAt) return '';
72
+ if (draft?.was) return doc.updatedAt !== draft.was ? 'changed' : '';
73
+ // A draft with no `was` (an old one, or a failed write's): only a save after it was kept counts.
74
+ const at = Date.parse(draft?.at || '');
75
+ return Number.isFinite(at) && Date.parse(doc.updatedAt) > at ? 'changed' : '';
76
+ }
77
+
78
+ const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
79
+ /** "14 Oct, 09:05" in the browser's time zone; '' for no date. */
80
+ export function when(iso) {
81
+ const t = Date.parse(iso || '');
82
+ if (!Number.isFinite(t)) return '';
83
+ const d = new Date(t);
84
+ const p = (n) => String(n).padStart(2, '0');
85
+ return `${d.getDate()} ${MONTHS[d.getMonth()]}, ${p(d.getHours())}:${p(d.getMinutes())}`;
86
+ }
87
+
88
+ /**
89
+ * The question a kept draft is offered back with - HTML for E.confirm (the
90
+ * title is escaped). OK throws away; Cancel / Escape / the backdrop keep.
91
+ *
92
+ * @param {object} draft
93
+ * @param {object|null} doc - the document as loaded now; null when it is gone
94
+ * @returns {{text: string, copy: boolean, options: {title: string, confirmText: string, cancelText: string}}}
95
+ */
96
+ export function draftQuestion(draft, doc) {
97
+ const name = draft?.title || doc?.title;
98
+ const at = when(draft?.at);
99
+ const ask = `Throw away the changes you left unsaved on <strong>${escHtml(name || 'this document')}</strong>${at ? ` (${at})` : ''}?`;
100
+ const keep = {title: 'Unsaved changes', confirmText: 'Throw away', cancelText: 'Put them back'};
101
+ const copy = {...keep, cancelText: 'Keep them as a new document'};
102
+ switch (draftFate(draft, doc)) {
103
+ case 'deleted':
104
+ return {copy: true, options: copy, text: `${ask}<br><br>The document has been deleted since, or is no longer shared with you - keeping them makes a new document of your own.`};
105
+ case 'changed': {
106
+ const saved = when(doc.updatedAt);
107
+ return {copy: false, options: keep, text: `${ask}<br><br>The document has been saved since${saved ? ` (${saved})` : ''}. Putting yours back replaces that version - it stays in Version history.`};
108
+ }
109
+ default:
110
+ return {copy: false, options: keep, text: `${ask} Putting them back returns them to the document.`};
111
+ }
112
+ }
113
+
114
+ /** The title of a document made from a draft whose document has gone. */
115
+ export const copyTitle = (draft) => `${(draft?.title || '').trim() || 'Untitled'} (your unsaved changes)`.slice(0, 200);