domma-cms 1.8.33 → 1.8.35

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 (88) hide show
  1. package/CHANGELOG.md +8 -0
  2. package/package.json +1 -1
  3. package/plugins/docs/CLAUDE.md +318 -0
  4. package/plugins/docs/admin/css/docs.css +783 -0
  5. package/plugins/docs/admin/lib/api.js +340 -0
  6. package/plugins/docs/admin/lib/convert.js +482 -0
  7. package/plugins/docs/admin/lib/drafts.js +115 -0
  8. package/plugins/docs/admin/lib/editor.js +851 -0
  9. package/plugins/docs/admin/lib/find-replace.js +426 -0
  10. package/plugins/docs/admin/lib/folders.js +203 -0
  11. package/plugins/docs/admin/lib/panel-guard.js +73 -0
  12. package/plugins/docs/admin/lib/pdf-view.js +126 -0
  13. package/plugins/docs/admin/lib/send.js +156 -0
  14. package/plugins/docs/admin/lib/settings.js +142 -0
  15. package/plugins/docs/admin/lib/share.js +323 -0
  16. package/plugins/docs/admin/lib/shortcuts.js +44 -0
  17. package/plugins/docs/admin/lib/templates.js +194 -0
  18. package/plugins/docs/admin/lib/versions.js +192 -0
  19. package/plugins/docs/admin/templates/docs.html +92 -0
  20. package/plugins/docs/admin/templates/editor.html +86 -0
  21. package/plugins/docs/admin/views/docs.js +777 -0
  22. package/plugins/docs/admin/views/editor.js +978 -0
  23. package/plugins/docs/config.js +24 -0
  24. package/plugins/docs/docs/guide.md +270 -0
  25. package/plugins/docs/lib/extensions.js +213 -0
  26. package/plugins/docs/lib/importer.js +72 -0
  27. package/plugins/docs/lib/pdf-worker.js +48 -0
  28. package/plugins/docs/lib/pdf.js +222 -0
  29. package/plugins/docs/lib/store.js +748 -0
  30. package/plugins/docs/plugin.js +1108 -0
  31. package/plugins/docs/plugin.json +89 -0
  32. package/plugins/docs/plugin.public.js +114 -0
  33. package/plugins/docs/templates/public-doc.html +36 -0
  34. package/plugins/docs/tests/_env.js +10 -0
  35. package/plugins/docs/tests/_pdf.js +73 -0
  36. package/plugins/docs/tests/convert.test.js +95 -0
  37. package/plugins/docs/tests/drafts.test.js +82 -0
  38. package/plugins/docs/tests/pdf.test.js +86 -0
  39. package/plugins/docs/tests/plugin.test.js +816 -0
  40. package/plugins/docs/tests/shortcuts.test.js +30 -0
  41. package/plugins/free-tier.lock.json +105 -1
  42. package/plugins/garage/CLAUDE.md +51 -0
  43. package/plugins/garage/admin/css/garage.css +73 -0
  44. package/plugins/garage/admin/lib/garage.js +236 -0
  45. package/plugins/garage/admin/templates/garage.html +86 -0
  46. package/plugins/garage/admin/views/garage.js +581 -0
  47. package/plugins/garage/collections/garage-vehicles/schema.json +101 -0
  48. package/plugins/garage/config.js +18 -0
  49. package/plugins/garage/docs/guide.md +118 -0
  50. package/plugins/garage/plugin.js +471 -0
  51. package/plugins/garage/plugin.json +68 -0
  52. package/plugins/garage/tests/access.test.js +134 -0
  53. package/plugins/garage/tests/garage.test.js +106 -0
  54. package/plugins/messages/CLAUDE.md +230 -0
  55. package/plugins/messages/admin/css/messages.css +117 -0
  56. package/plugins/messages/admin/lib/messages.js +452 -0
  57. package/plugins/messages/admin/templates/messages.html +76 -0
  58. package/plugins/messages/admin/views/messages.js +1219 -0
  59. package/plugins/messages/collections/user-message-threads/schema.json +22 -0
  60. package/plugins/messages/collections/user-messages/schema.json +23 -0
  61. package/plugins/messages/config.js +14 -0
  62. package/plugins/messages/docs/guide.md +176 -0
  63. package/plugins/messages/lib/call.js +138 -0
  64. package/plugins/messages/plugin.js +994 -0
  65. package/plugins/messages/plugin.json +59 -0
  66. package/plugins/messages/plugin.public.js +52 -0
  67. package/plugins/messages/templates/call.html +6 -0
  68. package/plugins/messages/tests/after-send.test.js +22 -0
  69. package/plugins/messages/tests/api.test.js +187 -0
  70. package/plugins/messages/tests/call.test.js +497 -0
  71. package/plugins/messages/tests/drafts.test.js +25 -0
  72. package/plugins/messages/tests/messages.test.js +167 -0
  73. package/plugins/theme-roller/CLAUDE.md +65 -0
  74. package/plugins/theme-roller/admin/css/theme-roller.css +43 -0
  75. package/plugins/theme-roller/admin/lib/prompt.js +22 -0
  76. package/plugins/theme-roller/admin/lib/themes.js +196 -0
  77. package/plugins/theme-roller/admin/lib/view.js +88 -0
  78. package/plugins/theme-roller/admin/templates/theme-roller.html +73 -0
  79. package/plugins/theme-roller/admin/views/theme-roller-view.js +639 -0
  80. package/plugins/theme-roller/config.js +1 -0
  81. package/plugins/theme-roller/docs/guide.md +104 -0
  82. package/plugins/theme-roller/plugin.js +263 -0
  83. package/plugins/theme-roller/plugin.json +42 -0
  84. package/plugins/theme-roller/tests/api.test.js +161 -0
  85. package/plugins/theme-roller/tests/prompt-values.test.js +22 -0
  86. package/plugins/theme-roller/tests/themes.test.js +77 -0
  87. package/plugins/theme-roller/tests/view.test.js +55 -0
  88. package/server/routes/public.js +16 -0
