domma-cms 1.8.8 → 1.8.9

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.
@@ -0,0 +1,355 @@
1
+ /**
2
+ * WordPress widgets -> Domma blocks. PURE: no I/O; the run (./index.js)
3
+ * supplies the lookups and writes what this returns.
4
+ *
5
+ * The widgets come from a `.wie` file - the JSON the free "Widget Importer &
6
+ * Exporter" WordPress plugin writes (Tools › Widget Importer & Exporter ›
7
+ * Export Widgets), because a WordPress export (WXR) has no widgets in it:
8
+ *
9
+ * {"<sidebar-id>": {"<widget-type>-<n>": {settings…}, …}, …}
10
+ *
11
+ * Sidebar ids are the theme's own (sidebar-1, footer-1, footer-widgets-2…);
12
+ * `wp_inactive_widgets` holds the ones taken off the site and is left out.
13
+ *
14
+ * Each widget area becomes one block, `wp-<area>` (content/blocks/<name>.html),
15
+ * its widgets in order, each a <section> with its title as a heading:
16
+ *
17
+ * text, custom_html, block -> their HTML through the importer's conversion
18
+ * (./convert.js - sanitised, WordPress shortcodes
19
+ * left as inert text, old links and media re-pointed)
20
+ * media_image -> the picture, when the export's media brought it here
21
+ * nav_menu -> [menu] of the menu the import made from it
22
+ * recent-posts, categories -> the Blog's [blog-list] / [blog-categories]
23
+ * archives -> a link to the Blog
24
+ * search -> a link that opens the site search (#site-search)
25
+ * anything else -> listed as needing rebuilding, nothing guessed
26
+ *
27
+ * An area whose id says "footer" is offered "Place in the footer" (on by
28
+ * default): its block joins the site's footer (`site.json` footer.blocks,
29
+ * drawn by services/renderer.js on every page).
30
+ *
31
+ * The file is untrusted: size and nesting are capped before JSON.parse, keys
32
+ * that could reach an object's prototype are dropped, only plain values are
33
+ * kept, and every string is escaped or sanitised on its way into HTML.
34
+ *
35
+ * @module services/wpImport/widgets
36
+ */
37
+ import {convertContent, escapeAttr, escapeText} from './convert.js';
38
+ import {freePath, slugify} from './plan.js';
39
+
40
+ export const WIE_MAX_BYTES = 2 * 1024 * 1024;
41
+ export const WIE_MAX_DEPTH = 8;
42
+ const MAX_AREAS = 60;
43
+ const MAX_WIDGETS = 100; // per area
44
+ const MAX_STRING = 200 * 1024; // per setting
45
+ const BAD_KEYS = new Set(['__proto__', 'constructor', 'prototype']);
46
+ const AREA_ID = /^[A-Za-z0-9_-]{1,100}$/;
47
+ const WIDGET_ID = /^([A-Za-z0-9_-]{1,80}?)-(\d{1,9})$/;
48
+ const SETTING_KEY = /^[\w-]{1,80}$/;
49
+ /** The most blocks the footer draws (renderer.js FOOTER_BLOCKS_MAX; Site Settings refuses more). */
50
+ const FOOTER_MAX = 8;
51
+
52
+ /** WordPress's own name for the widgets taken off the site. */
53
+ export const INACTIVE_AREA = 'wp_inactive_widgets';
54
+
55
+ /** The widget types, as the preview and the report say them. */
56
+ export const WIDGET_LABEL = {
57
+ text: 'Text', custom_html: 'Custom HTML', block: 'Block', media_image: 'Image', nav_menu: 'Navigation menu',
58
+ 'recent-posts': 'Recent posts', categories: 'Categories', archives: 'Archives', tag_cloud: 'Tag cloud', search: 'Search',
59
+ calendar: 'Calendar', rss: 'RSS feed', meta: 'Meta', pages: 'Pages', 'recent-comments': 'Recent comments',
60
+ media_gallery: 'Gallery', media_video: 'Video', media_audio: 'Audio'
61
+ };
62
+
63
+ export class WieError extends Error {
64
+ constructor(message) { super(message); this.statusCode = 400; }
65
+ }
66
+
67
+ const plain = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
68
+
69
+ /** The deepest nesting of {} and [] in a JSON text, counted without parsing it (stops past `limit`). */
70
+ export function jsonDepth(text, limit = Infinity) {
71
+ let depth = 0;
72
+ let max = 0;
73
+ let inString = false;
74
+ let escaped = false;
75
+ for (let i = 0; i < text.length; i++) {
76
+ const c = text.charCodeAt(i);
77
+ if (inString) {
78
+ if (escaped) escaped = false;
79
+ else if (c === 92) escaped = true;
80
+ else if (c === 34) inString = false;
81
+ continue;
82
+ }
83
+ if (c === 34) inString = true;
84
+ else if (c === 123 || c === 91) { depth++; if (depth > max) { max = depth; if (max > limit) return max; } }
85
+ else if (c === 125 || c === 93) depth--;
86
+ }
87
+ return max;
88
+ }
89
+
90
+ /** A widget's settings: plain values only, strings clipped, no key that could reach a prototype. */
91
+ function cleanSettings(raw) {
92
+ const out = {};
93
+ for (const [k, v] of Object.entries(raw)) {
94
+ if (BAD_KEYS.has(k) || !SETTING_KEY.test(k)) continue;
95
+ if (typeof v === 'string') out[k] = v.slice(0, MAX_STRING);
96
+ else if (typeof v === 'number' && Number.isFinite(v)) out[k] = v;
97
+ else if (typeof v === 'boolean') out[k] = v;
98
+ }
99
+ return out;
100
+ }
101
+
102
+ /**
103
+ * Read a .wie file.
104
+ *
105
+ * @param {string|Buffer} input
106
+ * @returns {{areas: {id: string, widgets: {id: string, type: string, settings: object}[]}[], inactive: number, notes: string[]}}
107
+ * @throws {WieError} when it is too big, too deep, not JSON, or not a widgets file
108
+ */
109
+ export function readWie(input) {
110
+ const size = Buffer.isBuffer(input) ? input.length : Buffer.byteLength(String(input ?? ''));
111
+ if (size > WIE_MAX_BYTES) throw new WieError(`The widgets file is over ${WIE_MAX_BYTES / 1048576} MB - that is far bigger than any real one.`);
112
+ const text = (Buffer.isBuffer(input) ? input.toString('utf8') : String(input ?? '')).replace(/^/, '');
113
+ if (!text.trim()) throw new WieError('The widgets file is empty.');
114
+ if (jsonDepth(text, WIE_MAX_DEPTH) > WIE_MAX_DEPTH) throw new WieError('That widgets file is nested far deeper than a real one - refused.');
115
+ let data;
116
+ try {
117
+ data = JSON.parse(text, (k, v) => (BAD_KEYS.has(k) ? undefined : v));
118
+ } catch {
119
+ throw new WieError('That is not a widgets file. In WordPress, use the free "Widget Importer & Exporter" plugin (Tools › Widget Importer & Exporter › Export Widgets) and upload the .wie file it gives you.');
120
+ }
121
+ if (!plain(data)) throw new WieError('That is not a widgets file - it should list widget areas, each with its widgets.');
122
+ const areas = [];
123
+ const notes = [];
124
+ let inactive = 0;
125
+ let ignored = 0;
126
+ for (const [id, widgets] of Object.entries(data)) {
127
+ if (!AREA_ID.test(id) || !plain(widgets)) { ignored++; continue; }
128
+ if (id === INACTIVE_AREA) { inactive += Object.keys(widgets).length; continue; }
129
+ if (areas.length >= MAX_AREAS) { ignored++; continue; }
130
+ const list = [];
131
+ for (const [wid, settings] of Object.entries(widgets)) {
132
+ const m = WIDGET_ID.exec(wid);
133
+ if (!m || !plain(settings)) { ignored++; continue; }
134
+ if (list.length >= MAX_WIDGETS) { ignored++; continue; }
135
+ list.push({id: wid, type: m[1].toLowerCase(), settings: cleanSettings(settings)});
136
+ }
137
+ areas.push({id, widgets: list});
138
+ }
139
+ if (!areas.length && !inactive) throw new WieError('That widgets file has no widget areas in it.');
140
+ if (ignored) notes.push(`${ignored} entr${ignored === 1 ? 'y' : 'ies'} in the widgets file ${ignored === 1 ? 'was' : 'were'} not in the shape the Widget Importer & Exporter writes, and ${ignored === 1 ? 'was' : 'were'} left out.`);
141
+ return {areas, inactive, notes};
142
+ }
143
+
144
+ /** Does the theme's id for an area say it is a footer? (footer, footer-1, footer-widgets-2, sidebar-footer…) */
145
+ export const isFooterArea = (id) => /footer/i.test(String(id || ''));
146
+
147
+ /** "footer-widgets-2" -> "Footer widgets 2". */
148
+ export function areaLabel(id) {
149
+ const s = String(id || '').replace(/[_-]+/g, ' ').trim();
150
+ return s ? `${s[0].toUpperCase()}${s.slice(1)}` : 'Widget area';
151
+ }
152
+
153
+ const typeLabel = (type) => WIDGET_LABEL[type] || areaLabel(type);
154
+ const clipText = (s, n) => String(s ?? '').replace(/[\u0000-\u001f\u007f]/g, ' ').replace(/\s+/g, ' ').trim().slice(0, n);
155
+ /** A widget's title as plain text (WordPress allows a little HTML in one). */
156
+ export const widgetTitle = (w) => clipText(String(w.settings?.title ?? '').replace(/<[^>]*>/g, '')
157
+ .replace(/&nbsp;/g, ' ').replace(/&#0*39;|&apos;/g, "'").replace(/&quot;/g, '"').replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&amp;/g, '&'), 150);
158
+ /** "Calendar ("Events")" - how a widget that needs rebuilding is listed. */
159
+ const describe = (w) => `${typeLabel(w.type)}${widgetTitle(w) ? ` ("${widgetTitle(w)}")` : ''}`;
160
+ const int = (v, def) => { const n = Number.parseInt(v, 10); return Number.isFinite(n) ? n : def; };
161
+
162
+ /**
163
+ * What one widget becomes - shared by the plan (can it come across?) and the
164
+ * run (its HTML), so the preview says what the run does.
165
+ *
166
+ * @param {object} w - {type, settings}
167
+ * @param {object} ctx
168
+ * @param {(id: number, url: string) => boolean} ctx.hasMedia - will the picture be here?
169
+ * @param {(termId: number) => string} ctx.menuSlug - the menu made from a WordPress menu ('' = none)
170
+ * @param {{posts: boolean, list: boolean, categories: boolean, basePath: string}} ctx.blog
171
+ * @param {boolean} ctx.search - site search is on
172
+ * @returns {{how: 'html'|'image'|'menu'|'blog'|'link'|'search'|'rebuild', why?: string, slug?: string}}
173
+ */
174
+ export function decideWidget(w, ctx) {
175
+ const s = w.settings || {};
176
+ const blog = ctx.blog || {};
177
+ switch (w.type) {
178
+ case 'text': case 'custom_html': case 'block':
179
+ return {how: 'html'};
180
+ case 'media_image':
181
+ if (ctx.hasMedia?.(int(s.attachment_id, 0), String(s.url || ''))) return {how: 'image'};
182
+ return {how: 'rebuild', why: 'its picture is not among the media being imported'};
183
+ case 'nav_menu': {
184
+ const slug = ctx.menuSlug?.(int(s.nav_menu, 0)) || '';
185
+ return slug ? {how: 'menu', slug} : {how: 'rebuild', why: 'its menu is not being imported'};
186
+ }
187
+ case 'recent-posts':
188
+ return blog.posts && blog.list ? {how: 'blog'} : {how: 'rebuild', why: 'posts are not going to a Blog'};
189
+ case 'categories':
190
+ return blog.posts && blog.categories ? {how: 'blog'} : {how: 'rebuild', why: 'posts are not going to a Blog'};
191
+ case 'archives':
192
+ return blog.posts ? {how: 'link'} : {how: 'rebuild', why: 'posts are not going to a Blog'};
193
+ case 'search':
194
+ return ctx.search ? {how: 'search'} : {how: 'rebuild', why: 'site search is switched off (System › Search)'};
195
+ default:
196
+ return {how: 'rebuild', why: 'this site has nothing like it'};
197
+ }
198
+ }
199
+
200
+ /**
201
+ * The plan's widget rows - one per widget area that has widgets.
202
+ *
203
+ * @param {object|null} wie - from readWie()
204
+ * @param {object} ctx
205
+ * @param {object} ctx.mapped - mapping.items of earlier runs
206
+ * @param {object} ctx.choices - per row key: skip / overwrite / import
207
+ * @param {object} ctx.footer - per area id: false = not in the footer
208
+ * @param {Set<string>} ctx.blockNames - the blocks here
209
+ * @param {Object<string,string>} ctx.blockHashes - block -> fingerprint now (blocks an earlier run made)
210
+ * @param {string[]} ctx.footerBlocks - site.json footer.blocks now
211
+ * @returns {{rows: object[], summary: object|null}}
212
+ */
213
+ export function planWidgets(wie, ctx) {
214
+ if (!wie) return {rows: [], summary: null};
215
+ const mapped = ctx.mapped || {};
216
+ const choices = ctx.choices || {};
217
+ const footerOpt = ctx.footer || {};
218
+ const taken = new Set([...(ctx.blockNames || [])]);
219
+ const inFooter = new Set(ctx.footerBlocks || []);
220
+ // Names an earlier run gave these areas are theirs, not a clash.
221
+ for (const a of wie.areas) { const was = mapped[`widgets:${a.id}`]; if (was?.block) taken.delete(was.block); }
222
+ const rows = [];
223
+ const areas = [];
224
+ let footerCount = inFooter.size;
225
+ for (const area of wie.areas) {
226
+ if (!area.widgets.length) continue;
227
+ const key = `widgets:${area.id}`;
228
+ const was = mapped[key];
229
+ const label = areaLabel(area.id);
230
+ const decisions = area.widgets.map(w => ({w, d: decideWidget(w, ctx)}));
231
+ const rebuild = decisions.filter(x => x.d.how === 'rebuild').map(x => `${describe(x.w)} - ${x.d.why}`);
232
+ const footer = isFooterArea(area.id);
233
+ const row = {
234
+ key, kind: 'widgets', wpId: 0, title: `${label} widgets`, area: area.id, count: area.widgets.length,
235
+ widgetTypes: [...new Set(area.widgets.map(w => typeLabel(w.type)))],
236
+ needs: rebuild.length ? `Rebuild: ${rebuild.join('; ')}` : '', footer
237
+ };
238
+ let action;
239
+ let name;
240
+ let reason = '';
241
+ if (choices[key] === 'skip') { action = 'skip'; name = was?.block || ''; reason = 'You chose to skip it.'; }
242
+ else if (was?.block && ctx.blockNames?.has(was.block)) {
243
+ name = was.block;
244
+ const edited = ctx.blockHashes?.[name] && ctx.blockHashes[name] !== was.hash;
245
+ if (edited && choices[key] !== 'overwrite') { action = 'keep'; reason = 'Edited on this site since it was imported - left as it is.'; }
246
+ else { action = 'update'; reason = edited ? 'Edited here since - you chose to overwrite it.' : ''; }
247
+ } else if (was?.block && choices[key] !== 'import') {
248
+ action = 'skip'; name = was.block; reason = 'Deleted on this site since it was imported - choose "Import again" to bring it back.';
249
+ } else {
250
+ const wanted = was?.block || `wp-${slugify(area.id) || 'widgets'}`.slice(0, 60).replace(/-+$/, '');
251
+ name = freePath(wanted, taken);
252
+ action = name === wanted ? 'create' : 'rename';
253
+ if (was?.block) reason = 'Imported again after being deleted here.';
254
+ else if (action === 'rename') reason = `This site has a block called ${wanted} - this one is ${name}.`;
255
+ }
256
+ if (name) taken.add(name);
257
+ // The footer: placed once; never twice, never taken back out, and not put back once removed here.
258
+ let place = '';
259
+ if (footer && action !== 'skip') {
260
+ if (inFooter.has(name)) place = 'there';
261
+ else if (footerOpt[area.id] === false) place = 'off';
262
+ else if (was?.placed) place = 'removed';
263
+ else if (footerCount >= FOOTER_MAX) place = 'full';
264
+ else { place = 'add'; footerCount++; }
265
+ }
266
+ rows.push({...row, action, target: name, reason, place});
267
+ areas.push({
268
+ id: area.id, label, block: name, action, count: area.widgets.length, types: row.widgetTypes, footer, place,
269
+ rebuild, coming: decisions.length - rebuild.length
270
+ });
271
+ }
272
+ return {rows, summary: {areas, inactive: wie.inactive || 0, notes: wie.notes || []}};
273
+ }
274
+
275
+ /**
276
+ * One widget area's block template: its widgets, in order, as HTML with no
277
+ * blank lines (a blank line ends an HTML block in Markdown - CLAUDE.md gotcha
278
+ * 11) and no "{{" (a block template's placeholder).
279
+ *
280
+ * @param {{id: string, widgets: object[]}} area
281
+ * @param {object} ctx - decideWidget()'s, plus:
282
+ * @param {object} ctx.convert - convertContent()'s context (mediaFor, mediaById, linkFor)
283
+ * @param {(id: number, url: string) => string} ctx.mediaUrl - /media/<file> for a picture, or ''
284
+ * @returns {{html: string, notes: string[], rebuild: string[]}}
285
+ */
286
+ export function widgetAreaHtml(area, ctx) {
287
+ const notes = [];
288
+ const rebuild = [];
289
+ const parts = [`<!-- Imported from WordPress: the "${area.id}" widget area. -->`];
290
+ for (const w of area.widgets) {
291
+ const d = decideWidget(w, ctx);
292
+ if (d.how === 'rebuild') { rebuild.push(`${describe(w)} - ${d.why}`); continue; }
293
+ const body = widgetBody(w, d, ctx, notes);
294
+ // Said, never silently dropped: a picture whose download failed, a widget with nothing left in it.
295
+ if (!body) { rebuild.push(`${describe(w)} - ${d.how === 'image' ? 'its picture did not come across' : 'nothing was left in it once cleaned'}`); continue; }
296
+ const title = widgetTitle(w);
297
+ const cls = `wp-widget wp-widget-${slugify(w.type) || 'other'}`;
298
+ parts.push(`<section class="${cls}">${title ? `\n<h3 class="wp-widget-title">${escapeText(title)}</h3>` : ''}\n${body}\n</section>`);
299
+ }
300
+ const html = parts.join('\n')
301
+ .replace(/\{/g, '&#123;')
302
+ .replace(/\n[ \t]*(?=\n)/g, '').replace(/\n{2,}/g, '\n');
303
+ return {html: `${html}\n`, notes, rebuild};
304
+ }
305
+
306
+ function widgetBody(w, d, ctx, notes) {
307
+ const s = w.settings || {};
308
+ if (d.how === 'html') {
309
+ const raw = w.type === 'text' ? s.text : s.content;
310
+ const conv = convertContent(String(raw ?? ''), {...ctx.convert, html: true});
311
+ for (const n of conv.notes) if (!notes.includes(n)) notes.push(n);
312
+ if (conv.shortcodes.length) {
313
+ const n = `WordPress shortcodes in the widgets are left as text to rebuild: ${conv.shortcodes.map(x => `[${x}]`).join(' ')}.`;
314
+ if (!notes.includes(n)) notes.push(n);
315
+ }
316
+ // A picture with no real address (src="x") would only ask this site for a page that is not there.
317
+ return conv.body.replace(/<img\b[^>]*>/gi, (tag) => (/\ssrc="(\/(?!\/)|https?:\/\/)/i.test(tag) ? tag : '')).trim();
318
+ }
319
+ if (d.how === 'image') {
320
+ const src = ctx.mediaUrl?.(int(s.attachment_id, 0), String(s.url || '')) || '';
321
+ if (!/^\/media\/[^/]+$/.test(src)) return '';
322
+ const alt = clipText(s.alt || s.image_title || '', 300);
323
+ let pic = `<img src="${escapeAttr(src)}" alt="${escapeAttr(alt)}">`;
324
+ const link = imageLink(s, src, ctx);
325
+ if (link) pic = `<a href="${escapeAttr(link)}">${pic}</a>`;
326
+ const caption = clipText(String(s.caption || '').replace(/<[^>]*>/g, ''), 500);
327
+ return `<figure class="wp-widget-figure">${pic}${caption ? `<figcaption>${escapeText(caption)}</figcaption>` : ''}</figure>`;
328
+ }
329
+ if (d.how === 'menu') return `[menu slug="${escapeAttr(d.slug)}" /]`;
330
+ if (d.how === 'blog') {
331
+ if (w.type === 'recent-posts') return `[blog-list count="${Math.min(20, Math.max(1, int(s.number, 5)))}" /]`;
332
+ return `[blog-categories${s.count ? ' show-counts="true"' : ''} /]`;
333
+ }
334
+ if (d.how === 'link') {
335
+ const base = /^\/[a-z0-9/-]*$/i.test(ctx.blog?.basePath || '') ? ctx.blog.basePath : '/blog';
336
+ const n = 'WordPress date archives became a link to the Blog.';
337
+ if (!notes.includes(n)) notes.push(n);
338
+ return `<p><a href="${escapeAttr(base)}">All posts</a></p>`;
339
+ }
340
+ if (d.how === 'search') return '<p><a class="btn btn-outline btn-sm wp-widget-search-link" href="#site-search">Search the site</a></p>';
341
+ return '';
342
+ }
343
+
344
+ /** Where an image widget's picture leads: its own link, the file, or nowhere. Only http(s) or this site. */
345
+ function imageLink(s, src, ctx) {
346
+ const type = String(s.link_type || 'none');
347
+ if (type === 'file' || type === 'post') return src;
348
+ if (type !== 'custom') return '';
349
+ const raw = String(s.link_url || '').trim();
350
+ if (!raw) return '';
351
+ const local = ctx.convert?.linkFor?.(raw);
352
+ const href = local || raw;
353
+ if (/^\/(?!\/)/.test(href) || /^https?:\/\//i.test(href)) return href;
354
+ return '';
355
+ }
@@ -25,7 +25,7 @@
25
25
  <link rel="stylesheet" href="/dist/domma/themes/domma-themes.css">
26
26
 
27
27
  <!-- Site CSS -->
28
- <link rel="stylesheet" href="/public/css/site.css?v=20261016-mobile-options">
28
+ <link rel="stylesheet" href="/public/css/site.css?v=20261016-footer-blocks">
29
29
  <link rel="stylesheet" href="/public/css/forms.css?v=20260927-submit-btn-css">
30
30
  <!-- Menu item hover / current-page effects. Loaded after site.css so a
31
31
  menu's own highlight wins over the blanket link styling there. -->
@@ -91,7 +91,7 @@
91
91
  </main>
92
92
 
93
93
  {{#if showFooter}}
94
- <footer id="site-footer" class="page-footer"></footer>
94
+ <footer id="site-footer" class="page-footer">{{footerBlocksHtml}}</footer>
95
95
  {{/if}}
96
96
 
97
97
  <!-- DOMPurify - must load before DommaJS -->
@@ -173,7 +173,7 @@
173
173
  </script>
174
174
 
175
175
  <!-- Site initialisation -->
176
- <script src="/public/js/site.js?v=20261016-mobile-options" type="module"></script>
176
+ <script src="/public/js/site.js?v=20261016-footer-blocks" type="module"></script>
177
177
  <script src="/public/js/collection-context.js?v=20261010-utf8-payloads" type="module"></script>
178
178
  {{ctxMenusModule}}
179
179