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.
- package/CHANGELOG.md +9 -0
- package/docs/DommaFeatures.md +13 -1
- package/package.json +1 -1
- package/plugins/_lib/admin/games/games.css +1 -1
- package/plugins/_lib/admin/games/kit.js +329 -124
- package/plugins/_lib/games/http.js +11 -1
- package/plugins/_lib/games/plays.js +28 -7
- package/plugins/crossword/CLAUDE.md +37 -0
- package/plugins/crossword/admin/css/index.css +18 -1
- package/plugins/crossword/admin/views/crossword.js +337 -205
- package/plugins/crossword/docs/guide.md +53 -0
- package/plugins/crossword/inject/body.html +1 -1
- package/plugins/crossword/plugin.js +38 -5
- package/plugins/crossword/plugin.json +4 -3
- package/plugins/crossword/public/crossword.css +36 -1
- package/plugins/crossword/public/crossword.js +468 -6
- package/plugins/docs/CLAUDE.md +318 -0
- package/plugins/docs/admin/css/docs.css +783 -0
- package/plugins/docs/admin/lib/api.js +340 -0
- package/plugins/docs/admin/lib/convert.js +482 -0
- package/plugins/docs/admin/lib/drafts.js +115 -0
- package/plugins/docs/admin/lib/editor.js +851 -0
- package/plugins/docs/admin/lib/find-replace.js +426 -0
- package/plugins/docs/admin/lib/folders.js +203 -0
- package/plugins/docs/admin/lib/panel-guard.js +73 -0
- package/plugins/docs/admin/lib/pdf-view.js +126 -0
- package/plugins/docs/admin/lib/send.js +156 -0
- package/plugins/docs/admin/lib/settings.js +142 -0
- package/plugins/docs/admin/lib/share.js +323 -0
- package/plugins/docs/admin/lib/shortcuts.js +44 -0
- package/plugins/docs/admin/lib/templates.js +194 -0
- package/plugins/docs/admin/lib/versions.js +192 -0
- package/plugins/docs/admin/templates/docs.html +92 -0
- package/plugins/docs/admin/templates/editor.html +86 -0
- package/plugins/docs/admin/views/docs.js +777 -0
- package/plugins/docs/admin/views/editor.js +978 -0
- package/plugins/docs/config.js +24 -0
- package/plugins/docs/docs/guide.md +270 -0
- package/plugins/docs/lib/extensions.js +213 -0
- package/plugins/docs/lib/importer.js +72 -0
- package/plugins/docs/lib/pdf-worker.js +48 -0
- package/plugins/docs/lib/pdf.js +222 -0
- package/plugins/docs/lib/store.js +748 -0
- package/plugins/docs/plugin.js +1108 -0
- package/plugins/docs/plugin.json +89 -0
- package/plugins/docs/plugin.public.js +114 -0
- package/plugins/docs/templates/public-doc.html +36 -0
- package/plugins/docs/tests/_env.js +10 -0
- package/plugins/docs/tests/_pdf.js +73 -0
- package/plugins/docs/tests/convert.test.js +95 -0
- package/plugins/docs/tests/drafts.test.js +82 -0
- package/plugins/docs/tests/pdf.test.js +86 -0
- package/plugins/docs/tests/plugin.test.js +816 -0
- package/plugins/docs/tests/shortcuts.test.js +30 -0
- package/plugins/free-tier.lock.json +147 -1
- package/plugins/garage/CLAUDE.md +51 -0
- package/plugins/garage/admin/css/garage.css +73 -0
- package/plugins/garage/admin/lib/garage.js +236 -0
- package/plugins/garage/admin/templates/garage.html +86 -0
- package/plugins/garage/admin/views/garage.js +581 -0
- package/plugins/garage/collections/garage-vehicles/schema.json +101 -0
- package/plugins/garage/config.js +18 -0
- package/plugins/garage/docs/guide.md +118 -0
- package/plugins/garage/plugin.js +471 -0
- package/plugins/garage/plugin.json +68 -0
- package/plugins/garage/tests/access.test.js +134 -0
- package/plugins/garage/tests/garage.test.js +106 -0
- package/plugins/messages/CLAUDE.md +230 -0
- package/plugins/messages/admin/css/messages.css +117 -0
- package/plugins/messages/admin/lib/messages.js +452 -0
- package/plugins/messages/admin/templates/messages.html +76 -0
- package/plugins/messages/admin/views/messages.js +1219 -0
- package/plugins/messages/collections/user-message-threads/schema.json +22 -0
- package/plugins/messages/collections/user-messages/schema.json +23 -0
- package/plugins/messages/config.js +14 -0
- package/plugins/messages/docs/guide.md +176 -0
- package/plugins/messages/lib/call.js +138 -0
- package/plugins/messages/plugin.js +994 -0
- package/plugins/messages/plugin.json +59 -0
- package/plugins/messages/plugin.public.js +52 -0
- package/plugins/messages/templates/call.html +6 -0
- package/plugins/messages/tests/after-send.test.js +22 -0
- package/plugins/messages/tests/api.test.js +187 -0
- package/plugins/messages/tests/call.test.js +497 -0
- package/plugins/messages/tests/drafts.test.js +25 -0
- package/plugins/messages/tests/messages.test.js +167 -0
- package/plugins/quiz/CLAUDE.md +43 -0
- package/plugins/quiz/admin/css/index.css +13 -1
- package/plugins/quiz/admin/views/quiz.js +423 -245
- package/plugins/quiz/docs/guide.md +61 -0
- package/plugins/quiz/inject/body.html +1 -1
- package/plugins/quiz/plugin.js +39 -5
- package/plugins/quiz/plugin.json +4 -3
- package/plugins/quiz/public/quiz.css +36 -1
- package/plugins/quiz/public/quiz.js +298 -6
- package/plugins/theme-roller/CLAUDE.md +65 -0
- package/plugins/theme-roller/admin/css/theme-roller.css +43 -0
- package/plugins/theme-roller/admin/lib/prompt.js +22 -0
- package/plugins/theme-roller/admin/lib/themes.js +196 -0
- package/plugins/theme-roller/admin/lib/view.js +88 -0
- package/plugins/theme-roller/admin/templates/theme-roller.html +73 -0
- package/plugins/theme-roller/admin/views/theme-roller-view.js +639 -0
- package/plugins/theme-roller/config.js +1 -0
- package/plugins/theme-roller/docs/guide.md +104 -0
- package/plugins/theme-roller/plugin.js +263 -0
- package/plugins/theme-roller/plugin.json +42 -0
- package/plugins/theme-roller/tests/api.test.js +161 -0
- package/plugins/theme-roller/tests/prompt-values.test.js +22 -0
- package/plugins/theme-roller/tests/themes.test.js +77 -0
- 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, '&').replace(/</g, '<').replace(/>/g, '>')
|
|
50
|
+
.replace(/"/g, '"').replace(/'/g, ''');
|
|
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 ? `` : '';
|
|
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, '&').replace(/</g, '<').replace(/>/g, '>')
|
|
30
|
+
.replace(/"/g, '"').replace(/'/g, ''');
|
|
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);
|