apostrophe 4.29.0 → 4.30.0

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 (48) hide show
  1. package/.claude/settings.local.json +15 -0
  2. package/CHANGELOG.md +34 -0
  3. package/modules/@apostrophecms/admin-bar/ui/apos/components/TheAposAdminBar.vue +0 -1
  4. package/modules/@apostrophecms/admin-bar/ui/apos/components/TheAposAdminBarMenu.vue +25 -8
  5. package/modules/@apostrophecms/admin-bar/ui/apos/components/TheAposContextBreakpointPreviewMode.vue +9 -0
  6. package/modules/@apostrophecms/admin-bar/ui/apos/components/TheAposContextTitle.vue +20 -2
  7. package/modules/@apostrophecms/area/index.js +10 -5
  8. package/modules/@apostrophecms/area/ui/apos/components/AposAreaWidget.vue +2 -0
  9. package/modules/@apostrophecms/command-menu/ui/apos/components/TheAposCommandMenu.vue +11 -1
  10. package/modules/@apostrophecms/i18n/i18n/en.json +8 -0
  11. package/modules/@apostrophecms/i18n/index.js +1 -8
  12. package/modules/@apostrophecms/image-widget/index.js +29 -1
  13. package/modules/@apostrophecms/layout-widget/index.js +124 -2
  14. package/modules/@apostrophecms/layout-widget/ui/apos/components/AposAreaLayoutEditor.vue +89 -6
  15. package/modules/@apostrophecms/layout-widget/ui/apos/components/AposGridLayout.vue +2 -2
  16. package/modules/@apostrophecms/layout-widget/ui/apos/components/AposGridManager.vue +2 -2
  17. package/modules/@apostrophecms/layout-widget/ui/apos/layout.css +8 -0
  18. package/modules/@apostrophecms/layout-widget/ui/src/layout.css +1 -1
  19. package/modules/@apostrophecms/layout-widget/views/widget.html +3 -3
  20. package/modules/@apostrophecms/login/index.js +13 -15
  21. package/modules/@apostrophecms/modal/ui/apos/components/AposModal.vue +2 -1
  22. package/modules/@apostrophecms/oembed/index.js +18 -13
  23. package/modules/@apostrophecms/recently-edited/ui/apos/components/AposRecentlyEditedIcon.vue +2 -0
  24. package/modules/@apostrophecms/rich-text-widget/index.js +36 -0
  25. package/modules/@apostrophecms/schema/ui/apos/logic/AposInputCheckboxes.js +1 -1
  26. package/modules/@apostrophecms/schema/ui/apos/logic/AposInputRadio.js +1 -1
  27. package/modules/@apostrophecms/styles/index.js +16 -0
  28. package/modules/@apostrophecms/styles/lib/handlers.js +6 -0
  29. package/modules/@apostrophecms/styles/lib/methods.js +93 -0
  30. package/modules/@apostrophecms/styles/lib/presets.js +17 -0
  31. package/modules/@apostrophecms/styles/ui/apos/universal/render.mjs +10 -1
  32. package/modules/@apostrophecms/submitted-draft/ui/apos/components/AposSubmittedDraftIcon.vue +1 -0
  33. package/modules/@apostrophecms/template/views/outerLayoutBase.html +1 -1
  34. package/modules/@apostrophecms/ui/ui/apos/components/AposButton.vue +14 -2
  35. package/modules/@apostrophecms/ui/ui/apos/components/AposContextMenu.vue +29 -5
  36. package/modules/@apostrophecms/ui/ui/apos/components/AposContextMenuDialog.vue +8 -0
  37. package/modules/@apostrophecms/ui/ui/apos/components/AposContextMenuItem.vue +5 -2
  38. package/modules/@apostrophecms/ui/ui/apos/components/AposLocalePicker.vue +14 -1
  39. package/modules/@apostrophecms/ui/ui/apos/scss/global/_utilities.scss +13 -1
  40. package/modules/@apostrophecms/util/index.js +4 -0
  41. package/modules/@apostrophecms/widget-type/index.js +6 -0
  42. package/modules/@apostrophecms/widget-type/ui/apos/components/AposWidgetEditor.vue +32 -0
  43. package/package.json +5 -5
  44. package/test/add-missing-schema-fields-project/node_modules/.package-lock.json +2 -2
  45. package/test/layout-widget-gap.js +530 -0
  46. package/test/login.js +122 -1
  47. package/test/rich-text-widget.js +200 -0
  48. package/test/styles.js +50 -0
