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,89 @@
1
+ {
2
+ "name": "docs",
3
+ "displayName": "Docs",
4
+ "version": "2.14.0",
5
+ "tier": "free",
6
+ "description": "A document editor: sharing, public links, import and export, send to a page, blog or email, version history and templates. PDF documents: add a PDF and it is kept as it is - filed, shared, linked, emailed and versioned like any document, shown in the browser's own viewer.",
7
+ "author": "Darryl Waterhouse",
8
+ "date": "2026-10-21",
9
+ "icon": "book-open",
10
+ "contentPaths": [
11
+ "data"
12
+ ],
13
+ "admin": {
14
+ "css": [
15
+ "admin/css/docs.css"
16
+ ],
17
+ "sidebar": [
18
+ {
19
+ "id": "docs",
20
+ "text": "Docs",
21
+ "icon": "book-open",
22
+ "url": "#/plugins/docs",
23
+ "hotkey": "o",
24
+ "section": "#/plugins/docs",
25
+ "permission": "docs",
26
+ "countUrl": "/api/plugins/docs/count"
27
+ }
28
+ ],
29
+ "routes": [
30
+ {
31
+ "path": "/plugins/docs",
32
+ "view": "plugin-docs",
33
+ "title": "Docs - Domma CMS"
34
+ },
35
+ {
36
+ "path": "/plugins/docs/d/:id",
37
+ "view": "plugin-docs-editor",
38
+ "title": "Docs - Domma CMS"
39
+ }
40
+ ],
41
+ "views": {
42
+ "plugin-docs": {
43
+ "entry": "docs/admin/views/docs.js",
44
+ "exportName": "docsView"
45
+ },
46
+ "plugin-docs-editor": {
47
+ "entry": "docs/admin/views/editor.js",
48
+ "exportName": "docsEditorView"
49
+ }
50
+ }
51
+ },
52
+ "permissions": [
53
+ {
54
+ "key": "docs",
55
+ "label": "Docs",
56
+ "group": "Plugins",
57
+ "description": "Documents, folders and templates - your own and those shared with you.",
58
+ "actions": [
59
+ {
60
+ "key": "read",
61
+ "label": "View",
62
+ "description": "Open Docs and read documents"
63
+ },
64
+ {
65
+ "key": "create",
66
+ "label": "Create",
67
+ "description": "Write new documents, folders and templates"
68
+ },
69
+ {
70
+ "key": "update",
71
+ "label": "Edit",
72
+ "description": "Edit documents and folders (your own; admins any, when shared site-wide)"
73
+ },
74
+ {
75
+ "key": "delete",
76
+ "label": "Delete",
77
+ "description": "Delete documents, folders and templates (the same)"
78
+ }
79
+ ],
80
+ "grant": [
81
+ "admin",
82
+ "user"
83
+ ]
84
+ }
85
+ ],
86
+ "uses": {
87
+ "blog": "Send a document to a new blog post"
88
+ }
89
+ }
@@ -0,0 +1,114 @@
1
+ /**
2
+ * Docs - the public link page: /docs/s/<token>.
3
+ *
4
+ * A read-only copy of one document for anyone holding its link, inside the
5
+ * site's own theme, never indexed. The body is sanitised again on the way
6
+ * out - it was sanitised when saved, but this is the one place a document
7
+ * meets the public. Downloads (?download=md|html|txt) come from the same
8
+ * page. Docs Pro may answer first (a password, an expired link) through the
9
+ * registry's gate.
10
+ *
11
+ * @module docs/plugin.public
12
+ */
13
+
14
+ import path from 'path';
15
+ import {fileURLToPath} from 'url';
16
+ import {renderBlogPage} from '../../server/services/renderer.js';
17
+ import {docByToken, sanitizeDoc, withoutSuggestions, kindOf, readFileBytes} from './lib/store.js';
18
+ import {docsRegistry} from './lib/extensions.js';
19
+ import {escapeHtml, htmlToMarkdown, htmlToText, standaloneHtml, fileName, startsWithTitle} from './admin/lib/convert.js';
20
+ import {contentDisposition, sizeText} from './lib/pdf.js';
21
+
22
+ const TEMPLATE = path.join(fileURLToPath(new URL('.', import.meta.url)), 'templates', 'public-doc.html');
23
+
24
+ const disposition = (name) => contentDisposition(name);
25
+
26
+ function notFound(reply) {
27
+ return reply.code(404).type('text/html').header('X-Robots-Tag', 'noindex')
28
+ .send('<!doctype html><meta charset="utf-8"><meta name="robots" content="noindex"><title>Not found</title>'
29
+ + '<p style="font-family:sans-serif;margin:3rem auto;max-width:32rem">This link does not work any more. Ask whoever sent it for a new one.</p>');
30
+ }
31
+
32
+ export default async function docsPublic(fastify) {
33
+ const registry = docsRegistry();
34
+
35
+ fastify.get('/docs/s/:token', {config: {rateLimit: {max: 60, timeWindow: '1 minute'}}}, async (request, reply) => {
36
+ const doc = await docByToken(request.params.token);
37
+ if (!doc) return notFound(reply);
38
+
39
+ // A link an add-on protects (a password, an expiry date) never opens without that add-on
40
+ // there to check it - a lapsed licence or a switched-off add-on must fail closed. (2.3)
41
+ if (doc.linkGuarded && !registry.hasGate()) return notFound(reply);
42
+ // A document an add-on keeps private right now (a members-only wiki's page, 2.11): its link is off.
43
+ if (registry.restrictionOf?.(doc)) return notFound(reply);
44
+ const gated = await registry.gate({doc, request, reply});
45
+ if (gated?.handled) {
46
+ if (gated.error) return notFound(reply);
47
+ return reply;
48
+ }
49
+
50
+ const title = doc.title || 'Untitled';
51
+ reply.header('X-Robots-Tag', 'noindex, nofollow').header('Cache-Control', 'no-store')
52
+ .header('Referrer-Policy', 'no-referrer');
53
+
54
+ const dl = request.query?.download;
55
+ // A PDF document (2.13) is its file: shown in the browser's own viewer, or saved. Nothing else is derived from it.
56
+ if (kindOf(doc) === 'pdf') {
57
+ if (dl !== undefined || request.query?.file === '1') {
58
+ const buf = await readFileBytes(doc.id);
59
+ if (!buf) return notFound(reply);
60
+ return reply.type('application/pdf')
61
+ .header('Content-Disposition', contentDisposition(fileName(title, 'pdf'), dl === undefined))
62
+ .header('X-Content-Type-Options', 'nosniff')
63
+ .header('Content-Security-Policy', "default-src 'none'; sandbox")
64
+ .send(buf);
65
+ }
66
+ const here = `/docs/s/${encodeURIComponent(request.params.token)}`;
67
+ const facts = [doc.file?.pages ? `${doc.file.pages} page${doc.file.pages === 1 ? '' : 's'}` : '', doc.file?.size ? sizeText(doc.file.size) : ''].filter(Boolean).join(' · ');
68
+ const html = await renderBlogPage(TEMPLATE, {
69
+ heading: `<h1 class="docs-public__title">${escapeHtml(title)}</h1>`,
70
+ body: `<iframe class="docs-public__pdf" src="${here}?file=1" title="${escapeHtml(title)}"></iframe>`
71
+ + `<p class="docs-public__pdf-fallback">If the document does not show above, <a href="${here}?download=pdf" rel="nofollow">download the PDF</a>.</p>`,
72
+ actions: `<a href="?download=pdf" rel="nofollow">Download PDF</a>`,
73
+ byline: `Shared document - PDF${facts ? ` · ${facts}` : ''}`,
74
+ updated: doc.updatedAt && !isNaN(new Date(doc.updatedAt)) ? `Last updated ${escapeHtml(new Date(doc.updatedAt).toUTCString().replace(/:\d\d GMT$/, ' GMT'))}` : ''
75
+ }, {
76
+ title, description: htmlToText(doc.content || '').slice(0, 160), noindex: true,
77
+ urlPath: here,
78
+ baseUrl: `${request.protocol}://${request.host || request.headers.host || request.hostname}`
79
+ });
80
+ return reply.type('text/html; charset=utf-8').send(html);
81
+ }
82
+
83
+ // Add-ons' filters (2.5): no comment marks, pending suggestions shown as not made.
84
+ const body = registry.publicHtml(withoutSuggestions(sanitizeDoc(doc.content)));
85
+
86
+ if (dl === 'md') {
87
+ return reply.type('text/markdown; charset=utf-8').header('Content-Disposition', disposition(fileName(title, 'md')))
88
+ .send(startsWithTitle(title, body) ? htmlToMarkdown(body) : `# ${title}\n\n${htmlToMarkdown(body)}`);
89
+ }
90
+ if (dl === 'html') {
91
+ return reply.type('text/html; charset=utf-8').header('Content-Disposition', disposition(fileName(title, 'html')))
92
+ .send(standaloneHtml(title, body));
93
+ }
94
+ if (dl === 'txt') {
95
+ return reply.type('text/plain; charset=utf-8').header('Content-Disposition', disposition(fileName(title, 'txt')))
96
+ .send(`${title}\n\n${htmlToText(body)}\n`);
97
+ }
98
+
99
+ const updated = doc.updatedAt ? new Date(doc.updatedAt) : null;
100
+ const html = await renderBlogPage(TEMPLATE, {
101
+ heading: startsWithTitle(title, body) ? '' : `<h1 class="docs-public__title">${escapeHtml(title)}</h1>`,
102
+ body,
103
+ actions: '<button type="button" id="docs-public-print">Print or save as PDF</button>'
104
+ + '<a href="?download=md" rel="nofollow">Markdown</a><a href="?download=html" rel="nofollow">HTML</a><a href="?download=txt" rel="nofollow">Text</a>',
105
+ byline: 'Shared document - read only',
106
+ updated: updated && !isNaN(updated) ? `Last updated ${escapeHtml(updated.toUTCString().replace(/:\d\d GMT$/, ' GMT'))}` : ''
107
+ }, {
108
+ title, description: htmlToText(body).slice(0, 160), noindex: true,
109
+ urlPath: `/docs/s/${request.params.token}`,
110
+ baseUrl: `${request.protocol}://${request.host || request.headers.host || request.hostname}`
111
+ });
112
+ return reply.type('text/html; charset=utf-8').send(html);
113
+ });
114
+ }
@@ -0,0 +1,36 @@
1
+ <style>
2
+ .docs-public{max-width:820px;margin:2rem auto 3rem;padding:0 1rem}
3
+ .docs-public__bar{display:flex;flex-wrap:wrap;gap:.5rem;align-items:center;justify-content:space-between;margin-bottom:1rem;color:var(--dm-text-muted);font-size:.875rem}
4
+ .docs-public__actions{display:flex;gap:.25rem;flex-wrap:wrap}
5
+ .docs-public__actions a,.docs-public__actions button{font:inherit;font-size:.8125rem;padding:.3rem .65rem;border:1px solid var(--dm-border);border-radius:var(--dm-radius,6px);background:var(--dm-surface);color:var(--dm-text);text-decoration:none;cursor:pointer}
6
+ .docs-public__actions a:hover,.docs-public__actions button:hover{border-color:var(--dm-primary)}
7
+ .docs-public__page{background:var(--dm-surface);color:var(--dm-text);border:1px solid var(--dm-border);border-radius:var(--dm-radius,8px);padding:clamp(1.25rem,5vw,3.5rem);line-height:1.65;overflow-wrap:anywhere}
8
+ .docs-public__page h1.docs-public__title{margin-top:0}
9
+ .docs-public__page img{max-width:100%;height:auto}
10
+ .docs-public__page table{border-collapse:collapse;width:100%;margin:0 0 1em}
11
+ .docs-public__page td,.docs-public__page th{border:1px solid var(--dm-border);padding:.4em .6em;vertical-align:top;text-align:left}
12
+ .docs-public__page blockquote{margin:0 0 1em;padding:.25em 1em;border-left:4px solid var(--dm-border);color:var(--dm-text-muted)}
13
+ .docs-public__page pre{background:color-mix(in srgb,var(--dm-text) 6%,transparent);padding:1em;border-radius:6px;overflow:auto}
14
+ .docs-public__page ul[data-type=checklist]{list-style:none;padding-left:.25em}
15
+ .docs-public__page ul[data-type=checklist] li::before{content:"\2610";margin-right:.5em}
16
+ .docs-public__page ul[data-type=checklist] li[data-checked=true]::before{content:"\2611"}
17
+ .docs-public__pdf{display:block;width:100%;height:min(85vh,1100px);border:0;background:#fff}
18
+ .docs-public__pdf-fallback{margin:.75rem 0 0;font-size:.875rem;color:var(--dm-text-muted)}
19
+ .docs-public__page:has(.docs-public__pdf){padding:0;overflow:hidden}
20
+ .docs-public__page:has(.docs-public__pdf) h1.docs-public__title{padding:1rem 1.25rem 0;margin-bottom:.75rem}
21
+ .docs-public__page:has(.docs-public__pdf) .docs-public__pdf-fallback{padding:0 1.25rem 1rem}
22
+ .docs-public__foot{margin-top:.75rem;font-size:.8125rem;color:var(--dm-text-muted)}
23
+ @media print{#site-navbar,.navbar,footer,.docs-public__bar,.docs-public__foot{display:none!important}.docs-public{margin:0;max-width:none}.docs-public__page{border:0;padding:0}}
24
+ </style>
25
+ <main class="docs-public">
26
+ <div class="docs-public__bar">
27
+ <span>{{byline}}</span>
28
+ <span class="docs-public__actions">{{actions}}</span>
29
+ </div>
30
+ <article class="docs-public__page">
31
+ {{heading}}
32
+ {{body}}
33
+ </article>
34
+ <p class="docs-public__foot">{{updated}}</p>
35
+ </main>
36
+ <script>document.getElementById('docs-public-print')?.addEventListener('click',function(){window.print();});</script>
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Imported FIRST by the Docs tests: point the plugin at a throwaway data
3
+ * directory, so a test run never reads or purges a real site's documents.
4
+ */
5
+ import fs from 'fs';
6
+ import os from 'os';
7
+ import path from 'path';
8
+
9
+ export const DATA = fs.mkdtempSync(path.join(os.tmpdir(), 'docs-test-'));
10
+ process.env.DOCS_DATA_DIR = DATA;
@@ -0,0 +1,73 @@
1
+ /**
2
+ * A small, valid PDF for the tests: one or more pages of Helvetica text,
3
+ * with a correct cross-reference table. `extra` puts raw objects in (a
4
+ * /JavaScript action, an attached file) to see them refused.
5
+ */
6
+ import zlib from 'zlib';
7
+
8
+ /**
9
+ * @param {object} [o]
10
+ * @param {string[]} [o.pages] the text on each page
11
+ * @param {string} [o.extra] extra object bodies, appended as further objects (a string per object, joined by \n)
12
+ * @param {boolean} [o.deflate] compress the content streams (FlateDecode)
13
+ * @param {string} [o.catalogExtra] text added inside the catalog dictionary
14
+ * @returns {Buffer}
15
+ */
16
+ export function makePdf({pages = ['Hello Docs'], extra = [], deflate = false, catalogExtra = ''} = {}) {
17
+ const objects = [];
18
+ const add = (body) => { objects.push(body); return objects.length; };
19
+ const catalog = add(null);
20
+ const pagesObj = add(null);
21
+ const font = add('<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>');
22
+ const kids = [];
23
+ for (const text of pages) {
24
+ const safe = String(text).replace(/[\\()]/g, (c) => `\\${c}`);
25
+ const content = Buffer.from(`BT /F1 18 Tf 72 720 Td (${safe}) Tj ET`, 'latin1');
26
+ const data = deflate ? zlib.deflateSync(content) : content;
27
+ const stream = add(Buffer.concat([Buffer.from(`<< /Length ${data.length}${deflate ? ' /Filter /FlateDecode' : ''} >>\nstream\n`, 'latin1'), data, Buffer.from('\nendstream', 'latin1')]));
28
+ const page = add(`<< /Type /Page /Parent ${pagesObj} 0 R /MediaBox [0 0 612 792] /Resources << /Font << /F1 ${font} 0 R >> >> /Contents ${stream} 0 R >>`);
29
+ kids.push(`${page} 0 R`);
30
+ }
31
+ objects[pagesObj - 1] = `<< /Type /Pages /Kids [${kids.join(' ')}] /Count ${kids.length} >>`;
32
+ objects[catalog - 1] = `<< /Type /Catalog /Pages ${pagesObj} 0 R ${catalogExtra} >>`;
33
+ for (const body of extra) add(body);
34
+
35
+ const parts = [Buffer.from('%PDF-1.4\n%\xe2\xe3\xcf\xd3\n', 'latin1')];
36
+ const offsets = [];
37
+ let at = parts[0].length;
38
+ objects.forEach((body, i) => {
39
+ offsets.push(at);
40
+ const head = Buffer.from(`${i + 1} 0 obj\n`, 'latin1');
41
+ const b = Buffer.isBuffer(body) ? body : Buffer.from(body, 'latin1');
42
+ const tail = Buffer.from('\nendobj\n', 'latin1');
43
+ parts.push(head, b, tail);
44
+ at += head.length + b.length + tail.length;
45
+ });
46
+ const xref = [`xref\n0 ${objects.length + 1}\n0000000000 65535 f \n`, ...offsets.map(o => `${String(o).padStart(10, '0')} 00000 n \n`)].join('');
47
+ const trailer = `trailer\n<< /Size ${objects.length + 1} /Root ${catalog} 0 R >>\nstartxref\n${at}\n%%EOF\n`;
48
+ parts.push(Buffer.from(xref + trailer, 'latin1'));
49
+ return Buffer.concat(parts);
50
+ }
51
+
52
+ /**
53
+ * A real PNG (RGBA, with a little noise so it is bigger than a blank pad's), for
54
+ * the drawn-signature and picture tests: the server reads its header and
55
+ * refuses anything that is not one.
56
+ */
57
+ export function makePng(width = 200, height = 60) {
58
+ const crcTable = [];
59
+ for (let n = 0; n < 256; n++) { let c = n; for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1; crcTable[n] = c >>> 0; }
60
+ const crc = (buf) => { let c = 0xffffffff; for (const b of buf) c = crcTable[(c ^ b) & 0xff] ^ (c >>> 8); return (c ^ 0xffffffff) >>> 0; };
61
+ const chunk = (type, data) => { const len = Buffer.alloc(4); len.writeUInt32BE(data.length); const td = Buffer.concat([Buffer.from(type, 'latin1'), data]); const c = Buffer.alloc(4); c.writeUInt32BE(crc(td)); return Buffer.concat([len, td, c]); };
62
+ const ihdr = Buffer.alloc(13); ihdr.writeUInt32BE(width, 0); ihdr.writeUInt32BE(height, 4); ihdr[8] = 8; ihdr[9] = 6; ihdr[10] = 0; ihdr[11] = 0; ihdr[12] = 0;
63
+ const raw = Buffer.alloc((width * 4 + 1) * height);
64
+ for (let y = 0; y < height; y++) {
65
+ raw[y * (width * 4 + 1)] = 0;
66
+ for (let x = 0; x < width; x++) {
67
+ const i = y * (width * 4 + 1) + 1 + x * 4;
68
+ const on = (x * 7 + y * 13) % 11 < 3;
69
+ raw[i] = 20; raw[i + 1] = 20; raw[i + 2] = 30; raw[i + 3] = on ? 255 : 0;
70
+ }
71
+ }
72
+ return Buffer.concat([Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]), chunk('IHDR', ihdr), chunk('IDAT', zlib.deflateSync(raw)), chunk('IEND', Buffer.alloc(0))]);
73
+ }
@@ -0,0 +1,95 @@
1
+ import {test} from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import {startsWithTitle, parseHtml, htmlToMarkdown, htmlToText, countWords, excerpt, preview, outline, standaloneHtml, fileName, decodeEntities}
4
+ from '../admin/lib/convert.js';
5
+
6
+ /** Docs - the pure conversions behind export, excerpts and the Outline. */
7
+
8
+ test('parseHtml builds a tree, decodes entities and drops scripts', () => {
9
+ const t = parseHtml('<p class="x">a &amp; b<br>c</p><script>alert(1)</script><!-- note -->');
10
+ assert.equal(t.children.length, 1);
11
+ const p = t.children[0];
12
+ assert.equal(p.name, 'p');
13
+ assert.equal(p.attrs.class, 'x');
14
+ assert.equal(p.children[0].text, 'a & b');
15
+ assert.equal(p.children[1].name, 'br');
16
+ });
17
+
18
+ test('parseHtml closes implied paragraphs, list items and cells', () => {
19
+ const t = parseHtml('<ul><li>one<li>two</ul><p>para<h2>head</h2><table><tr><td>a<td>b<tr><td>c</table>');
20
+ const ul = t.children[0];
21
+ assert.equal(ul.children.length, 2);
22
+ assert.equal(t.children[2].name, 'h2');
23
+ const rows = t.children[3].children;
24
+ assert.equal(rows.length, 2);
25
+ assert.equal(rows[0].children.length, 2);
26
+ });
27
+
28
+ test('headings, emphasis, links and images', () => {
29
+ const md = htmlToMarkdown('<h1>Title</h1><p>Some <strong>bold</strong>, <em>italic</em>, <s>gone</s> and <a href="https://x.test/a b">a link</a>.</p><p><img src="/media/p.png" alt="Pic"></p>');
30
+ assert.equal(md, '# Title\n\nSome **bold**, *italic*, ~~gone~~ and [a link](https://x.test/a%20b).\n\n![Pic](/media/p.png)\n');
31
+ });
32
+
33
+ test('bold spaces stay outside the markers', () => {
34
+ assert.equal(htmlToMarkdown('<p>a<b> b </b>c</p>'), 'a **b** c\n');
35
+ });
36
+
37
+ test('markdown characters in text are escaped', () => {
38
+ assert.equal(htmlToMarkdown('<p>2 * 3 = [six] #1</p>'), '2 \\* 3 = \\[six\\] #1\n');
39
+ assert.equal(htmlToMarkdown('<p># not a heading</p>'), '\\# not a heading\n');
40
+ assert.equal(htmlToMarkdown('<p>1. not a list</p>'), '1\\. not a list\n');
41
+ });
42
+
43
+ test('nested lists and checklists', () => {
44
+ const md = htmlToMarkdown('<ul><li>one<ul><li>inner</li></ul></li><li>two</li></ul><ol start="3"><li>c</li><li>d</li></ol><ul data-type="checklist"><li data-checked="true">done</li><li data-checked="false">todo</li></ul>');
45
+ assert.equal(md, '- one\n\n - inner\n- two\n\n3. c\n4. d\n\n- [x] done\n- [ ] todo\n');
46
+ });
47
+
48
+ test('quotes, code and rules', () => {
49
+ const md = htmlToMarkdown('<blockquote><p>quoted</p><p>twice</p></blockquote><pre><code class="language-js">let a = 1;\n</code></pre><hr><p>use <code>x*y</code></p>');
50
+ assert.equal(md, '> quoted\n>\n> twice\n\n```js\nlet a = 1;\n```\n\n---\n\nuse `x*y`\n');
51
+ });
52
+
53
+ test('tables become GFM tables, pipes escaped', () => {
54
+ const md = htmlToMarkdown('<table><thead><tr><th>A</th><th>B</th></tr></thead><tbody><tr><td>1|2</td><td>3</td></tr></tbody></table>');
55
+ assert.equal(md, '| A | B |\n| --- | --- |\n| 1\\|2 | 3 |\n');
56
+ });
57
+
58
+ test('underline and highlight stay as inline HTML; divs and loose text survive', () => {
59
+ assert.equal(htmlToMarkdown('<div>loose <u>under</u><div><mark>hi</mark></div></div>'), 'loose <u>under</u>\n\n<mark>hi</mark>\n');
60
+ });
61
+
62
+ test('line breaks become hard breaks', () => {
63
+ assert.equal(htmlToMarkdown('<p>a<br>b</p>'), 'a\\\nb\n');
64
+ });
65
+
66
+ test('plain text, words and excerpts', () => {
67
+ const html = '<h1>Hi</h1><p>one two&nbsp;three</p><ul><li>four</li><li>five</li></ul>';
68
+ assert.equal(htmlToText(html), 'Hi\n\none two three\n\nfour\nfive');
69
+ assert.equal(countWords(html), 6);
70
+ assert.equal(countWords(''), 0);
71
+ assert.equal(excerpt('<p>' + 'word '.repeat(100) + '</p>', 20).length, 20);
72
+ });
73
+
74
+ test('outline lists headings in order', () => {
75
+ assert.deepEqual(outline('<h1>A</h1><p>x</p><div><h2>B <em>b</em></h2></div><h3> </h3>'),
76
+ [{level: 1, text: 'A'}, {level: 2, text: 'B b'}]);
77
+ });
78
+
79
+ test('standalone file escapes the title; file names are safe', () => {
80
+ const html = standaloneHtml('<Plan>', '<p>x</p>');
81
+ assert.match(html, /<title>&lt;Plan&gt;<\/title>/);
82
+ assert.equal(fileName('a/b: c?', 'md'), 'a b c.md');
83
+ assert.equal(fileName('', 'html'), 'Untitled.html');
84
+ assert.equal(decodeEntities('&#x41;&#66;&bogus;'), 'AB&bogus;');
85
+ });
86
+
87
+ test('preview keeps one line per block', () => {
88
+ assert.equal(preview('<h1>Plan</h1><p>Intro</p><ul><li>a</li><li>b</li></ul>'), 'Plan\nIntro\na\nb');
89
+ });
90
+
91
+ test('a body that opens with its title is not given the title twice', () => {
92
+ assert.equal(startsWithTitle('Plan & more', '<h1>Plan <em>&amp;</em> more</h1><p>x</p>'), true);
93
+ assert.equal(startsWithTitle('Plan', '<p>Plan</p>'), false);
94
+ assert.doesNotMatch(standaloneHtml('Plan', '<h1>Plan</h1>'), /<h1>Plan<\/h1>\n<h1>/);
95
+ });
@@ -0,0 +1,82 @@
1
+ import {test} from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import {draftKey, legacyDraftKey, normaliseDraft, makeDraft, draftDiffers, draftFate, draftQuestion, copyTitle, when} from '../admin/lib/drafts.js';
4
+
5
+ /** Docs - the editor's kept draft and its restore question (admin/lib/drafts.js). */
6
+
7
+ test('a draft is per signed-in person: keyed by user, and never read back for anyone else', () => {
8
+ assert.equal(draftKey('abc', 'u1'), 'docs.draft.u1.abc');
9
+ assert.equal(draftKey('abc', ''), '', 'nobody signed in: no key, no draft');
10
+ assert.equal(legacyDraftKey('abc'), 'docs.draft.abc');
11
+ const mine = {owner: 'u1', title: 'T', content: '<p>a</p>', at: 'A', was: 'W'};
12
+ assert.deepEqual(normaliseDraft(mine, 'u1'), mine);
13
+ assert.equal(normaliseDraft(mine, 'u2'), null, "another person's draft is no draft");
14
+ assert.equal(normaliseDraft({...mine, owner: undefined}, 'u1'), null, 'no owner (2.9) is never offered');
15
+ assert.equal(normaliseDraft(mine, ''), null);
16
+ });
17
+
18
+ test('a stored draft is whitelisted; `base` is read as `was`', () => {
19
+ assert.equal(normaliseDraft(null, 'u1'), null);
20
+ assert.equal(normaliseDraft('not json', 'u1'), null);
21
+ assert.equal(normaliseDraft({owner: 'u1', title: 'x'}, 'u1'), null, 'no content, no draft');
22
+ const old = normaliseDraft(JSON.stringify({owner: 'u1', title: 'T', content: '<p>a</p>', at: '2026-10-14T09:00:00.000Z', base: '2026-10-14T08:00:00.000Z', evil: 1}), 'u1');
23
+ assert.deepEqual(old, {owner: 'u1', title: 'T', content: '<p>a</p>', at: '2026-10-14T09:00:00.000Z', was: '2026-10-14T08:00:00.000Z'});
24
+ assert.equal(normaliseDraft({owner: 'u1', content: '', title: 5, was: 'w'}, 'u1').title, '');
25
+ });
26
+
27
+ test('makeDraft keeps the owner and the `was` it is given - never "now"', () => {
28
+ const d = makeDraft({owner: 'u1', title: 'T', content: 'c', was: 'W1', at: 'A1'});
29
+ assert.deepEqual(d, {owner: 'u1', title: 'T', content: 'c', at: 'A1', was: 'W1'});
30
+ assert.equal(makeDraft({content: 'c'}).was, '');
31
+ });
32
+
33
+ test('draftDiffers: content or a title of its own', () => {
34
+ const doc = {title: 'T', content: '<p>a</p>'};
35
+ assert.equal(draftDiffers({title: 'T', content: '<p>a</p>'}, doc), false);
36
+ assert.equal(draftDiffers({title: '', content: '<p>a</p>'}, doc), false);
37
+ assert.equal(draftDiffers({title: 'T2', content: '<p>a</p>'}, doc), true);
38
+ assert.equal(draftDiffers({title: 'T', content: '<p>b</p>'}, doc), true);
39
+ assert.equal(draftDiffers(null, doc), false);
40
+ assert.equal(draftDiffers({content: 'x'}, null), true);
41
+ });
42
+
43
+ test('draftFate: deleted, changed since `was`, or nothing', () => {
44
+ const d = {was: '2026-10-14T08:00:00.000Z', at: '2026-10-14T09:00:00.000Z'};
45
+ assert.equal(draftFate(d, null), 'deleted');
46
+ assert.equal(draftFate(d, {updatedAt: d.was}), '');
47
+ assert.equal(draftFate(d, {updatedAt: '2026-10-14T08:30:00.000Z'}), 'changed', 'saved elsewhere before the draft was even kept');
48
+ assert.equal(draftFate(d, {updatedAt: d.was, trashedAt: 'x'}), 'trashed');
49
+ // No `was`: only a save after the draft was kept counts.
50
+ assert.equal(draftFate({at: d.at}, {updatedAt: '2026-10-14T08:30:00.000Z'}), '');
51
+ assert.equal(draftFate({at: d.at}, {updatedAt: '2026-10-14T10:00:00.000Z'}), 'changed');
52
+ });
53
+
54
+ test('draftQuestion: OK throws away; Cancel keeps - put back, or a new document when it has gone', () => {
55
+ const d = {title: 'Q3 <b>"plan"</b>', content: 'c', was: 'W', at: '2026-10-14T09:00:00.000Z'};
56
+ const plain = draftQuestion(d, {title: 'Q3', updatedAt: 'W'});
57
+ assert.equal(plain.copy, false);
58
+ assert.equal(plain.options.confirmText, 'Throw away');
59
+ assert.equal(plain.options.cancelText, 'Put them back');
60
+ assert.match(plain.text, /^Throw away the changes/);
61
+ assert.ok(plain.text.includes('Q3 &lt;b&gt;&quot;plan&quot;&lt;/b&gt;'), 'the title is escaped for E.confirm');
62
+ assert.ok(!/saved since|deleted/.test(plain.text));
63
+
64
+ const changed = draftQuestion(d, {title: 'Q3', updatedAt: '2026-10-14T10:00:00.000Z'});
65
+ assert.equal(changed.copy, false);
66
+ assert.match(changed.text, /saved since/);
67
+ assert.match(changed.text, /Version history/);
68
+
69
+ const gone = draftQuestion(d, null);
70
+ assert.equal(gone.copy, true);
71
+ assert.equal(gone.options.confirmText, 'Throw away');
72
+ assert.match(gone.options.cancelText, /new document/);
73
+ assert.match(gone.text, /deleted since/);
74
+ });
75
+
76
+ test('copyTitle and when', () => {
77
+ assert.equal(copyTitle({title: 'Plan'}), 'Plan (your unsaved changes)');
78
+ assert.equal(copyTitle({title: ' '}), 'Untitled (your unsaved changes)');
79
+ assert.ok(copyTitle({title: 'x'.repeat(300)}).length <= 200);
80
+ assert.equal(when(''), '');
81
+ assert.match(when('2026-10-14T09:05:00.000Z'), /^\d{1,2} Oct, \d\d:\d\d$/);
82
+ });
@@ -0,0 +1,86 @@
1
+ import './_env.js';
2
+ import {test} from 'node:test';
3
+ import assert from 'node:assert/strict';
4
+ import zlib from 'zlib';
5
+ import {makePdf} from './_pdf.js';
6
+ import {inspectPdf, extractPdfText, extractToHtml, extractCapability, contentDisposition, cleanFileName, sizeText, REFUSED_NAMES} from '../lib/pdf.js';
7
+
8
+ /** lib/pdf.js on its own: the checks a file passes before Docs keeps it, and the text read out of it. */
9
+
10
+ test('inspectPdf: a PDF passes with its page count and hash; anything else is refused with a plain reason', () => {
11
+ const ok = inspectPdf(makePdf({pages: ['a', 'b', 'c']}));
12
+ assert.equal(ok.ok, true);
13
+ assert.equal(ok.pages, 3);
14
+ assert.match(ok.sha256, /^[a-f0-9]{64}$/);
15
+ assert.equal(ok.encrypted, false);
16
+ assert.deepEqual(inspectPdf(Buffer.alloc(0)), {ok: false, reason: 'The file is empty.'});
17
+ assert.match(inspectPdf(Buffer.from('<html>'), {}).reason, /not a PDF/);
18
+ assert.match(inspectPdf(makePdf(), {maxBytes: 100}).reason, /over 0 MB/);
19
+ assert.equal(inspectPdf(makePdf({extra: ['<< /Filter /Standard /V 2 >>'], catalogExtra: '/Encrypt 9 0 R'})).encrypted, true);
20
+ });
21
+
22
+ test('inspectPdf: every refused name is caught plain, #-escaped, and inside a deflated stream; lookalikes pass', () => {
23
+ for (const name of Object.keys(REFUSED_NAMES)) {
24
+ const plain = inspectPdf(makePdf({extra: [`<< /${name} 1 >>`]}));
25
+ assert.equal(plain.ok, false, name);
26
+ assert.match(plain.reason, new RegExp(REFUSED_NAMES[name].replace(/[()]/g, '\\$&')));
27
+ const escaped = `/${name.slice(0, 1)}#${name.charCodeAt(1).toString(16)}${name.slice(2)}`;
28
+ assert.equal(inspectPdf(makePdf({extra: [`<< ${escaped} 1 >>`]})).ok, false, `${name} escaped as ${escaped}`);
29
+ const deflated = zlib.deflateSync(Buffer.from(`<< /${name} 1 >>`));
30
+ const hidden = makePdf({extra: [Buffer.concat([Buffer.from(`<< /Length ${deflated.length} /Filter /FlateDecode >>\nstream\n`), deflated, Buffer.from('\nendstream')])]});
31
+ assert.equal(inspectPdf(hidden).ok, false, `${name} deflated`);
32
+ }
33
+ // A name that merely starts the same way is a different name.
34
+ assert.equal(inspectPdf(makePdf({extra: ['<< /JSON 1 /Launcher 2 /XFAB 3 >>']})).ok, true);
35
+ // Text inside a content stream that says the word is not a name.
36
+ assert.equal(inspectPdf(makePdf({pages: ['JavaScript is fun /JavaScript']})).ok, false, 'a slash-name in a string is still refused - the check is by bytes, not meaning');
37
+ assert.equal(inspectPdf(makePdf({pages: ['JavaScript is fun']})).ok, true);
38
+ });
39
+
40
+ test('inspectPdf: a stream that inflates to too much is refused quickly, never followed', () => {
41
+ // 200 MB of zeros deflate to a few KB.
42
+ const bomb = zlib.deflateSync(Buffer.alloc(200 * 1024 * 1024));
43
+ const pdf = makePdf({extra: [Buffer.concat([Buffer.from(`<< /Length ${bomb.length} /Filter /FlateDecode >>\nstream\n`), bomb, Buffer.from('\nendstream')])]});
44
+ const t0 = Date.now();
45
+ assert.equal(inspectPdf(pdf).ok, false);
46
+ assert.ok(Date.now() - t0 < 5000, 'capped, so quick');
47
+ });
48
+
49
+ test('extractPdfText: the text of each page, or null when it cannot be read', {skip: extractCapability().available ? false : extractCapability().reason}, async () => {
50
+ const r = await extractPdfText(makePdf({pages: ['Hello Docs', 'Second (page)'], deflate: true}));
51
+ assert.deepEqual(r, {pages: 2, text: 'Hello Docs\n\nSecond (page)'});
52
+ assert.equal(await extractPdfText(Buffer.from('%PDF-1.4 not really')), null);
53
+ assert.equal(await extractPdfText(makePdf(), {timeoutMs: 1}), null, 'out of time is null, not an error');
54
+ });
55
+
56
+ test('extractToHtml, cleanFileName, contentDisposition, sizeText', () => {
57
+ assert.equal(extractToHtml('One <b>\ntwo\n\nThree'), '<p>One &lt;b&gt;<br>two</p><p>Three</p>');
58
+ assert.equal(extractToHtml(''), '');
59
+ assert.equal(cleanFileName('../../etc/passwd\u0000.pdf'), 'passwd.pdf');
60
+ assert.equal(cleanFileName('C:\\Users\\me\\Q3 report.pdf'), 'Q3 report.pdf');
61
+ assert.equal(cleanFileName(''), 'document.pdf');
62
+ assert.equal(contentDisposition('Q3 "report" é.pdf'), `attachment; filename="Q3 report _.pdf"; filename*=UTF-8''Q3%20%22report%22%20%C3%A9.pdf`);
63
+ assert.match(contentDisposition('x.pdf', true), /^inline; /);
64
+ assert.equal(sizeText(877), '877 B');
65
+ assert.equal(sizeText(1536), '2 KB');
66
+ assert.equal(sizeText(1.5 * 1024 * 1024), '1.5 MB');
67
+ assert.equal(sizeText(12 * 1024 * 1024), '12 MB');
68
+ });
69
+
70
+ test('inspectPdf: a name inside an object stream with a nested dictionary, a double-deflated stream, and a stream past the cap', () => {
71
+ const name = Buffer.from('<< /S /JavaScript /JS (x) >>');
72
+ // The stream dictionary holds a nested one (every object stream with /DecodeParms does): still inflated and searched.
73
+ const z = zlib.deflateSync(name);
74
+ const nested = makePdf({extra: [Buffer.concat([Buffer.from(`<< /Type /ObjStm /Filter /FlateDecode /DecodeParms << /Predictor 12 /Columns 4 >> /Length ${z.length} >>\nstream\n`), z, Buffer.from('\nendstream')])]});
75
+ assert.equal(inspectPdf(nested).ok, false, 'nested dictionary');
76
+ // Deflated twice (/Filter [/FlateDecode /FlateDecode]).
77
+ const zz = zlib.deflateSync(zlib.deflateSync(name));
78
+ const twice = makePdf({extra: [Buffer.concat([Buffer.from(`<< /Filter [/FlateDecode /FlateDecode] /Length ${zz.length} >>\nstream\n`), zz, Buffer.from('\nendstream')])]});
79
+ assert.equal(inspectPdf(twice).ok, false, 'double deflate');
80
+ // A stream that inflates past the cap cannot be checked: refused, not skipped.
81
+ const bomb = zlib.deflateSync(Buffer.alloc(40 * 1024 * 1024));
82
+ const big = makePdf({extra: [Buffer.concat([Buffer.from(`<< /Length ${bomb.length} /Filter /FlateDecode >>\nstream\n`), bomb, Buffer.from('\nendstream')])]});
83
+ const r = inspectPdf(big);
84
+ assert.equal(r.ok, false);
85
+ assert.match(r.reason, /too large to check/);
86
+ });