@@ -0,0 +1,77 @@
1
+ import {test} from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import {slugify, uniqueId, cleanChanges, cleanLabel, usageOf, withoutTheme, themeCss, isSafeValue} from '../admin/lib/themes.js';
4
+
5
+ test('slugify makes a class-safe id', () => {
6
+ assert.equal(slugify(' Café Brand!! '), 'cafe-brand');
7
+ assert.equal(slugify('!!!'), '');
8
+ assert.equal(slugify('x'.repeat(80)).length, 60);
9
+ });
10
+
11
+ test('uniqueId never collides with a built-in theme or a saved one', () => {
12
+ assert.equal(uniqueId('Ocean Dark', {builtins: ['ocean-dark']}), 'ocean-dark-2');
13
+ assert.equal(uniqueId('Brand', {taken: ['brand', 'brand-2']}), 'brand-3');
14
+ assert.equal(uniqueId('Brand'), 'brand');
15
+ assert.equal(uniqueId('???'), '');
16
+ });
17
+
18
+ test('only real tokens with plain values survive', () => {
19
+ const {changes, dropped} = cleanChanges({
20
+ '--dm-primary': ' #ff0000 ', '--dm-surface': 'red; } body { display: none', color: 'blue',
21
+ '--dm-bg': 'url(https://evil)', '--dm-radius-md': '8px'
22
+ });
23
+ assert.deepEqual(changes, {'--dm-primary': '#ff0000', '--dm-radius-md': '8px'});
24
+ assert.deepEqual(dropped.sort(), ['--dm-bg', '--dm-surface', 'color']);
25
+ assert.equal(isSafeValue('rgba(0, 0, 0, .5)'), true);
26
+ });
27
+
28
+ test('labels are tidy and never empty', () => {
29
+ assert.equal(cleanLabel(' My theme '), 'My theme');
30
+ assert.equal(cleanLabel(''), 'Untitled theme');
31
+ assert.equal(cleanLabel('', 'fallback'), 'fallback');
32
+ });
33
+
34
+ test('usageOf reads the CMS theme config the way the renderer does', () => {
35
+ const cfg = {theme: 'mine', adminTheme: 'mine', autoTheme: {enabled: false, dayTheme: 'mine'},
36
+ overrides: [{theme: 'mine'}, {theme: 'mine', enabled: false}]};
37
+ assert.deepEqual(usageOf('mine', cfg), ['site', 'admin', 'region']);
38
+ const auto = {theme: 'mine', autoTheme: {enabled: true, dayTheme: 'mine', nightTheme: 'mine'}};
39
+ assert.deepEqual(usageOf('mine', auto), ['day', 'night'], 'with switching on, the site theme is not what shows');
40
+ assert.deepEqual(usageOf('mine', {}), []);
41
+ });
42
+
43
+ test('withoutTheme puts every use back on the base, and nothing else', () => {
44
+ const cfg = {theme: 'mine', adminTheme: 'charcoal-dark', autoTheme: {enabled: true, dayTheme: 'mine', nightTheme: 'x'},
45
+ overrides: [{id: 'a', theme: 'mine'}, {id: 'b', theme: 'y'}]};
46
+ assert.deepEqual(withoutTheme(cfg, 'mine', 'ocean-dark'), {
47
+ theme: 'ocean-dark',
48
+ autoTheme: {enabled: true, dayTheme: 'ocean-dark', nightTheme: 'x'},
49
+ overrides: [{id: 'a', theme: 'ocean-dark'}, {id: 'b', theme: 'y'}]
50
+ });
51
+ assert.equal(withoutTheme({theme: 'other'}, 'mine', 'ocean-dark'), null);
52
+ });
53
+
54
+ test('exported CSS is scoped to the class the CMS applies', () => {
55
+ const css = themeCss({id: 'brand', label: 'Brand */ evil', baseTheme: 'ocean-dark', changes: {'--dm-primary': '#123456', bad: 'x'}}, '2026-09-25');
56
+ assert.match(css, /\.dm-custom-brand \{\n {4}--dm-primary: #123456;\n\}/);
57
+ assert.match(css, /dm-theme-ocean-dark dm-custom-brand/);
58
+ assert.ok(!css.includes('*/ evil'), 'a label cannot close the comment');
59
+ assert.ok(!css.includes('bad'));
60
+ });
61
+
62
+ test('the sidebar badge counts saved themes; the popover says what is worn and where each is used', async () => {
63
+ const {badgeFor} = await import('../admin/lib/themes.js');
64
+ const themes = [
65
+ {id: 'ruby', label: 'Ruby', baseTheme: 'silver-dark', usage: ['site', 'admin']},
66
+ {id: 'sea', label: 'Sea', baseTheme: 'ocean-light', usage: []}
67
+ ];
68
+ const b = badgeFor(themes, {theme: 'ruby', adminTheme: 'ruby', autoTheme: {enabled: false}});
69
+ assert.equal(b.count, 2);
70
+ assert.equal(b.label, '2 custom themes, 1 in use');
71
+ assert.deepEqual(b.details.rows, [{label: 'Site theme', value: 'Ruby'}, {label: 'Admin theme', value: 'Ruby'}]);
72
+ assert.deepEqual(b.details.items[0], {text: 'Ruby', meta: 'On silver-dark · Site, Admin', tone: 'ok'});
73
+ assert.deepEqual(b.details.items[1], {text: 'Sea', meta: 'On ocean-light'});
74
+ const auto = badgeFor(themes, {adminTheme: 'dreamy-light', autoTheme: {enabled: true, dayTheme: 'sea', nightTheme: 'charcoal-dark'}});
75
+ assert.deepEqual(auto.details.rows[0], {label: 'Site (day / night)', value: 'Sea / charcoal-dark'});
76
+ assert.equal(badgeFor([], {}).label, 'No custom themes yet');
77
+ });
@@ -0,0 +1,55 @@
1
+ /** Theme Roller 1.2 - the screen's pure decisions (admin/lib/view.js). */
2
+ import {test} from 'node:test';
3
+ import assert from 'node:assert/strict';
4
+ import {changesKey, filterThemes, listCounts, staleNote, stillDirty} from '../admin/lib/view.js';
5
+
6
+ const THEMES = [
7
+ {id: 'brand', label: 'Brand', baseTheme: 'ocean-light', usage: ['site'], updatedAt: '2026-10-15T10:00:00Z'},
8
+ {id: 'night', label: 'Night "owl"', baseTheme: 'charcoal-dark', usage: [], updatedAt: '2026-10-14T10:00:00Z'}
9
+ ];
10
+
11
+ test('filter counts and the filter itself, with search', () => {
12
+ assert.deepEqual(listCounts(THEMES), {all: 2, used: 1, unused: 1});
13
+ assert.deepEqual(listCounts(), {all: 0, used: 0, unused: 0});
14
+ assert.deepEqual(filterThemes(THEMES, 'used').map(t => t.id), ['brand']);
15
+ assert.deepEqual(filterThemes(THEMES, 'unused').map(t => t.id), ['night']);
16
+ assert.deepEqual(filterThemes(THEMES, 'all', 'charcoal').map(t => t.id), ['night']);
17
+ assert.deepEqual(filterThemes(THEMES, 'all', '"OWL"').map(t => t.id), ['night']);
18
+ assert.deepEqual(filterThemes(THEMES, 'used', 'night'), []);
19
+ });
20
+
21
+ test('a save leaves changes made while it ran unsaved', () => {
22
+ const a = changesKey({'--dm-primary': '#f00', '--dm-surface': '#fff'});
23
+ assert.equal(a, changesKey({'--dm-surface': '#fff', '--dm-primary': '#f00'}), 'order does not matter');
24
+ assert.equal(stillDirty(`ocean|${a}`, `ocean|${a}`), false);
25
+ assert.equal(stillDirty(`ocean|${a}`, `ocean|${changesKey({'--dm-primary': '#0f0'})}`), true);
26
+ assert.equal(stillDirty(`ocean|${a}`, `forest|${a}`), true, 'a new base counts');
27
+ });
28
+
29
+ test('a kept draft says when it is older than what is saved now', () => {
30
+ assert.equal(staleNote({theme: {id: 'brand', was: '2026-10-15T10:00:00Z'}}, THEMES), '');
31
+ assert.match(staleNote({theme: {id: 'brand', was: '2026-10-01T00:00:00Z'}}, THEMES), /saved again since/);
32
+ assert.match(staleNote({theme: {id: 'gone', was: 'x'}}, THEMES), /deleted since/);
33
+ assert.equal(staleNote({theme: {id: '', was: ''}}, THEMES), '', 'a new theme has nothing to be older than');
34
+ assert.equal(staleNote({css: 'a{}', cssWas: 'b{}'}, THEMES, 'b{}'), '');
35
+ assert.match(staleNote({css: 'a{}', cssWas: 'b{}'}, THEMES, 'c{}'), /custom\.css has changed/);
36
+ assert.equal(staleNote({css: 'a{}', cssWas: 'b{}'}, THEMES, null), '', 'the file could not be read - say nothing');
37
+ assert.equal(staleNote({css: 'a{}'}, THEMES, 'c{}'), '', 'an old draft without cssWas');
38
+ });
39
+
40
+ // 1.2.1 sweep: the restore question (OK throws away, Cancel keeps) and the per-user key.
41
+ import {RESTORE_ANSWERS, draftKeyFor, restoreQuestion} from '../admin/lib/view.js';
42
+
43
+ test('the restore question asks to throw away, naming what was left', () => {
44
+ assert.equal(restoreQuestion({theme: {id: 'brand', label: 'Brand'}}), 'Throw away the changes you left unsaved to the theme "Brand"?');
45
+ assert.equal(restoreQuestion({theme: {id: '', label: 'New theme'}, css: 'a{}'}),
46
+ 'Throw away the changes you left unsaved to a new theme and the Custom CSS?');
47
+ assert.match(restoreQuestion({css: ''}, 'Note.'), /to the Custom CSS\? Note\.$/);
48
+ assert.equal(RESTORE_ANSWERS.confirmText, 'Throw away');
49
+ assert.equal(RESTORE_ANSWERS.cancelText, 'Keep them');
50
+ });
51
+ test('the draft key carries the signed-in user; no user keeps nothing', () => {
52
+ assert.equal(draftKeyFor('u1'), 'theme-roller.drafts.u1');
53
+ assert.notEqual(draftKeyFor('u1'), draftKeyFor('u2'));
54
+ assert.equal(draftKeyFor(''), null);
55
+ });
@@ -44,6 +44,22 @@ export function getBaseUrl(request) {
44
44
  import {sendHtml} from '../services/htmlResponse.js';
45
45
  export {sendHtml};
46
46
 
47
+ /**
48
+ * Escape user-controlled strings before interpolating into HTML.
49
+ * Prevents reflected XSS in the built-in 403 and 404 pages.
50
+ *
51
+ * @param {string} str
52
+ * @returns {string}
53
+ */
54
+ function escapeHtml(str) {
55
+ return String(str)
56
+ .replace(/&/g, '&')
57
+ .replace(/</g, '&lt;')
58
+ .replace(/>/g, '&gt;')
59
+ .replace(/"/g, '&quot;')
60
+ .replace(/'/g, '&#39;');
61
+ }
62
+
47
63
  export async function publicRoutes(fastify) {
48
64
  // Admin panel: serve index.html for all /admin/* paths (SPA fallback)
49
65
  fastify.get('/admin', async (request, reply) => {