@@ -0,0 +1,15 @@
1
+ {
2
+ "permissions": {
3
+ "allow": [
4
+ "Bash(timeout 180 npx mocha:*)",
5
+ "Bash(timeout 600 npx mocha:*)",
6
+ "Bash(npm ls:*)",
7
+ "Bash(timeout 540 npx mocha:*)",
8
+ "Bash(echo:*)",
9
+ "Bash(timeout 10 node:*)",
10
+ "Bash(timeout 300 npx mocha:*)",
11
+ "Bash(timeout 60 npx mocha:*)",
12
+ "Bash(timeout 120 npx mocha:*)"
13
+ ]
14
+ }
15
+ }
package/CHANGELOG.md CHANGED
@@ -1,5 +1,39 @@
1
1
  # Changelog
2
2
 
3
+ ## 4.30.0
4
+
5
+ ### Adds
6
+
7
+ - Layout widget gap is now controllable through the styles system, both site-wide via a global `layoutGap` preset and per widget via a `gap` styles field. A new `className` option allows additional CSS classes to be added to the widget grid container.
8
+
9
+ ### Fixes
10
+
11
+ - Fixed layout widget not regaining full focus when switching back to Edit content mode.
12
+ - Fixed illegal HTML `id` attribute values generated by the admin UI.
13
+ - Fixed orderable table array items dragging the entire floating window.
14
+ - Fixed keyboard shortcuts for widget operations (copy, cut, paste, duplicate, remove) blocking the browser's native clipboard behavior when no widget was focused. Previously, selecting and copying text while logged in was intercepted unconditionally by the admin UI.
15
+ - Removed duplicate `<meta charset>` tag from `outerLayoutBase.html` and standardized charset to `utf-8`.
16
+ - Updated `apostrophe` and `oembetter` to remove oembed services that no longer support public access, eliminating them as a potential future XSS vector. New `minimumAllowlist` and `minimumEndpoints` options on `@apostrophecms/oembed` allow developers to prune the list further.
17
+
18
+ ### Security
19
+
20
+ - **Password reset base URL requirement:** The password reset feature now refuses to operate unless `baseUrl` or `APOS_BASE_URL` is set, preventing a vulnerability where ApostropheCMS could be convinced to send emails with links to attacker-controlled sites. Only affects projects with `passwordReset: true` on the login module. Thanks to [SPIDY](https://github.com/Mujahidkhan525) for reporting.
21
+ - **XSS via full name field:** A malicious full name containing HTML was executed in the page title tooltip in the admin bar, posing an XSS risk to other users. All multi-user projects should update promptly. Thanks to [Muhammad Uwais](https://github.com/MuhammadUwais) for reporting.
22
+ - **XSS via image widget link URL:** Users with editing privileges could trigger arbitrary JavaScript via a `javascript:` URL in the image widget's link URL field. A migration is included to strip any such URLs already in the database. Thanks to [Muhammad Uwais](https://github.com/MuhammadUwais) for reporting.
23
+ - **SSRF via rich text HTML import:** The rich text widget's HTML import feature no longer fetches images from arbitrary hosts, which could be used to probe internal networks or exfiltrate internal images. Configure `imageImportAllowedHostnames` on `@apostrophecms/rich-text-widget` to opt in. Thanks to [Yiğit Şengezer](https://github.com/yigitsengezer) and [Sainithin0309](https://github.com/Sainithin0309) for reporting.
24
+ - **the xmp tag could be used to pass forbidden markup through sanitize-html**, even when xmp itself. This was fixed in `sanitize-html` and the dependency was bumped. Thanks to [Vincenzo Turturro](https://github.com/sushi-gif) for reporting the vulnerability.
25
+ - **the `linkHref` field of image widgets was an XSS vulnerability** because it did not use the `url` field type. This means that a user with editing privileges could potentially carry out XSS. In addition, we have updated the `launder` module to sanitize URLs more robustly for the `url` field type, and bumped that dependency. Also, a database migration is included to clean any XSS attacks that could be present in existing links. Thanks to [Muhammad Uwais](https://github.com/MuhammadUwais) for reporting the issue.
26
+
27
+ ### Accessibility
28
+
29
+ - Corrected ARIA semantics on the top admin navigation bar.
30
+ - Improved the document context title (admin bar middle group) and the underlying `AposContextMenu` machinery.
31
+ - Improved the locale switcher (`AposLocalePicker`).
32
+ - The Recently Edited Documents tray icon now exposes its action via `aria-label`.
33
+ - Fixed `.apos-sr-only` so screen-reader-only content is correctly exposed to the accessibility tree.
34
+ - Icon-only context-utility buttons in the admin bar tray (e.g. the global settings cog) now expose their action via `aria-label`.
35
+
36
+
3
37
  ## 4.29.0 (2026-04-15)
4
38
 
5
39
  ### Adds
@@ -11,7 +11,6 @@
11
11
  <nav
12
12
  ref="adminBar"
13
13
  :class="classes"
14
- role="menubar"
15
14
  aria-label="Apostrophe Admin Bar"
16
15
  >
17
16
  <div class="apos-admin-bar__row">
@@ -1,8 +1,5 @@
1
1
  <template>
2
- <ol
3
- class="apos-admin-bar__items"
4
- role="menu"
5
- >
2
+ <ol class="apos-admin-bar__items">
6
3
  <li
7
4
  v-if="pageTree"
8
5
  class="apos-admin-bar__item"
@@ -12,7 +9,6 @@
12
9
  label="apostrophe:pages"
13
10
  class="apos-admin-bar__btn"
14
11
  :modifiers="['no-motion']"
15
- role="menuitem"
16
12
  action-test-label="page-manager-button"
17
13
  @click="emitEvent({ action: '@apostrophecms/page:manager' })"
18
14
  />
@@ -33,7 +29,6 @@
33
29
  class: 'apos-admin-bar__btn',
34
30
  type: 'subtle'
35
31
  }"
36
- role="menuitem"
37
32
  @item-clicked="emitEvent"
38
33
  />
39
34
  <Component
@@ -44,7 +39,6 @@
44
39
  :modifiers="['no-motion']"
45
40
  class="apos-admin-bar__btn"
46
41
  :action-test-label="`${item.name}-manager-button`"
47
- role="menuitem"
48
42
  @click="emitEvent(item)"
49
43
  />
50
44
  </li>
@@ -62,7 +56,6 @@
62
56
  type: 'primary',
63
57
  modifiers: ['round', 'no-motion']
64
58
  }"
65
- role="menuitem"
66
59
  @item-clicked="emitEvent"
67
60
  />
68
61
  </li>
@@ -89,6 +82,7 @@
89
82
  :label="item.label"
90
83
  :action="item.action"
91
84
  :state="trayItemState[item.name] ? [ 'active' ] : []"
85
+ :attrs="trayItemAttrs(item)"
92
86
  @click="emitEvent(item)"
93
87
  />
94
88
  </template>
@@ -185,6 +179,29 @@ export default {
185
179
  } else {
186
180
  return item.options.tooltip;
187
181
  }
182
+ },
183
+ // Tray utility buttons render icon-only, so the visible label
184
+ // (e.g. "Global Content") is sr-only and doesn't describe what the
185
+ // button does. Make them accessible by providing an aria-label based on
186
+ // the tooltip content.
187
+ trayItemAttrs(item) {
188
+ const tooltip = item.options?.tooltip;
189
+ let key = null;
190
+ if (item.options?.toggle) {
191
+ if (this.trayItemState[item.name] && tooltip?.deactivate) {
192
+ key = tooltip.deactivate;
193
+ } else if (tooltip?.activate) {
194
+ key = tooltip.activate;
195
+ }
196
+ } else if (typeof tooltip === 'string') {
197
+ key = tooltip;
198
+ } else if (tooltip && typeof tooltip.content === 'string') {
199
+ key = tooltip.content;
200
+ }
201
+ if (!key) {
202
+ return {};
203
+ }
204
+ return { 'aria-label': this.$t(key) };
188
205
  }
189
206
  }
