domma-cms 1.8.33 → 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 (87) hide show
  1. package/CHANGELOG.md +4 -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
@@ -0,0 +1,167 @@
1
+ /** The pure part of Messages - admin/lib/messages.js. */
2
+ import {test} from 'node:test';
3
+ import assert from 'node:assert/strict';
4
+ import {
5
+ ago, arrangeThreads, attachmentUrl, buildBubbles, cleanAttachments, cleanDrafts, dayLabel, filterCounts,
6
+ indexByThread, initials, messageRights, threadTitle, unreadBadge, UNREAD_AFTER_MS, unreadKey, unreadNotices,
7
+ CALL_PICK_MAX, callGroupName, callTarget, callTargetText} from '../admin/lib/messages.js';
8
+
9
+ const me = 'u1';
10
+ const T = (id, extra = {}) => ({id, kind: 'group-adhoc', name: id.toUpperCase(), participants: [{id: me, name: 'Me'}, {id: 'u2', name: 'Ann'}], unread: 0, lastMessageAt: '2026-09-20T10:00:00Z', ...extra});
11
+
12
+ test('titles: a DM is the other person, a group its name', () => {
13
+ assert.equal(threadTitle({kind: 'dm', participants: [{id: me, name: 'Me'}, {id: 'u2', name: 'Ann Lee'}]}, me), 'Ann Lee');
14
+ assert.equal(threadTitle({kind: 'dm', participants: [me, 'u9']}, me), 'u9');
15
+ assert.equal(threadTitle({kind: 'group-adhoc', name: ''}, me), 'Unnamed group');
16
+ assert.equal(initials('Ann Lee'), 'AL');
17
+ assert.equal(initials(''), '?');
18
+ });
19
+
20
+ test('arrange: newest first; inbox hides archived; unread and archived filters; search', () => {
21
+ const list = [T('a', {lastMessageAt: '2026-09-01T00:00:00Z', unread: 2}), T('b', {archivedBy: [me]}), T('c', {lastMessageAt: '2026-09-24T00:00:00Z', lastMessagePreview: 'budget'})];
22
+ assert.deepEqual(arrangeThreads(list, {myId: me}).map(t => t.id), ['c', 'a']);
23
+ assert.deepEqual(arrangeThreads(list, {myId: me, filter: 'unread'}).map(t => t.id), ['a']);
24
+ assert.deepEqual(arrangeThreads(list, {myId: me, filter: 'archived'}).map(t => t.id), ['b']);
25
+ assert.deepEqual(arrangeThreads(list, {myId: me, query: 'BUDGET'}).map(t => t.id), ['c']);
26
+ assert.deepEqual(filterCounts(list, me), {inbox: 2, unread: 1, archived: 1});
27
+ // Someone else's archive is not mine.
28
+ assert.equal(arrangeThreads([T('x', {archivedBy: ['u2']})], {myId: me}).length, 1);
29
+ });
30
+
31
+ test('rights: edit only mine, inside the window; delete mine, or as admin or thread creator', () => {
32
+ const now = Date.parse('2026-09-25T12:00:00Z');
33
+ const m = {senderId: me, createdAt: '2026-09-25T11:50:00Z'};
34
+ assert.deepEqual([messageRights(m, {myId: me, now}).canEdit, messageRights(m, {myId: me, now}).editLeftMinutes], [true, 5]);
35
+ assert.equal(messageRights({...m, createdAt: '2026-09-25T11:00:00Z'}, {myId: me, now}).canEdit, false);
36
+ const theirs = {senderId: 'u2', createdAt: m.createdAt};
37
+ assert.equal(messageRights(theirs, {myId: me, now}).canDelete, false);
38
+ assert.equal(messageRights(theirs, {myId: me, now, isAdmin: true}).canDelete, true);
39
+ assert.equal(messageRights(theirs, {myId: me, now, threadCreator: me}).canDelete, true);
40
+ assert.equal(messageRights({...m, deletedAt: 'x'}, {myId: me, now}).canDelete, false);
41
+ });
42
+
43
+ test('attachments: the address is always rebuilt - a stored url is never trusted', () => {
44
+ assert.equal(attachmentUrl({mediaId: 'a b.png'}), '/media/a%20b.png');
45
+ assert.equal(attachmentUrl({mediaId: 'x.png', url: 'javascript:alert(1)'}), '/media/x.png');
46
+ assert.equal(attachmentUrl({url: '/media/y.png'}), '/media/y.png');
47
+ const clean = cleanAttachments([{mediaId: 'ok.pdf', filename: 'Ok', url: 'https://evil'}, {mediaId: '../etc/passwd'}, {filename: 'no id'}]);
48
+ assert.deepEqual(clean, [{mediaId: 'ok.pdf', filename: 'Ok', mime: '', size: 0, url: '/media/ok.pdf'}]);
49
+ });
50
+
51
+ test('drafts: whitelisted, empty ones dropped', () => {
52
+ const d = cleanDrafts({t1: {text: 'hi', attachments: [{mediaId: 'a.png', filename: 'a'}, {nope: 1}]}, t2: {text: ' ', attachments: []}, t3: 'junk'});
53
+ assert.deepEqual(Object.keys(d), ['t1']);
54
+ assert.equal(d.t1.attachments.length, 1);
55
+ assert.deepEqual(cleanDrafts(null), {});
56
+ });
57
+
58
+ test('bubbles: a day separator on each new day; the sender only in groups, only when it changes', () => {
59
+ const now = Date.parse('2026-09-25T12:00:00Z');
60
+ const msgs = [
61
+ {id: '1', senderId: 'u2', body: 'hi', createdAt: '2026-09-24T09:00:00Z'},
62
+ {id: '2', senderId: 'u2', body: 'again', createdAt: '2026-09-24T09:01:00Z'},
63
+ {id: '3', senderId: me, body: 'yo', createdAt: '2026-09-25T09:00:00Z', editedAt: 'x'},
64
+ {id: '4', senderId: 'u2', body: '', deletedAt: 'x', createdAt: '2026-09-25T09:05:00Z', attachments: [{mediaId: 'a.png', mime: 'image/png'}]}
65
+ ];
66
+ const b = buildBubbles(msgs, {myId: me, isGroup: true, nameOf: () => 'Ann', now});
67
+ assert.deepEqual(b.map(x => x.isDay ? `[${x.dayLabel}]` : x.id), ['[Yesterday]', '1', '2', '[Today]', '3', '4']);
68
+ assert.deepEqual(b.filter(x => !x.isDay).map(x => x.showSender), [true, false, false, true]);
69
+ assert.match(b[4].meta, /edited/);
70
+ assert.equal(b[5].bodyText, 'message deleted');
71
+ assert.equal(b[5].attachments.length, 0, 'a deleted message shows no attachments');
72
+ assert.equal(buildBubbles(msgs, {myId: me, isGroup: false, now}).some(x => x.showSender), false);
73
+ assert.equal(dayLabel('nonsense', now), '');
74
+ // 1.6: a line typed in the conversation's call says so beside the time.
75
+ const inCall = buildBubbles([{id: '5', senderId: 'u2', body: 'from the call', createdAt: '2026-09-25T10:00:00Z', callChat: {id: 'x', who: 'w'}}], {myId: me, now});
76
+ assert.match(inCall.find(x => x.id === '5').meta, /in the call/);
77
+ });
78
+
79
+ test('badge: unread in conversations not archived; plain text popover', () => {
80
+ const now = Date.parse('2026-09-25T12:00:00Z');
81
+ const b = unreadBadge([T('a', {unread: 2}), T('b', {unread: 5, archivedBy: [me]}), T('c', {unread: 1, name: '<b>x</b>'})], me, now);
82
+ assert.equal(b.count, 3);
83
+ assert.equal(b.label, '3 unread messages in 2 conversations');
84
+ assert.equal(b.tone, 'info');
85
+ assert.deepEqual(b.details.rows.map(r => r.value), ['3', '2', '1']);
86
+ assert.ok(b.details.items.some(i => i.text === '<b>x</b>'), 'names pass through as text');
87
+ assert.match(b.details.items.find(i => i.text === 'A').meta, /^2 unread · /);
88
+ assert.equal(unreadBadge([], me, now).count, 0);
89
+ assert.equal(unreadBadge([], me, now).showZero, true);
90
+ assert.equal(b.showZero, true);
91
+ });
92
+
93
+ test('unread notices: waiting 10 minutes, one per conversation, to the recipient only', () => {
94
+ const now = Date.parse('2026-09-26T12:00:00Z');
95
+ const at = (mins) => new Date(now - mins * 60_000).toISOString();
96
+ const threads = [
97
+ {id: 't1', kind: 'dm', participants: ['a', 'b']},
98
+ {id: 't2', kind: 'group-adhoc', name: 'Site team', participants: ['a', 'b', 'c'], archivedBy: ['c']}
99
+ ];
100
+ const msgs = [
101
+ {threadId: 't1', senderId: 'a', body: 'Are you in today?', createdAt: at(12), readBy: ['a']},
102
+ {threadId: 't1', senderId: 'a', body: 'just now', createdAt: at(2), readBy: ['a']},
103
+ {threadId: 't2', senderId: 'b', body: 'Skip', createdAt: at(30), readBy: ['b', 'a']},
104
+ {threadId: 't2', senderId: 'b', body: 'old', createdAt: at(60 * 24 * 3), readBy: ['b']}
105
+ ];
106
+ const n = unreadNotices(threads, msgs, {a: 'Ann', b: 'Bob'}, now);
107
+ assert.equal(n.length, 1, 'b in the DM; a read the group; c archived it; the 3-day-old one is left alone');
108
+ assert.equal(n[0].title, 'Ann sent you a message');
109
+ assert.equal(n[0].body, 'Are you in today?');
110
+ assert.deepEqual(n[0].audience, {userIds: ['b']});
111
+ assert.equal(n[0].dedupeKey, unreadKey('t1', 'b'));
112
+ assert.ok(UNREAD_AFTER_MS === 600_000);
113
+ });
114
+
115
+ test('older dates read dd/mm/yyyy: the list stamp and the day separator', () => {
116
+ const now = new Date(2026, 8, 27, 12, 0).getTime();
117
+ const old = new Date(2026, 8, 3, 9, 30).toISOString();
118
+ assert.equal(ago(old, now), '03/09/2026');
119
+ assert.equal(dayLabel(old, now), 'Thu 03/09/2026');
120
+ });
121
+
122
+ test('Call…: which conversation a call with the people picked goes to', () => {
123
+ const P = (...ids) => ids.map(id => ({id, name: id}));
124
+ const threads = [
125
+ {id: 'dm-ann', kind: 'dm', participants: P(me, 'u2'), lastMessageAt: '2026-09-20T10:00:00Z'},
126
+ {id: 'dm-bob-archived', kind: 'dm', participants: [me, 'u3'], archivedBy: [me]},
127
+ {id: 'g-old', kind: 'group-adhoc', name: 'Old', participants: P(me, 'u2', 'u3'), lastMessageAt: '2026-09-01T10:00:00Z'},
128
+ {id: 'g-new', kind: 'group-adhoc', name: 'New', participants: P('u3', me, 'u2'), lastMessageAt: '2026-09-25T10:00:00Z'},
129
+ {id: 'g-big', kind: 'group-adhoc', name: 'Big', participants: P(me, 'u2', 'u3', 'u4')},
130
+ {id: 'g-gone', kind: 'group-adhoc', name: 'Gone', participants: P(me, 'u4', 'u5'), archivedBy: [me]},
131
+ {id: 'm-team', kind: 'group-managed', name: 'Team', participants: P(me, 'u5', 'u6')},
132
+ {id: 'g-without-me', kind: 'group-adhoc', name: 'Them', participants: P('u6', 'u7')}
133
+ ];
134
+ assert.equal(callTarget(threads, me, ['u2']).id, 'dm-ann', 'one person: your DM');
135
+ assert.equal(callTarget(threads, me, ['u3']).id, 'dm-bob-archived', 'a DM is one per pair, archived or not');
136
+ assert.deepEqual(callTarget(threads, me, ['u9']), {create: {kind: 'dm', participants: ['u9']}});
137
+ assert.equal(callTarget(threads, me, ['u3', 'u2']).id, 'g-new', 'exactly those people, the latest used');
138
+ assert.equal(callTarget(threads, me, ['u2', 'u3', 'u2', me]).id, 'g-new', 'repeats and yourself are ignored');
139
+ assert.deepEqual(callTarget(threads, me, ['u4', 'u5']), {create: {kind: 'group-adhoc', participants: ['u4', 'u5']}}, 'not one you archived');
140
+ assert.ok(callTarget(threads, me, ['u5', 'u6']).create, 'a managed group is not taken for an ad-hoc call');
141
+ assert.ok(callTarget(threads, me, ['u6', 'u7']).create, 'nor one you are not in');
142
+ assert.equal(callTarget(threads, me, []), null);
143
+ assert.equal(CALL_PICK_MAX, 5);
144
+ assert.ok(callTarget(threads, me, ['a', 'b', 'c', 'd', 'e']).create);
145
+ assert.equal(callTarget(threads, me, ['a', 'b', 'c', 'd', 'e', 'f']), null, 'a call holds 6, you included');
146
+
147
+ assert.equal(callGroupName([{name: 'Alex Carter'}, {name: 'Priya Shah'}, {name: 'Sam Patel'}]), 'Alex, Priya and Sam');
148
+ assert.equal(callGroupName([{name: 'Alex Carter'}, {name: 'Priya'}]), 'Alex and Priya');
149
+ assert.equal(callGroupName([]), 'Call');
150
+ assert.ok(callGroupName(Array.from({length: 30}, () => ({name: 'Bartholomew'}))).length <= 80);
151
+
152
+ assert.equal(callTargetText(callTarget(threads, me, ['u2']), me), 'Calls in your conversation with u2.');
153
+ assert.equal(callTargetText(callTarget(threads, me, ['u2', 'u3']), me), 'Calls in your group "New".');
154
+ assert.equal(callTargetText(callTarget(threads, me, ['u8', 'u9']), me), 'Starts a new group for these people and calls.');
155
+ assert.equal(callTargetText(null, me), '');
156
+ });
157
+
158
+ test('indexByThread: one list per conversation, in collection order; entries with no thread are left out', () => {
159
+ const E = (id, threadId, extra = {}) => ({id, data: {threadId, ...extra}});
160
+ const idx = indexByThread([E('m1', 't1'), E('m2', 't2'), E('m3', 't1', {deletedAt: 'x'}), {id: 'odd', data: {}}, null]);
161
+ assert.deepEqual([...idx.keys()], ['t1', 't2']);
162
+ assert.deepEqual(idx.get('t1').map(e => e.id), ['m1', 'm3']); // deleted ones stay in: the routes decide what to skip
163
+ assert.deepEqual(idx.get('t2').map(e => e.id), ['m2']);
164
+ assert.equal(idx.get('t9'), undefined);
165
+ assert.equal(indexByThread([]).size, 0);
166
+ assert.equal(indexByThread(undefined).size, 0);
167
+ });
@@ -0,0 +1,65 @@
1
+ # Theme Roller
2
+
3
+ Makes custom themes - a built-in Domma theme plus the `--dm-*` tokens changed on top - and offers them to
4
+ the CMS with `hooks.registerThemes` (domma-cms 0.73+). The CMS does the rest: they are listed under
5
+ Custom themes in Content › Theme and applied as `dm-theme-<base> dm-custom-<id>` (core
6
+ `server/services/themeSettings.js`, "Custom themes"). This plugin only stores them and offers them.
7
+
8
+ ## Files
9
+
10
+ - `plugin.js` - API + provider. Themes live in `data/themes/<id>.json` (a site's own, declared in
11
+ `contentPaths`, never shipped); an in-memory Map mirrors them so the provider - called on every page
12
+ render - never touches disk. Writes are tmp+rename. "Use" goes through the core's `saveThemeConfig`.
13
+ - `admin/lib/themes.js` - PURE, shared by server and browser: slug/unique ids (never a built-in id),
14
+ cleaning tokens (same safe-value rule as the core), `usageOf`, `withoutTheme` (delete in use -> base),
15
+ `themeCss` (export).
16
+ - `admin/views/theme-roller-view.js`, `admin/templates/theme-roller.html`, `admin/css/theme-roller.css`.
17
+ - **The Custom CSS tab** edits the site-wide `content/custom.css` (inlined into every public page after the
18
+ theme) through CORE's `GET/PUT /api/settings/custom-css` - not this plugin's API - so it needs the
19
+ `settings.update` permission (Site Settings > Edit); without it the tab cannot load or save. The box stays
20
+ locked until the file has loaded (saving an empty box would replace the whole file); Ctrl+S saves.
21
+ - `tests/themes.test.js` (pure), `tests/api.test.js` (API against the real core theme service -
22
+ snapshots and restores the CMS checkout's config/theme.json and site.json).
23
+
24
+ A stored theme is `{id, label, baseTheme, changes, createdAt, updatedAt}`. Files from 1.0 (`name`, no
25
+ label) load as id = label = name.
26
+
27
+ ## Gotchas (the ThemeRoller component, from Domma's tools bundle)
28
+
29
+ - It re-themes the WHOLE admin while it runs (theme.set + a preview `<style>` on `body, :root` with
30
+ `!important`). The view takes the admin's `dm-custom-*` class off `<body>` while it runs, and on
31
+ leaving destroys the roller and re-applies the admin theme via the core's `admin-theme.js`.
32
+ - There is NO `getBaseTheme()` (1.0 called it on Save and threw - that was "it doesn't save", along with
33
+ `data/themes/` never being created). The tools bundle has its OWN theme engine, so the roller's
34
+ dropdown and `Domma.theme.get()` can disagree: the base is `#theme-select`'s value (`currentBase`).
35
+ - `loadPreset()` / `_refreshInputs()` wipe the changes. `loadInto` sets the base, builds a fresh
36
+ roller, puts `_changes` in place and re-renders its inputs (`_render` + accordion + `_bindEvents`),
37
+ falling back to `setAll` if those privates disappear. `_render` reads `_changes[var] || computed`.
38
+ - E.prompt/E.confirm render HTML (sanitised): labels in the message go in `esc()`d. Never give a prompt its starting
39
+ value through `inputValue` (a `"` cuts it short) - use `promptWith` (admin/lib/prompt.js); a test bans it.
40
+ - The public theme switcher cannot offer custom themes (the core refuses them there): a visitor can only switch
41
+ between built-in themes. Say so to a site owner who expects their custom theme in the switcher.
42
+
43
+ ## 1.2.0 (2026-10-15 Tool Upgrade, second pass)
44
+
45
+ - Saved themes: All / In use / Not in use with counts, a search box, and core's skeleton on the first load
46
+ (`loading.js` at run time; `.trx-loading` keeps its text on an older core). `load()` checks `here()` after awaits.
47
+ - **Saves never swallow newer edits**: `save()` compares what it sent (`base|changesKey`) with the roller after the
48
+ PUT and `saveCss()` the box with what it sent - anything changed meanwhile stays unsaved (and is drafted on leave).
49
+ - **Drafts know their age**: the theme draft carries `was` (the theme's `updatedAt` when editing began) and the CSS
50
+ draft `cssWas` (the file as loaded or last saved). `staleNote()` (admin/lib/view.js, tests/view.test.js) adds to the
51
+ restore question when the theme was saved again or deleted since, or custom.css changed.
52
+
53
+ ## 1.2.1 - unsaved-work sweep
54
+
55
+ The draft is per user (`theme-roller.drafts.<uid>`; the old shared `theme-roller.draft` is removed unread). Restore asks
56
+ "Throw away the changes you left unsaved to …?" (OK throws away, Cancel/Escape keeps; `restoreQuestion` in
57
+ admin/lib/view.js). A CSS draft whose file cannot be loaded is kept for next time, never dropped - `pendingCss` carries it
58
+ into the leave write so unsaved theme changes cannot overwrite it. Row Enter and every menu action run a task later, so
59
+ the Enter cannot answer the confirm they open.
60
+
61
+ ## 1.3.0 (2026-10-21): free
62
+
63
+ `tier: free`, no `closedSource` - no licence is needed or checked any more (the engine only checks a
64
+ `closedSource` plugin). Included in every hosted plan and free to self-host; not `bundle: false`, so the next
65
+ `make sync-free-tier` ships it in the engine.
@@ -0,0 +1,43 @@
1
+ /* Theme Roller admin screen - see admin/views/theme-roller-view.js. */
2
+ .trx { display: flex; flex-direction: column; gap: .75rem; }
3
+ .trx [hidden] { display: none !important; }
4
+ .trx-alert { margin: 0; display: flex; gap: .5rem; align-items: center; padding: .6rem .8rem; border-radius: var(--dm-radius-md);
5
+ background: color-mix(in srgb, var(--dm-warning) 16%, transparent); color: var(--dm-text); }
6
+ .trx-bar { display: flex; flex-wrap: wrap; gap: .75rem; align-items: center; justify-content: space-between; margin-bottom: .75rem; }
7
+ .trx-editing { display: flex; flex-wrap: wrap; gap: .5rem; align-items: baseline; min-width: 0; }
8
+ .trx-editing small { color: var(--dm-text-muted); }
9
+ .trx-actions { display: flex; gap: .4rem; align-items: center; flex-wrap: wrap; }
10
+ .trx-actions .btn { display: inline-flex; align-items: center; gap: .3rem; }
11
+ .trx-more { font-size: var(--dm-text-lg); line-height: 1; }
12
+ .trx-chips { display: inline-flex; gap: .3rem; }
13
+ .trx-chip { display: inline-flex; align-items: center; font-size: var(--dm-text-xs); font-weight: 600; padding: .1rem .5rem; border-radius: 999px;
14
+ background: color-mix(in srgb, var(--dm-success) 16%, transparent); color: var(--dm-success); white-space: nowrap; }
15
+ .trx-chip.is-dirty { background: color-mix(in srgb, var(--dm-warning) 18%, transparent); color: var(--dm-warning); }
16
+ .trx-count { opacity: .65; font-size: var(--dm-text-xs); margin-left: .2rem; }
17
+ .trx-card { border: 1px solid var(--dm-border); border-radius: var(--dm-radius-md); padding: .75rem .9rem; display: flex; flex-direction: column; gap: .6rem; }
18
+ .trx-card-title { display: flex; align-items: center; gap: .3rem; font-weight: 600; }
19
+ .trx-note { margin: 0; color: var(--dm-text-muted); font-size: var(--dm-text-sm); }
20
+ .trx-list { display: flex; flex-direction: column; }
21
+ .trx-row { display: flex; align-items: center; gap: .75rem; padding: .55rem .25rem; border-bottom: 1px solid var(--dm-border); cursor: pointer; }
22
+ .trx-row:last-child { border-bottom: 0; }
23
+ .trx-row:hover, .trx-row:focus-visible { background: var(--dm-surface-hover, rgba(127,127,127,.08)); outline: none; }
24
+ .trx-row.is-editing { box-shadow: inset 3px 0 0 var(--dm-primary); }
25
+ .trx-swatch { display: inline-flex; flex-shrink: 0; width: 2.2rem; height: 1.4rem; border-radius: var(--dm-radius-sm); overflow: hidden; border: 1px solid var(--dm-border); }
26
+ .trx-swatch span { flex: 1; }
27
+ .trx-row-main { flex: 1; min-width: 0; display: flex; flex-direction: column; }
28
+ .trx-row-main strong, .trx-row-main small { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
29
+ .trx-row-main small { color: var(--dm-text-muted); font-size: var(--dm-text-xs); }
30
+ .trx-kebab { border: 0; background: none; cursor: pointer; font-size: var(--dm-text-lg); color: var(--dm-text-muted); border-radius: var(--dm-radius-sm); padding: 0 .3rem; }
31
+ .trx-kebab:hover { color: var(--dm-primary); }
32
+ .trx-css { width: 100%; min-height: 420px; font-family: var(--dm-font-mono, monospace); font-size: var(--dm-text-sm); }
33
+ .trx-css-actions { display: flex; justify-content: flex-end; }
34
+ @media (max-width: 640px) { .trx-row-main small { display: none; } }
35
+
36
+ /* Saved themes: filter and search (1.2) */
37
+ .trx-toolbar { display: flex; align-items: center; gap: .5rem; flex-wrap: wrap; margin-bottom: .5rem; }
38
+ .trx-seg { display: inline-flex; border: 1px solid var(--dm-border); border-radius: var(--dm-radius-md); overflow: hidden; }
39
+ .trx-seg button { border: 0; background: none; color: var(--dm-text); padding: .3rem .7rem; cursor: pointer; font: inherit; font-size: var(--dm-text-sm); }
40
+ .trx-seg button + button { border-left: 1px solid var(--dm-border); }
41
+ .trx-seg button.is-on { background: var(--dm-primary); color: var(--dm-primary-text); }
42
+ .trx-seg small { font-size: var(--dm-text-xs); opacity: .75; margin-left: .2rem; }
43
+ .trx-search { width: auto; flex: 0 1 14rem; padding: .25rem .5rem; font-size: var(--dm-text-sm); }
@@ -0,0 +1,22 @@
1
+ /**
2
+ * E.prompt with a starting value that survives any character. Domma's
3
+ * `inputValue` is sanitised into a value="…" attribute, so a " in it cuts the
4
+ * text short (escaping does not help - it is decoded back). This sets the
5
+ * input's .value after the dialog is built instead. The same helper as the
6
+ * core admin's tool-kit promptWith (CMS 1.6.53), kept here so the plugin
7
+ * works on older cores too.
8
+ *
9
+ * @param {string} message
10
+ * @param {object} [options] - E.prompt options (without inputValue)
11
+ * @param {*} [value] - the starting text
12
+ * @returns {Promise<string|null>}
13
+ */
14
+ export function promptWith(message, options, value) {
15
+ const answer = E.prompt(message, options);
16
+ const input = [...document.querySelectorAll('.dm-dialog-input')].at(-1);
17
+ if (input) {
18
+ input.value = String(value ?? '');
19
+ setTimeout(() => { try { input.select(); } catch { /* gone */ } }, 60);
20
+ }
21
+ return answer;
22
+ }
@@ -0,0 +1,196 @@
1
+ /**
2
+ * Theme Roller - the pure rules, shared by the server (plugin.js) and the admin
3
+ * screen. No I/O and no globals, so the tests run them directly.
4
+ *
5
+ * A saved theme is `{id, label, baseTheme, changes, createdAt, updatedAt}`:
6
+ * a built-in Domma theme plus the `--dm-*` tokens changed on top of it. The CMS
7
+ * applies it as the base's class plus `dm-custom-<id>` (see the core's
8
+ * themeSettings.js); this plugin only stores themes and offers them.
9
+ */
10
+
11
+ /** A saved theme's id: what the CMS names its class and its config entries by. */
12
+ export const ID_RE = /^[a-z0-9][a-z0-9-]{0,59}$/;
13
+ const TOKEN_RE = /^--dm-[a-z0-9-]+$/;
14
+
15
+ /**
16
+ * An id from a label: lowercase, hyphens, 60 characters at most.
17
+ *
18
+ * @param {string} label
19
+ * @returns {string} '' when nothing usable is left
20
+ */
21
+ export function slugify(label) {
22
+ return String(label || '')
23
+ .toLowerCase()
24
+ .normalize('NFKD').replace(/[̀-ͯ]/g, '')
25
+ .replace(/[^a-z0-9]+/g, '-')
26
+ .replace(/^-+|-+$/g, '')
27
+ .slice(0, 60)
28
+ .replace(/-+$/, '');
29
+ }
30
+
31
+ /**
32
+ * An id for a new theme that collides with nothing: not a built-in theme (the
33
+ * CMS would ignore it) and not another saved theme (it would overwrite it).
34
+ *
35
+ * @param {string} label
36
+ * @param {{builtins?: string[], taken?: string[]}} known
37
+ * @returns {string} '' when the label has nothing usable in it
38
+ */
39
+ export function uniqueId(label, {builtins = [], taken = []} = {}) {
40
+ const base = slugify(label);
41
+ if (!base) return '';
42
+ const used = new Set([...builtins, ...taken]);
43
+ if (!used.has(base)) return base;
44
+ for (let n = 2; n < 1000; n++) {
45
+ const id = `${base.slice(0, 55)}-${n}`;
46
+ if (!used.has(id)) return id;
47
+ }
48
+ return '';
49
+ }
50
+
51
+ /**
52
+ * A token value that cannot break out of its declaration - the same rule the
53
+ * CMS applies before it emits one. Refused, not escaped.
54
+ */
55
+ export function isSafeValue(value) {
56
+ if (typeof value !== 'string') return false;
57
+ const v = value.trim();
58
+ if (!v || v.length > 200) return false;
59
+ if (/[{}<>;\\]|\/\*|\*\//.test(v)) return false; // the core's rule: no comment, no escapes
60
+ return !/@import|javascript:|expression\s*\(|url\s*\(/i.test(v);
61
+ }
62
+
63
+ /**
64
+ * Keep only real `--dm-*` tokens with plain values.
65
+ *
66
+ * @param {object} changes
67
+ * @returns {{changes: Object<string,string>, dropped: string[]}}
68
+ */
69
+ export function cleanChanges(changes) {
70
+ const out = {};
71
+ const dropped = [];
72
+ for (const [k, v] of Object.entries(changes && typeof changes === 'object' ? changes : {})) {
73
+ if (TOKEN_RE.test(k) && isSafeValue(v)) out[k] = v.trim();
74
+ else dropped.push(k);
75
+ }
76
+ return {changes: out, dropped};
77
+ }
78
+
79
+ /** A label for display: trimmed, 80 characters, never empty. */
80
+ export function cleanLabel(label, fallback = 'Untitled theme') {
81
+ const s = String(label ?? '').replace(/\s+/g, ' ').trim().slice(0, 80);
82
+ return s || fallback;
83
+ }
84
+
85
+ /**
86
+ * Where a theme is in use, from the CMS's theme config (config/theme.json).
87
+ *
88
+ * @param {string} id
89
+ * @param {object} cfg
90
+ * @returns {string[]} any of 'site', 'admin', 'day', 'night', 'region'
91
+ */
92
+ export function usageOf(id, cfg = {}) {
93
+ const where = [];
94
+ const auto = cfg.autoTheme || {};
95
+ if (!auto.enabled && cfg.theme === id) where.push('site');
96
+ if (cfg.adminTheme === id) where.push('admin');
97
+ if (auto.enabled && auto.dayTheme === id) where.push('day');
98
+ if (auto.enabled && auto.nightTheme === id) where.push('night');
99
+ if ((cfg.overrides || []).some(o => o && o.enabled !== false && o.theme === id)) where.push('region');
100
+ return where;
101
+ }
102
+
103
+ /** Human wording for usageOf's keys. */
104
+ export const USAGE_LABEL = {site: 'Site', admin: 'Admin', day: 'Day', night: 'Night', region: 'Region'};
105
+
106
+ /**
107
+ * The theme config with every use of `id` swapped for `replacement` - what
108
+ * deleting a theme in use does, so nothing is left pointing at nothing.
109
+ *
110
+ * @param {object} cfg
111
+ * @param {string} id
112
+ * @param {string} replacement - a built-in theme, normally the deleted theme's base
113
+ * @returns {object|null} a partial config to save, or null when nothing uses it
114
+ */
115
+ export function withoutTheme(cfg, id, replacement) {
116
+ const patch = {};
117
+ if (cfg.theme === id) patch.theme = replacement;
118
+ if (cfg.adminTheme === id) patch.adminTheme = replacement;
119
+ const auto = cfg.autoTheme || {};
120
+ if (auto.dayTheme === id || auto.nightTheme === id) {
121
+ patch.autoTheme = {
122
+ ...auto,
123
+ ...(auto.dayTheme === id && {dayTheme: replacement}),
124
+ ...(auto.nightTheme === id && {nightTheme: replacement})
125
+ };
126
+ }
127
+ if ((cfg.overrides || []).some(o => o?.theme === id)) {
128
+ patch.overrides = cfg.overrides.map(o => (o?.theme === id ? {...o, theme: replacement} : o));
129
+ }
130
+ return Object.keys(patch).length ? patch : null;
131
+ }
132
+
133
+ /**
134
+ * A standalone stylesheet for a theme - what "Export CSS" downloads. Scoped to
135
+ * the class the CMS uses, plus a `:root` block commented out for anyone
136
+ * pasting it into a page that does not.
137
+ *
138
+ * @param {{id: string, label?: string, baseTheme: string, changes: object}} theme
139
+ * @param {string} [date] - YYYY-MM-DD
140
+ * @returns {string}
141
+ */
142
+ export function themeCss(theme, date = new Date().toISOString().slice(0, 10)) {
143
+ const {changes} = cleanChanges(theme.changes);
144
+ const lines = Object.entries(changes).map(([k, v]) => ` ${k}: ${v};`);
145
+ // A character class, not a `*/` pattern: removing `*/` from `**//` leaves `*/`.
146
+ const label = cleanLabel(theme.label, theme.id).replace(/[*/<>]/g, '');
147
+ return [
148
+ '/**',
149
+ ` * ${label} - a Domma theme on ${theme.baseTheme}`,
150
+ ` * Exported ${date} from the Theme Roller.`,
151
+ ` * Apply alongside the base: <body class="dm-theme-${theme.baseTheme} dm-custom-${theme.id}">`,
152
+ ' */',
153
+ `.dm-custom-${theme.id} {`,
154
+ ...lines,
155
+ '}',
156
+ ''
157
+ ].join('\n');
158
+ }
159
+
160
+ /**
161
+ * The sidebar badge and its hover popover (the core's countUrl contract:
162
+ * {count, label, tone, details: {title, rows, items, empty}}, all plain text).
163
+ * Count = saved themes; the popover says what the site and the admin wear now,
164
+ * then each saved theme and where it is in use.
165
+ *
166
+ * @param {Array<{id, label, baseTheme, usage?: string[]}>} themes - with usage filled in
167
+ * @param {object} cfg - the CMS theme config
168
+ * @returns {object}
169
+ */
170
+ export function badgeFor(themes, cfg = {}) {
171
+ const byId = new Map(themes.map(t => [t.id, t]));
172
+ const name = (id) => (id ? (byId.get(id)?.label ?? id) : '-');
173
+ const inUse = themes.filter(t => (t.usage || []).length).length;
174
+ const auto = cfg.autoTheme?.enabled ? cfg.autoTheme : null;
175
+ const n = themes.length;
176
+ return {
177
+ count: n,
178
+ label: n ? `${n} custom theme${n === 1 ? '' : 's'}${inUse ? `, ${inUse} in use` : ''}` : 'No custom themes yet',
179
+ tone: 'info',
180
+ details: {
181
+ title: 'Theme Roller',
182
+ rows: [
183
+ auto
184
+ ? {label: 'Site (day / night)', value: `${name(auto.dayTheme)} / ${name(auto.nightTheme)}`}
185
+ : {label: 'Site theme', value: name(cfg.theme)},
186
+ {label: 'Admin theme', value: name(cfg.adminTheme)}
187
+ ],
188
+ items: themes.slice(0, 8).map(t => ({
189
+ text: t.label,
190
+ meta: `On ${t.baseTheme}${(t.usage || []).length ? ` · ${(t.usage).map(u => USAGE_LABEL[u] || u).join(', ')}` : ''}`,
191
+ ...((t.usage || []).length && {tone: 'ok'})
192
+ })),
193
+ empty: 'No saved themes yet - make one and Save it.'
194
+ }
195
+ };
196
+ }
@@ -0,0 +1,88 @@
1
+ /**
2
+ * Theme Roller - the screen's pure decisions: the Saved themes filter and its
3
+ * counts, whether a save left newer changes unsaved, and whether a kept draft
4
+ * is older than what the server now holds. No DOM, no storage; tested in
5
+ * tests/view.test.js.
6
+ *
7
+ * @module theme-roller/admin/lib/view
8
+ */
9
+
10
+ export const SHOWS = ['all', 'used', 'unused'];
11
+
12
+ /** How many themes each filter shows. */
13
+ export function listCounts(themes = []) {
14
+ const used = themes.filter(t => (t.usage || []).length).length;
15
+ return {all: themes.length, used, unused: themes.length - used};
16
+ }
17
+
18
+ /** The themes a filter (and a search) shows. */
19
+ export function filterThemes(themes = [], show = 'all', query = '') {
20
+ const q = String(query || '').trim().toLowerCase();
21
+ return themes.filter(t => {
22
+ const used = (t.usage || []).length > 0;
23
+ if (show === 'used' && !used) return false;
24
+ if (show === 'unused' && used) return false;
25
+ return !q || `${t.label} ${t.id} ${t.baseTheme}`.toLowerCase().includes(q);
26
+ });
27
+ }
28
+
29
+ /** A stable text of a theme's changes, to compare one save with the next. */
30
+ export function changesKey(changes = {}) {
31
+ return JSON.stringify(Object.keys(changes).sort().map(k => [k, changes[k]]));
32
+ }
33
+
34
+ /**
35
+ * After a save: is what is in the roller now still what was sent? Anything
36
+ * changed while the save was in flight stays unsaved.
37
+ * @param {string} sent - changesKey(…) + the base, as sent
38
+ * @param {string} now - the same, read after the save
39
+ */
40
+ export const stillDirty = (sent, now) => sent !== now;
41
+
42
+ /**
43
+ * What a kept draft is worth on restore, as a sentence to add to the question
44
+ * (or '' when nothing changed since): the theme it edits was saved again, or
45
+ * deleted, or the Custom CSS file changed.
46
+ * @param {object} draft - {theme?: {id, was}, css?, cssWas?}
47
+ * @param {object[]} themes - the saved themes now
48
+ * @param {string|null} cssNow - content/custom.css now (null = not loaded)
49
+ */
50
+ export function staleNote(draft, themes = [], cssNow = null) {
51
+ const notes = [];
52
+ if (draft?.theme?.id) {
53
+ const t = themes.find(x => x.id === draft.theme.id);
54
+ if (!t) notes.push('The theme has been deleted since - keeping yours starts it again as a new theme.');
55
+ else if (draft.theme.was && t.updatedAt !== draft.theme.was) notes.push('The theme was saved again since - keeping yours puts your older changes over it once you Save.');
56
+ }
57
+ if (draft?.css != null && draft.cssWas != null && cssNow != null && cssNow !== draft.cssWas) {
58
+ notes.push('content/custom.css has changed since - keeping yours replaces the newer file once you Save.');
59
+ }
60
+ return notes.join(' ');
61
+ }
62
+
63
+ /**
64
+ * The question asked when a kept draft is offered back: OK throws it away,
65
+ * Cancel and Escape keep it (restore). Plain text - the caller escapes it.
66
+ * @param {object} draft - {theme?: {id, label}, css?}
67
+ * @param {string} [note] - staleNote()
68
+ */
69
+ export function restoreQuestion(draft, note = '') {
70
+ const parts = [];
71
+ if (draft?.theme) parts.push(draft.theme.id ? `the theme "${draft.theme.label || draft.theme.id}"` : 'a new theme');
72
+ if (draft?.css != null) parts.push('the Custom CSS');
73
+ return `Throw away the changes you left unsaved to ${parts.join(' and ') || 'this screen'}?${note ? ` ${note}` : ''}`;
74
+ }
75
+
76
+ /** The answers that go with restoreQuestion: OK throws away, Cancel (and Escape) keeps. */
77
+ export const RESTORE_ANSWERS = Object.freeze({confirmText: 'Throw away', cancelText: 'Keep them'});
78
+
79
+ /**
80
+ * Where the kept draft lives: per signed-in person, so a shared browser never
81
+ * offers one person's work to another. No one signed in: null - keep nothing.
82
+ */
83
+ export function draftKeyFor(uid) {
84
+ return uid ? `theme-roller.drafts.${uid}` : null;
85
+ }
86
+
87
+ /** The pre-1.2.1 key, shared by everyone on the browser - removed when found, never read. */
88
+ export const OLD_DRAFT_KEY = 'theme-roller.draft';