190
207
  };
@@ -14,6 +14,7 @@
14
14
  :label="screen.label"
15
15
  :tooltip="$t(screen.label)"
16
16
  :title="$t(screen.label)"
17
+ :attrs="shortcutAttrs(screen)"
17
18
  :icon="screen.icon"
18
19
  :icon-only="true"
19
20
  type="subtle"
@@ -36,6 +37,7 @@
36
37
  :active-item="mode"
37
38
  :center-on-icon="true"
38
39
  menu-placement="bottom-end"
40
+ :dialog-label="'apostrophe:breakpointPreviewSelectMenu'"
39
41
  @item-clicked="selectBreakpoint"
40
42
  />
41
43
  <Transition>
@@ -320,6 +322,13 @@ export default {
320
322
  },
321
323
  setShowDropdown() {
322
324
  this.showDropdown = Object.values(this.screens).some(({ shortcut }) => !shortcut);
325
+ },
326
+ shortcutAttrs(screen) {
327
+ return {
328
+ 'aria-label': this.$t('apostrophe:breakpointPreviewShortcut', {
329
+ breakpoint: this.$t(screen.label)
330
+ })
331
+ };
323
332
  }
324
333
  }
325
334
  };
@@ -33,6 +33,8 @@
33
33
  :disabled="hasCustomUi || isUnpublished"
34
34
  :center-on-icon="true"
35
35
  menu-placement="bottom-end"
36
+ :dialog-label="'apostrophe:publicationStatusMenu'"
37
+ :trigger-aria-label="draftTriggerAriaLabel"
36
38
  @item-clicked="switchDraftMode"
37
39
  />
38
40
  <AposLabel
@@ -56,6 +58,15 @@
56
58
  <script>
57
59
  import dayjs from 'dayjs';
58
60
 
61
+ function escapeHtml(s) {
62
+ return String(s)
63
+ .replace(/&/g, '&amp;')
64
+ .replace(/</g, '&lt;')
65
+ .replace(/>/g, '&gt;')
66
+ .replace(/"/g, '&quot;')
67
+ .replace(/'/g, '&#39;');
68
+ }
69
+
59
70
  export default {
60
71
  name: 'TheAposContextTitle',
61
72
  props: {
@@ -78,8 +89,8 @@ export default {
78
89
  if (this.context.updatedBy) {
79
90
  const editor = this.context.updatedBy;
80
91
  editorLabel = '';
81
- editorLabel += editor.title ? `${editor.title} ` : '';
82
- editorLabel += editor.username ? `(${editor.username})` : '';
92
+ editorLabel += editor.title ? `${escapeHtml(editor.title)} ` : '';
93
+ editorLabel += editor.username ? `(${escapeHtml(editor.username)})` : '';
83
94
  }
84
95
  return editorLabel;
85
96
  },
@@ -91,6 +102,13 @@ export default {
91
102
  type: 'quiet'
92
103
  };
93
104
  },
105
+ draftTriggerAriaLabel() {
106
+ return this.$t('apostrophe:publicationStatusTrigger', {
107
+ status: this.$t(
108
+ this.draftMode === 'draft' ? 'apostrophe:draft' : 'apostrophe:published'
109
+ )
110
+ });
111
+ },
94
112
  isUnpublished() {
95
113
  return !this.context.lastPublishedAt;
96
114
  },
@@ -19,7 +19,8 @@ module.exports = {
19
19
  action: {
20
20
  type: 'command-menu-area-cut-widget'
21
21
  },
22
- shortcut: 'Ctrl+X Meta+X'
22
+ shortcut: 'Ctrl+X Meta+X',
23
+ requireWidgetFocus: true
23
24
  },
24
25
  [`${self.__meta.name}:copy-widget`]: {
25
26
  type: 'item',
@@ -27,7 +28,8 @@ module.exports = {
27
28
  action: {
28
29
  type: 'command-menu-area-copy-widget'
29
30
  },
30
- shortcut: 'Ctrl+C Meta+C'
31
+ shortcut: 'Ctrl+C Meta+C',
32
+ requireWidgetFocus: true
31
33
  },
32
34
  [`${self.__meta.name}:paste-widget`]: {
33
35
  type: 'item',
@@ -35,7 +37,8 @@ module.exports = {
35
37
  action: {
36
38
  type: 'command-menu-area-paste-widget'
37
39
  },
38
- shortcut: 'Ctrl+V Meta+V'
40
+ shortcut: 'Ctrl+V Meta+V',
41
+ requireWidgetFocus: true
39
42
  },
40
43
  [`${self.__meta.name}:duplicate-widget`]: {
41
44
  type: 'item',
@@ -43,7 +46,8 @@ module.exports = {
43
46
  action: {
44
47
  type: 'command-menu-area-duplicate-widget'
45
48
  },
46
- shortcut: 'Ctrl+Shift+D Meta+Shift+D'
49
+ shortcut: 'Ctrl+Shift+D Meta+Shift+D',
50
+ requireWidgetFocus: true
47
51
  },
48
52
  [`${self.__meta.name}:remove-widget`]: {
49
53
  type: 'item',
@@ -51,7 +55,8 @@ module.exports = {
51
55
  action: {
52
56
  type: 'command-menu-area-remove-widget'
53
57
  },
54
- shortcut: 'Backspace'
58
+ shortcut: 'Backspace',
59
+ requireWidgetFocus: true
55
60
  }
56
61
  },
57
62
  modal: {
@@ -536,6 +536,7 @@ export default {
536
536
  apos.bus.$on('widget-focus-parent', this.focusParent);
537
537
  apos.bus.$on('context-menu-toggled', this.getFocusForMenu);
538
538
  apos.bus.$on('suppress-focused-widget-controls', this.doSuppressWidgetControls);
539
+ apos.bus.$on('clear-focused-widget-control-suppression', this.clearSuppressionFlags);
539
540
 
540
541
  this.breadcrumbs.$lastEl = this.$el;
541
542
 
@@ -573,6 +574,7 @@ export default {
573
574
  // Remove the focus parent listener when unmounted
574
575
  apos.bus.$off('widget-focus-parent', this.focusParent);
575
576
  apos.bus.$off('suppress-focused-widget-controls', this.doSuppressWidgetControls);
577
+ apos.bus.$off('clear-focused-widget-control-suppression', this.clearSuppressionFlags);
576
578
  window.removeEventListener('scroll', this.stickyControlsScroll);
577
579
  window.removeEventListener('resize', this.stickyControlsResize);
578
580
  this.unregisterFromGraph();
@@ -10,6 +10,7 @@
10
10
  import { mapActions, mapState } from 'pinia';
11
11
  import AposThemeMixin from 'Modules/@apostrophecms/ui/mixins/AposThemeMixin';
12
12
  import { useModalStore } from 'Modules/@apostrophecms/ui/stores/modal';
13
+ import { useWidgetStore } from 'Modules/@apostrophecms/ui/stores/widget';
13
14
 
14
15
  export default {
15
16
  name: 'TheAposCommandMenu',
@@ -50,7 +51,13 @@ export default {
50
51
  .flatMap(command => {
51
52
  return command.shortcut
52
53
  .split(' ')
53
- .map(shortcut => [ shortcut.toUpperCase(), command.action ]);
54
+ .map(shortcut => [
55
+ shortcut.toUpperCase(),
56
+ {
57
+ ...command.action,
58
+ requireWidgetFocus: command.requireWidgetFocus || false
59
+ }
60
+ ]);
54
61
  });
55
62
  })
56
63
  );
@@ -111,6 +118,9 @@ export default {
111
118
  ? keys.slice('SHIFT+'.length)
112
119
  : keys];
113
120
  if (action) {
121
+ if (action.requireWidgetFocus && !useWidgetStore().focusedWidget) {
122
+ return;
123
+ }
114
124
  event.preventDefault();
115
125
  apos.bus.$emit(action.type, action.payload);
116
126
  return;
@@ -89,6 +89,8 @@
89
89
  "breakpointPreviewExit": "Exit",
90
90
  "breakpointPreviewMobile": "Mobile",
91
91
  "breakpointPreviewSelect": "Select Breakpoint",
92
+ "breakpointPreviewSelectMenu": "Breakpoint preview menu",
93
+ "breakpointPreviewShortcut": "Preview at {{ breakpoint }} breakpoint",
92
94
  "breakpointPreviewTablet": "Tablet",
93
95
  "browse": "Browse",
94
96
  "browseDocType": "Browse {{ type }}",
@@ -387,6 +389,7 @@
387
389
  "mediaUploadViaDrop": "Drop ’em when you’re ready",
388
390
  "mediaUploadViaExplorer": "Or click to open the file explorer",
389
391
  "mergeCells": "Merge Cells",
392
+ "menu": "Menu",
390
393
  "minLabel": "Min:",
391
394
  "minSize": "Min size of {{ width }}x{{ height }}",
392
395
  "minimumSize": "Minimum size of {{ width }} x {{ height }} px",
@@ -475,6 +478,8 @@
475
478
  "publishBeforeUsingTooltip": "Publish this content before using it in a relationship",
476
479
  "publishType": "Publish {{ type }}",
477
480
  "published": "Published",
481
+ "publicationStatusMenu": "Publication status",
482
+ "publicationStatusTrigger": "Publication status: {{ status }}. Change publication status.",
478
483
  "publishingBatchConfirmation": "Are you sure you want to publish {{ count }} {{ type }}?",
479
484
  "publishingBatchConfirmationButton": "Yes, publish content.",
480
485
  "rawCssAndJs": "Raw CSS and JS",
@@ -490,6 +495,7 @@
490
495
  "recentlyEditedActionSubmitted": "Submitted",
491
496
  "recentlyEditedCurrentUser": "Me ({{ user }})",
492
497
  "recentlyEditedDocuments": "Recently edited documents",
498
+ "recentlyEditedManagerOpen": "Open recently edited documents manager",
493
499
  "recentlyEditedEditedBy": "Edited by",
494
500
  "recentlyEditedClearAllFilters": "Clear all filters",
495
501
  "recentlyEditedClearSearch": "Clear search",
@@ -643,6 +649,8 @@
643
649
  "styleGradientAngle": "Angle",
644
650
  "styleGradientEnd": "End Color",
645
651
  "styleGradientStart": "Start Color",
652
+ "styleLayoutGap": "Layout Gap",
653
+ "styleLayoutGapHelp": "Sets the spacing between columns inside layout sections across the site.",
646
654
  "styleLeft": "Left",
647
655
  "styleMargin": "Margin",
648
656
  "styleOverlayColor": "Overlay Color",
@@ -29,10 +29,6 @@
29
29
  // in the same language as the website content.
30
30
  // Example: `defaultAdminLocale: 'fr'`.
31
31
  //
32
- // ### `encoding`
33
- //
34
- // Defaults to `'utf-8'`. You almost certainly do not want to change this.
35
- //
36
32
  // ### `slugDirection`
37
33
  //
38
34
  // Controls the default `direction` value of slug schema. Can be `ltr`, `rtl` or
@@ -81,8 +77,6 @@ module.exports = {
81
77
  },
82
78
  // If true, slugifying will strip accents from Latin characters
83
79
  stripUrlAccents: false,
84
- // You almost certainly do not want to change this
85
- encoding: 'utf-8',
86
80
  slugDirection: 'ltr'
87
81
  },
88
82
  async init(self) {
@@ -166,7 +160,6 @@ module.exports = {
166
160
  await self.i18next.init(i18nextOptions);
167
161
  self.addInitialResources();
168
162
  self.enableBrowserData();
169
- self.encoding = self.options.encoding;
170
163
  },
171
164
  handlers(self) {
172
165
  return {
@@ -1369,7 +1362,7 @@ module.exports = {
1369
1362
  helpers(self) {
1370
1363
  return {
1371
1364
  encoding() {
1372
- return self.encoding;
1365
+ return 'utf-8';
1373
1366
  }
1374
1367
  };
1375
1368
  }
@@ -102,7 +102,7 @@ module.exports = {
102
102
  linkHref: {
103
103
  label: 'apostrophe:url',
104
104
  help: 'apostrophe:linkHrefHelp',
105
- type: 'string',
105
+ type: 'url',
106
106
  required: true,
107
107
  if: {
108
108
  linkTo: '_url'
@@ -147,6 +147,7 @@ module.exports = {
147
147
  self.showPlaceholder = self.options.placeholder !== false;
148
148
  self.options.placeholder = true;
149
149
  self.determineBestAssetUrl('placeholder');
150
+ self.addCleanLinkHrefMigration();
150
151
  },
151
152
  handlers(self) {
152
153
  return {
@@ -159,6 +160,33 @@ module.exports = {
159
160
  },
160
161
  methods(self) {
161
162
  return {
163
+ // Clear link hrefs that use dangerous URL schemes (e.g.
164
+ // `javascript:`) and may have been stored before the linkHref
165
+ // schema field was changed to `type: 'url'`. Registered per
166
+ // module instance so subclasses of this widget are migrated
167
+ // under their own widget type and migration name.
168
+ addCleanLinkHrefMigration() {
169
+ self.apos.migration.add(
170
+ `${self.__meta.name}:clean-naughty-link-href`,
171
+ async () => {
172
+ await self.apos.migration.eachWidget({}, async (doc, widget, dotPath) => {
173
+ if (widget.type !== self.name) {
174
+ return;
175
+ }
176
+ if (!widget.linkHref) {
177
+ return;
178
+ }
179
+ if (!self.apos.launder.naughtyHref(widget.linkHref)) {
180
+ return;
181
+ }
182
+ await self.apos.doc.db.updateOne(
183
+ { _id: doc._id },
184
+ { $set: { [`${dotPath}.linkHref`]: '' } }
185
+ );
186
+ });
187
+ }
188
+ );
189
+ },
162
190
  validateAndAddSchemaLabels() {
163
191
  const linkWithType = self.options.linkWithType;
164
192
 
@@ -19,6 +19,10 @@ module.exports = {
19
19
  gap: '1.5rem',
20
20
  defaultCellHorizontalAlignment: null,
21
21
  defaultCellVerticalAlignment: null,
22
+ // Extra class name(s) to append to the rendered layout-widget area
23
+ // wrapper, in addition to the built-in `layout-widget` class. Accepts
24
+ // a string of space-separated class names.
25
+ className: '',
22
26
  injectStyles: true,
23
27
  minifyStyles: true
24
28
  },
@@ -98,6 +102,25 @@ module.exports = {
98
102
  validateAndIdentifyTypes() {
99
103
  const { column } = self.validateAndIdentifyTypes();
100
104
  self.columnWidgetName = column;
105
+ },
106
+ // Detect the widget-style "gap" field (any styles field whose
107
+ // CSS `property` resolves to `gap`). Only the first match is used.
108
+ // Also detect whether the @apostrophecms/styles module has a
109
+ // site-wide layout gap field configured (via the `layoutGap`
110
+ // preset / `layoutGapDefault: true` marker).
111
+ detectGapFields() {
112
+ const widgetGapFields = self.apos.styles
113
+ .fieldsWithProperty(self.schema, 'gap');
114
+ if (widgetGapFields.length > 1) {
115
+ self.apos.util.warn(
116
+ `[${self.__meta.name}] Multiple style fields produce the ` +
117
+ `CSS \`gap\` property (${widgetGapFields.join(', ')}). ` +
118
+ 'Only the first one will be honoured as the widget-scope gap.'
119
+ );
120
+ }
121
+ self.widgetGapFieldName = widgetGapFields[0] || null;
122
+ self.globalGapEnabled = !!self.apos.modules['@apostrophecms/styles']
123
+ ?.layoutGapFieldName;
101
124
  }
102
125
  }
103
126
  };
@@ -118,6 +141,17 @@ module.exports = {
118
141
  defaultCellHorizontalAlignment: self.options.defaultCellHorizontalAlignment,
119
142
  defaultCellVerticalAlignment: self.options.defaultCellVerticalAlignment
120
143
  },
144
+ widgetGapFieldName: self.widgetGapFieldName || null,
145
+ widgetGapFieldUnit: self.widgetGapFieldName
146
+ ? (self.apos.styles
147
+ .getFieldByPath(self.schema, self.widgetGapFieldName)
148
+ ?.unit || '')
149
+ : '',
150
+ globalGapEnabled: !!self.globalGapEnabled,
151
+ // Opt-in flag read by the generic widget editor: when true,
152
+ // it broadcasts `apos-widget-live-preview` events on the apos bus
153
+ // during the style-only fast path.
154
+ subscribesToLivePreview: !!self.widgetGapFieldName,
121
155
  columnWidgetName: self.columnWidgetName
122
156
  };
123
157
  },
@@ -132,6 +166,7 @@ module.exports = {
132
166
  defaultCellHorizontalAlignment,
133
167
  defaultCellVerticalAlignment
134
168
  } = self.options;
169
+ const widgetGap = self.resolveWidgetGap(widget);
135
170
  return {
136
171
  ..._super(widget, { scene }),
137
172
  columns,
@@ -141,13 +176,53 @@ module.exports = {
141
176
  tablet,
142
177
  gap,
143
178
  defaultCellHorizontalAlignment,
144
- defaultCellVerticalAlignment
179
+ defaultCellVerticalAlignment,
180
+ _gap: widgetGap,
181
+ _gapHasGlobal: !!self.globalGapEnabled
145
182
  };
146
183
  }
147
184
  };
148
185
  },
149
186
  methods(self) {
150
187
  return {
188
+ // Resolve the widget-scope gap for a widget instance, if this
189
+ // module declares a styles field with `property: 'gap'`. Returns
190
+ // the resolved value (with unit, when applicable). When the
191
+ // widget has no explicit value, falls back to the field's `def`.
192
+ // Returns `null` only when no widget gap field is
193
+ // configured, no widget value is set, and no `def` is declared
194
+ // on the field.
195
+ resolveWidgetGap(widget) {
196
+ if (!self.widgetGapFieldName || !widget) {
197
+ return null;
198
+ }
199
+ const field = self.apos.styles.getFieldByPath(
200
+ self.schema, self.widgetGapFieldName
201
+ );
202
+ let value = self.apos.util.get(widget, self.widgetGapFieldName);
203
+ if (value === null || value === undefined || value === '') {
204
+ if (field?.def === null || field?.def === undefined || field?.def === '') {
205
+ return null;
206
+ }
207
+ value = field.def;
208
+ }
209
+ if (field?.unit && typeof value !== 'string') {
210
+ return `${value}${field.unit}`;
211
+ }
212
+ return value;
213
+ },
214
+ // Determine whether the inline `--grid-gap` CSS variable should
215
+ // be omitted on the grid container so the global cascade
216
+ // (`var(--apos-layout-gap, …)`) can take effect. The inline var
217
+ // is omitted whenever:
218
+ // - no widget-scope gap value is set, AND
219
+ // - the global layout-gap field is configured.
220
+ shouldOmitInlineGap(widget, global) {
221
+ if (!self.globalGapEnabled) {
222
+ return false;
223
+ }
224
+ return self.resolveWidgetGap(widget) === null;
225
+ },
151
226
  publicCssNodes(req) {
152
227
  return [
153
228
  {
@@ -277,11 +352,13 @@ module.exports = {
277
352
  const mobileBreakpointPlus = mobileBreakpoint + 1;
278
353
  const tabletBreakpoint = self.options.tablet?.breakpoint || 1024;
279
354
  const tabletBreakpointPlus = tabletBreakpoint + 1;
355
+ const gapDefault = self.options.gap || '0';
280
356
  cssContent = cssContent
281
357
  .replace(/\{\$mobile\}/g, mobileBreakpoint)
282
358
  .replace(/\{\$mobile-plus\}/g, mobileBreakpointPlus)
283
359
  .replace(/\{\$tablet\}/g, tabletBreakpoint)
284
- .replace(/\{\$tablet-plus\}/g, tabletBreakpointPlus);
360
+ .replace(/\{\$tablet-plus\}/g, tabletBreakpointPlus)
361
+ .replace(/\{\$gap-default\}/g, gapDefault);
285
362
 
286
363
  return self.processCss(cssContent, scene);
287
364
  },
@@ -371,6 +448,51 @@ module.exports = {
371
448
  (a.order ?? 0) - (b.order ?? 0)
372
449
  );
373
450
  return items[items.length - 1]._id;
451
+ },
452
+ // Compute the `--grid-gap: <value>;` declaration to inline on the
453
+ // grid container, or an empty string when the cascade should
454
+ // resolve gap via `var(--apos-layout-gap, …)` instead.
455
+ // Honours the priority order:
456
+ // 1. Widget-style gap (when set on this widget instance).
457
+ // 2. Static module option (BC) — when no global gap field is
458
+ // configured, or it has no value.
459
+ // 3. Otherwise, omit the inline var so the global cascade wins.
460
+ // Must be invoked via the widget's own module namespace —
461
+ // `apos.modules[data.manager.__meta.name].gapInlineCss(...)` —
462
+ // so that `self` resolves to the actual subclass and picks up
463
+ // its `widgetGapFieldName` / `globalGapEnabled`.
464
+ gapInlineCss(widget, options, global) {
465
+ const widgetGap = self.resolveWidgetGap(widget);
466
+ if (widgetGap !== null) {
467
+ return ` --grid-gap: ${widgetGap};`;
468
+ }
469
+ if (self.shouldOmitInlineGap(widget, global)) {
470
+ return '';
471
+ }
472
+ const fallback = (options && options.gap) || self.options.gap || '0';
473
+ return ` --grid-gap: ${fallback};`;
474
+ },
475
+ // Build the `aposParentOptions` payload passed by the rendered
476
+ // layout-widget area to the in-place editor (AposAreaLayoutEditor).
477
+ // Includes the widget's resolved gap (from its `gap` styles field,
478
+ // when present) so the live editor's grid container reflects the
479
+ // saved per-widget value rather than only the static module
480
+ // option. Sets `gap: null` to signal the editor to omit
481
+ // `--grid-gap` so the global cascade resolves it through
482
+ // `:root { --apos-layout-gap }`. Must be invoked via the widget's
483
+ // own module namespace, like `gapInlineCss`.
484
+ parentOptionsForArea(widget, options, global) {
485
+ const opts = {
486
+ ...(options || {}),
487
+ widgetId: widget._id
488
+ };
489
+ const widgetGap = self.resolveWidgetGap(widget);
490
+ if (widgetGap !== null) {
491
+ opts.gap = widgetGap;
492
+ } else if (self.shouldOmitInlineGap(widget, global)) {
493
+ opts.gap = null;
494
+ }
495
+ return opts;
374
496
  }
375
497
  };
376
498
  }