apostrophe 4.28.0 → 4.29.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 (88) hide show
  1. package/CHANGELOG.md +33 -3
  2. package/README.md +142 -0
  3. package/defaults.js +1 -0
  4. package/lib/safe-json-script.js +27 -0
  5. package/modules/@apostrophecms/admin-bar/ui/apos/components/TheAposAdminBarLocale.vue +1 -1
  6. package/modules/@apostrophecms/admin-bar/ui/apos/components/TheAposContextBar.vue +1 -0
  7. package/modules/@apostrophecms/area/ui/apos/components/AposAreaWidget.vue +3 -5
  8. package/modules/@apostrophecms/area/ui/apos/components/AposBreadcrumbOperations.vue +13 -1
  9. package/modules/@apostrophecms/asset/lib/globalIcons.js +3 -0
  10. package/modules/@apostrophecms/attachment/index.js +43 -1
  11. package/modules/@apostrophecms/color-field/index.js +7 -1
  12. package/modules/@apostrophecms/doc/index.js +11 -1
  13. package/modules/@apostrophecms/doc-type/index.js +165 -32
  14. package/modules/@apostrophecms/doc-type/ui/apos/components/AposDocEditor.vue +1 -1
  15. package/modules/@apostrophecms/doc-type/ui/apos/logic/AposDocContextMenu.js +104 -59
  16. package/modules/@apostrophecms/file/index.js +109 -9
  17. package/modules/@apostrophecms/i18n/i18n/de.json +0 -2
  18. package/modules/@apostrophecms/i18n/i18n/en.json +40 -1
  19. package/modules/@apostrophecms/i18n/i18n/es.json +0 -1
  20. package/modules/@apostrophecms/i18n/i18n/fr.json +0 -1
  21. package/modules/@apostrophecms/i18n/i18n/it.json +0 -1
  22. package/modules/@apostrophecms/i18n/i18n/pt-BR.json +0 -1
  23. package/modules/@apostrophecms/i18n/i18n/sk.json +0 -1
  24. package/modules/@apostrophecms/i18n/ui/apos/apps/AposI18nBatchReporting.js +18 -1
  25. package/modules/@apostrophecms/i18n/ui/apos/apps/AposI18nLocalizeActions.js +50 -0
  26. package/modules/@apostrophecms/i18n/ui/apos/components/AposI18nLocalize.vue +56 -13
  27. package/modules/@apostrophecms/image/ui/apos/components/AposImageRelationshipEditor.vue +8 -2
  28. package/modules/@apostrophecms/layout-column-widget/index.js +156 -163
  29. package/modules/@apostrophecms/layout-widget/index.js +7 -2
  30. package/modules/@apostrophecms/layout-widget/ui/apos/components/AposAreaLayoutEditor.vue +6 -11
  31. package/modules/@apostrophecms/layout-widget/ui/apos/components/AposGridColumn.vue +3 -5
  32. package/modules/@apostrophecms/layout-widget/ui/apos/components/AposGridLayout.vue +4 -4
  33. package/modules/@apostrophecms/layout-widget/ui/apos/components/AposGridManager.vue +0 -16
  34. package/modules/@apostrophecms/layout-widget/ui/apos/lib/grid-state.mjs +7 -27
  35. package/modules/@apostrophecms/layout-widget/views/column.html +7 -9
  36. package/modules/@apostrophecms/login/index.js +39 -40
  37. package/modules/@apostrophecms/modal/ui/apos/components/AposDocsManagerToolbar.vue +17 -2
  38. package/modules/@apostrophecms/modal/ui/apos/components/AposModal.vue +3 -2
  39. package/modules/@apostrophecms/notification/ui/apos/components/AposNotification.vue +1 -0
  40. package/modules/@apostrophecms/page/index.js +2 -0
  41. package/modules/@apostrophecms/piece-type/index.js +3 -1
  42. package/modules/@apostrophecms/piece-type/ui/apos/components/AposDocsManager.vue +1 -0
  43. package/modules/@apostrophecms/piece-type/ui/apos/components/AposDocsManagerDisplay.vue +5 -0
  44. package/modules/@apostrophecms/recently-edited/index.js +831 -0
  45. package/modules/@apostrophecms/recently-edited/ui/apos/components/AposCellTitle.vue +54 -0
  46. package/modules/@apostrophecms/recently-edited/ui/apos/components/AposRecentlyEditedCombo.vue +454 -0
  47. package/modules/@apostrophecms/recently-edited/ui/apos/components/AposRecentlyEditedFilterTag.vue +75 -0
  48. package/modules/@apostrophecms/recently-edited/ui/apos/components/AposRecentlyEditedFilters.vue +287 -0
  49. package/modules/@apostrophecms/recently-edited/ui/apos/components/AposRecentlyEditedIcon.vue +16 -0
  50. package/modules/@apostrophecms/recently-edited/ui/apos/components/AposRecentlyEditedManager.vue +346 -0
  51. package/modules/@apostrophecms/recently-edited/ui/apos/composables/useRecentlyEditedBatch.js +193 -0
  52. package/modules/@apostrophecms/recently-edited/ui/apos/composables/useRecentlyEditedData.js +276 -0
  53. package/modules/@apostrophecms/recently-edited/ui/apos/composables/useRecentlyEditedFetch.js +199 -0
  54. package/modules/@apostrophecms/recently-edited/ui/apos/composables/useRecentlyEditedFilters.js +100 -0
  55. package/modules/@apostrophecms/schema/ui/apos/logic/AposInputRelationship.js +8 -4
  56. package/modules/@apostrophecms/schema/ui/apos/logic/AposInputWrapper.js +1 -1
  57. package/modules/@apostrophecms/styles/index.js +10 -0
  58. package/modules/@apostrophecms/styles/lib/apiRoutes.js +6 -0
  59. package/modules/@apostrophecms/styles/lib/handlers.js +5 -0
  60. package/modules/@apostrophecms/styles/lib/methods.js +9 -3
  61. package/modules/@apostrophecms/styles/lib/presets.js +119 -0
  62. package/modules/@apostrophecms/styles/ui/apos/components/TheAposStyles.vue +3 -8
  63. package/modules/@apostrophecms/styles/ui/apos/composables/AposStyles.js +1 -3
  64. package/modules/@apostrophecms/styles/ui/apos/render-factory.js +29 -0
  65. package/modules/@apostrophecms/styles/ui/apos/universal/backgroundHelpers.mjs +140 -0
  66. package/modules/@apostrophecms/styles/ui/apos/universal/customRules.mjs +105 -0
  67. package/modules/@apostrophecms/styles/ui/apos/universal/render.mjs +195 -15
  68. package/modules/@apostrophecms/template/index.js +22 -6
  69. package/modules/@apostrophecms/ui/ui/apos/components/AposCellContextMenu.vue +2 -0
  70. package/modules/@apostrophecms/ui/ui/apos/components/AposContextMenu.vue +18 -4
  71. package/modules/@apostrophecms/ui/ui/apos/composables/useInfiniteScroll.js +91 -0
  72. package/modules/@apostrophecms/ui/ui/apos/scss/global/_theme.scss +1 -0
  73. package/modules/@apostrophecms/ui/ui/apos/stores/modal.js +5 -2
  74. package/modules/@apostrophecms/ui/ui/apos/utils/index.js +9 -0
  75. package/modules/@apostrophecms/url/index.js +38 -4
  76. package/modules/@apostrophecms/widget-type/index.js +22 -6
  77. package/modules/@apostrophecms/widget-type/ui/apos/components/AposWidgetEditor.vue +8 -4
  78. package/package.json +19 -19
  79. package/test/files.js +129 -0
  80. package/test/layout-widget-migration.js +719 -0
  81. package/test/login-requirements.js +1 -1
  82. package/test/pieces-public-api.js +80 -0
  83. package/test/pieces.js +25 -0
  84. package/test/recently-edited.js +2311 -0
  85. package/test/schemas.js +39 -3
  86. package/test/static-build.js +642 -0
  87. package/test/styles.js +2569 -0
  88. package/modules/@apostrophecms/layout-widget/ui/apos/components/AposLayoutColControlDialog.vue +0 -171
@@ -57,8 +57,37 @@
57
57
  import { klona } from 'klona';
58
58
  import customRules from './customRules.mjs';
59
59
 
60
+ // For legacy reasons this stays the default export.
60
61
  export default renderGlobalStyles;
61
- export { renderGlobalStyles, renderScopedStyles };
62
+ export {
63
+ renderGlobalStyles, renderScopedStyles, createRenderer
64
+ };
65
+
66
+ /**
67
+ * Creates a renderer with shared options pre-applied.
68
+ * Callers can override per-call options as needed.
69
+ *
70
+ * @param {Object} options - Shared options for all render calls
71
+ * @returns {{ renderGlobalStyles: Function, renderScopedStyles: Function }}
72
+ */
73
+ function createRenderer(options = {}) {
74
+ const memoized = { ...options };
75
+
76
+ return {
77
+ renderGlobalStyles(schema, doc, callOptions = {}) {
78
+ return renderGlobalStyles(schema, doc, {
79
+ ...memoized,
80
+ ...callOptions
81
+ });
82
+ },
83
+ renderScopedStyles(schema, doc, callOptions = {}) {
84
+ return renderScopedStyles(schema, doc, {
85
+ ...memoized,
86
+ ...callOptions
87
+ });
88
+ }
89
+ };
90
+ }
62
91
 
63
92
  // Exported for testing purposes
64
93
  export const NORMALIZERS = {
@@ -67,7 +96,8 @@ export const NORMALIZERS = {
67
96
  };
68
97
  export const EXTRACTORS = {
69
98
  _: extract,
70
- object: extractObject
99
+ object: extractObject,
100
+ customType: extractCustomType
71
101
  };
72
102
  const FILTERS = {
73
103
  _: filter,
@@ -85,7 +115,8 @@ const FILTERS = {
85
115
  * @returns {{ css: string; classes: string[] }} Compiled CSS stylesheet and classes
86
116
  */
87
117
  function renderGlobalStyles(schema, doc, {
88
- checkIfConditionsFn
118
+ checkIfConditionsFn,
119
+ ...engineOptions
89
120
  } = {}) {
90
121
  const withConditions = filterConditionalFields(
91
122
  klona(schema),
@@ -97,7 +128,8 @@ function renderGlobalStyles(schema, doc, {
97
128
  const storage = {
98
129
  classes: new Set(),
99
130
  styles: new Map(),
100
- conditions: withConditions.conditions
131
+ conditions: withConditions.conditions,
132
+ options: engineOptions
101
133
  };
102
134
 
103
135
  for (const field of withConditions.schema) {
@@ -105,6 +137,10 @@ function renderGlobalStyles(schema, doc, {
105
137
  if (!filter(field, doc)) {
106
138
  continue;
107
139
  }
140
+ if (field.customType) {
141
+ extractCustomType(field, doc, { storage });
142
+ continue;
143
+ }
108
144
  const normalizer = NORMALIZERS[field.type] || NORMALIZERS._;
109
145
  const extractor = EXTRACTORS[field.type] || EXTRACTORS._;
110
146
  const normalzied = normalizer(field, doc, {
@@ -135,7 +171,8 @@ function renderGlobalStyles(schema, doc, {
135
171
  function renderScopedStyles(schema, doc, {
136
172
  rootSelector = null,
137
173
  checkIfConditionsFn,
138
- subset = null
174
+ subset = null,
175
+ ...engineOptions
139
176
  } = {}) {
140
177
  const withConditions = filterConditionalFields(
141
178
  klona(schema),
@@ -149,7 +186,8 @@ function renderScopedStyles(schema, doc, {
149
186
  classes: new Set(),
150
187
  styles: new Map(),
151
188
  inlineVotes: new Set(),
152
- conditions: withConditions.conditions
189
+ conditions: withConditions.conditions,
190
+ options: engineOptions
153
191
  };
154
192
 
155
193
  for (const field of withConditions.schema) {
@@ -157,6 +195,13 @@ function renderScopedStyles(schema, doc, {
157
195
  if (!filter(field, doc)) {
158
196
  continue;
159
197
  }
198
+ if (field.customType) {
199
+ extractCustomType(field, doc, {
200
+ rootSelector,
201
+ storage
202
+ });
203
+ continue;
204
+ }
160
205
  const normalizer = NORMALIZERS[field.type] || NORMALIZERS._;
161
206
  const extractor = EXTRACTORS[field.type] || EXTRACTORS._;
162
207
  const normalized = normalizer(field, doc, {
@@ -326,6 +371,9 @@ function filter(field, doc) {
326
371
  if (!doc[field.name] && doc[field.name] !== 0) {
327
372
  return false;
328
373
  }
374
+ if (field.customType) {
375
+ return true;
376
+ }
329
377
  const hasProperty = Array.isArray(field.property)
330
378
  ? field.property.length > 0
331
379
  : !!field.property;
@@ -356,6 +404,9 @@ function filterObject(field, doc) {
356
404
  if (!doc[field.name]) {
357
405
  return false;
358
406
  }
407
+ if (field.customType) {
408
+ return true;
409
+ }
359
410
  if (field.property && field.valueTemplate) {
360
411
  return true;
361
412
  }
@@ -390,17 +441,33 @@ function normalize(field, doc, {
390
441
  const fieldUnit = field.unit || '';
391
442
  const fieldMediaQuery = field.mediaQuery || rootMediaQuery;
392
443
 
393
- if (field.class) {
394
- applyFieldClass(field.class, fieldValue, storage);
444
+ // skipFalsyValues: when enabled and value is falsy, skip the field entirely.
445
+ // No CSS property, no CSS variable, no class toggle — nothing is emitted.
446
+ if (field.skipFalsyValues && !fieldValue) {
395
447
  return {
396
448
  raw: field,
397
- selectors,
398
- properties,
399
- value: fieldValue,
449
+ selectors: [],
450
+ properties: [],
451
+ value: null,
400
452
  unit: ''
401
453
  };
402
454
  }
403
455
 
456
+ if (field.class) {
457
+ applyFieldClass(field.class, fieldValue, storage);
458
+ // When the field also has `property`, continue to generate CSS output.
459
+ // Otherwise return early (class-only field, preserving existing behavior).
460
+ if (!field.property) {
461
+ return {
462
+ raw: field,
463
+ selectors,
464
+ properties,
465
+ value: fieldValue,
466
+ unit: ''
467
+ };
468
+ }
469
+ }
470
+
404
471
  if (!properties) {
405
472
  properties = [];
406
473
  }
@@ -485,9 +552,11 @@ function normalizeObject(field, doc, {
485
552
  });
486
553
  delete normalized.unit;
487
554
 
488
- const schema = field.schema.filter(subfield => {
489
- return filter(subfield, doc[field.name] || {});
490
- });
555
+ const schema = field.customType
556
+ ? field.schema
557
+ : field.schema.filter(subfield => {
558
+ return filter(subfield, doc[field.name] || {});
559
+ });
491
560
 
492
561
  for (const subfield of schema) {
493
562
  subfields.push(
@@ -518,7 +587,7 @@ function normalizeObject(field, doc, {
518
587
  * @param {RuntimeStorage} storage
519
588
  */
520
589
  function extract(normalized, storage) {
521
- if (normalized.class) {
590
+ if (normalized.class && normalized.properties.length === 0) {
522
591
  return;
523
592
  }
524
593
  const styles = storage.styles;
@@ -570,6 +639,117 @@ function extract(normalized, storage) {
570
639
  });
571
640
  }
572
641
 
642
+ /**
643
+ * Dispatch a field with a `customType` to the matching custom rule.
644
+ * The custom rule is authoritative — the field doesn't flow through
645
+ * the standard normalize → extract pipeline.
646
+ *
647
+ * For object fields: normalizes with normalizeObject(), builds a subfield
648
+ * map, and lets the rule do partial processing (remaining subfields
649
+ * continue through extract()).
650
+ *
651
+ * For non-object fields: normalizes normally, passes an empty subfield
652
+ * map, and the rule is fully responsible for CSS output.
653
+ *
654
+ * Custom rules may return `inline: false` to explicitly opt out of
655
+ * inline styles when their output requires scoped CSS (e.g.
656
+ * pseudo-element rules). When absent, the field-level vote stands.
657
+ *
658
+ * @param {SchemaField} field
659
+ * @param {Object} doc
660
+ * @param {Object} opts
661
+ * @param {String} [opts.rootSelector]
662
+ * @param {RuntimeStorage} opts.storage
663
+ */
664
+ function extractCustomType(field, doc, { rootSelector, storage } = {}) {
665
+ const ruleName = field.customType;
666
+ const rule = customRules[ruleName];
667
+ if (!rule) {
668
+
669
+ console.error(
670
+ `[styles] Unknown customType "${ruleName}" on field "${field.name}". ` +
671
+ 'The field will be skipped. Available types: ' +
672
+ Object.keys(customRules).join(', ')
673
+ );
674
+ return;
675
+ }
676
+
677
+ const isObject = field.type === 'object';
678
+ const normalizer = isObject ? normalizeObject : normalize;
679
+ const normalized = normalizer(field, doc, {
680
+ rootSelector,
681
+ storage
682
+ });
683
+
684
+ const subfieldMap = {};
685
+ if (isObject && normalized.subfields) {
686
+ for (const sub of normalized.subfields) {
687
+ subfieldMap[sub.raw.name] = sub;
688
+ }
689
+ }
690
+
691
+ const result = rule({
692
+ field: normalized,
693
+ subfields: subfieldMap,
694
+ options: storage.options || {}
695
+ });
696
+ const processed = new Set(result.processedFields || []);
697
+
698
+ if (result.inline === false && storage.inlineVotes) {
699
+ storage.inlineVotes.add(false);
700
+ }
701
+
702
+ // Add the custom rule's CSS output to storage
703
+ if (result.rules?.length) {
704
+ for (const selector of normalized.selectors) {
705
+ let currentStyles = storage.styles;
706
+ if (normalized.mediaQuery) {
707
+ const mediaQuery = `@media ${normalized.mediaQuery}`;
708
+ storage.styles.set(mediaQuery, storage.styles.get(mediaQuery) || new Map());
709
+ currentStyles = storage.styles.get(mediaQuery);
710
+ }
711
+ for (const r of result.rules) {
712
+ currentStyles.set(
713
+ selector, (currentStyles.get(selector) || new Set()).add(r)
714
+ );
715
+ }
716
+ }
717
+ }
718
+
719
+ // Add media-scoped rules from the custom rule (e.g. responsive image breakpoints).
720
+ // Media rules require scoped CSS, so automatically opt out of inline styles.
721
+ if (result.mediaRules?.length) {
722
+ if (storage.inlineVotes) {
723
+ storage.inlineVotes.add(false);
724
+ }
725
+ for (const mr of result.mediaRules) {
726
+ const mediaQuery = `@media ${mr.query}`;
727
+ storage.styles.set(mediaQuery, storage.styles.get(mediaQuery) || new Map());
728
+ const mediaStyles = storage.styles.get(mediaQuery);
729
+ for (const selector of normalized.selectors) {
730
+ for (const r of mr.rules) {
731
+ mediaStyles.set(
732
+ selector, (mediaStyles.get(selector) || new Set()).add(r)
733
+ );
734
+ }
735
+ }
736
+ }
737
+ }
738
+
739
+ // For object fields: continue processing subfields NOT consumed by the rule.
740
+ // Filter before extract — normalizeObject deliberately skips filtering for
741
+ // customType so the rule receives the full schema. Leftover subfields must
742
+ // pass the same filter the main loop applies to avoid emitting broken CSS.
743
+ if (isObject && normalized.subfields) {
744
+ const subdoc = doc[field.name] || {};
745
+ for (const sub of normalized.subfields) {
746
+ if (!processed.has(sub.raw.name) && filter(sub.raw, subdoc)) {
747
+ extract(sub, storage);
748
+ }
749
+ }
750
+ }
751
+ }
752
+
573
753
  /**
574
754
  * Extract CSS rules from a normalized object field and populate the central
575
755
  * styles map with the selectors and corresponding rules.
@@ -35,6 +35,7 @@ const path = require('path');
35
35
  const { stripIndent } = require('common-tags');
36
36
  const { SemanticAttributes } = require('@opentelemetry/semantic-conventions');
37
37
  const voidElements = require('void-elements');
38
+ const safeJsonForScript = require('../../../lib/safe-json-script');
38
39
 
39
40
  module.exports = {
40
41
  options: { alias: 'template' },
@@ -1140,12 +1141,23 @@ module.exports = {
1140
1141
  // attrs: { href: '/some/path', rel: 'stylesheet' }
1141
1142
  // }
1142
1143
  // ]
1143
- // Node object SHOULD have either `name`, `text`, `raw` or `comment` property.
1144
- // A node with `name` can have `attrs` (array of element attributes)
1145
- // and `body` (array of child nodes, recursion).
1144
+ // Node object SHOULD have either `name`, `text`, `raw`, `json` or
1145
+ // `comment` property. A node with `name` can have `attrs` (array of
1146
+ // element attributes) and `body` (array of child nodes, recursion).
1146
1147
  // `text` nodes are rendered as text (no HTML tags), the value is always a string.
1147
1148
  // `comment` nodes are rendered as HTML comments, the value is always a string.
1148
1149
  // `raw` nodes are rendered as is, no escaping, the value is always a string.
1150
+ // `json` nodes are rendered as a JSON serialization of the value,
1151
+ // safely escaped for inclusion inside a `<script>` element so that
1152
+ // untrusted content cannot break out of the surrounding script tag.
1153
+ // Use this instead of building a `raw` body from `JSON.stringify()`
1154
+ // yourself, e.g.:
1155
+ //
1156
+ // {
1157
+ // name: 'script',
1158
+ // attrs: { type: 'application/ld+json' },
1159
+ // body: [ { json: myData } ]
1160
+ // }
1149
1161
  renderNodes(nodes) {
1150
1162
  if (!Array.isArray(nodes)) {
1151
1163
  self.logError(
@@ -1164,6 +1176,9 @@ module.exports = {
1164
1176
  if (node.raw != null) {
1165
1177
  return node.raw;
1166
1178
  }
1179
+ if (node.json != null) {
1180
+ return safeJsonForScript(node.json);
1181
+ }
1167
1182
  if (node.name != null) {
1168
1183
  const name = self.apos.util.escapeHtml(node.name);
1169
1184
  const attrs = Object.entries(node.attrs || {})
@@ -1252,9 +1267,10 @@ module.exports = {
1252
1267
  return data;
1253
1268
  },
1254
1269
 
1255
- pruneDataForExternalFront(req, template, data, moduleName) {
1256
- return data;
1257
- },
1270
+ // An opportunity to modify `data` IN PLACE to send less data to
1271
+ // the external front, e.g. Astro. Invoked immediately before
1272
+ // data is sent to Astro
1273
+ pruneDataForExternalFront(req, template, data, moduleName) {},
1258
1274
 
1259
1275
  getDocsForExternalFront(req, template, data, moduleName) {
1260
1276
  return [
@@ -16,6 +16,8 @@
16
16
  :show-restore="options.showRestore"
17
17
  :show-dismiss-submission="options.showDismissSubmission"
18
18
  :can-delete-draft="options.canDeleteDraft"
19
+ :show-unpublish="options.showUnpublish"
20
+ :cross-locale="options.crossLocale || false"
19
21
  @menu-open="menuOpen = true"
20
22
  @menu-close="menuOpen = false"
21
23
  />
@@ -41,7 +41,7 @@
41
41
  class="apos-context-menu__dropdown-content"
42
42
  :class="popoverClass"
43
43
  tabindex="0"
44
- @keyup.tab="onKeyup"
44
+ @keydown.tab="onKeydownTab"
45
45
  @keyup.esc="onKeyup"
46
46
  @keyup.enter="onKeyup"
47
47
  >
@@ -75,7 +75,7 @@
75
75
  ...popoverClass
76
76
  ]"
77
77
  tabindex="0"
78
- @keyup.tab="onKeyup"
78
+ @keydown.tab="onKeydownTab"
79
79
  @keyup.esc="onKeyup"
80
80
  @keyup.enter="onKeyup"
81
81
  >
@@ -559,11 +559,26 @@ const ignoreInputTypes = [
559
559
  'week'
560
560
  ];
561
561
 
562
+ // Handle Tab on keydown — before the browser moves focus.
563
+ // At keydown time document.activeElement is the element the user
564
+ // is on (the source), and preventDefault() can stop the browser's
565
+ // default focus movement.
566
+ function onKeydownTab(event) {
567
+ if (!isOpen.value || modalDepth.value !== modalStore.getDepth()) {
568
+ return;
569
+ }
570
+ if (event.target?.nodeName?.toLowerCase() === 'textarea') {
571
+ return;
572
+ }
573
+ event.stopImmediatePropagation();
574
+ onTab(event);
575
+ }
576
+
562
577
  /**
563
578
  * @param {KeyboardEvent} event
564
579
  */
565
580
  function onKeyup(event) {
566
- if (modalDepth.value !== modalStore.getDepth() || !isOpen.value) {
581
+ if (!isOpen.value || modalDepth.value !== modalStore.getDepth()) {
567
582
  return;
568
583
  }
569
584
 
@@ -572,7 +587,6 @@ function onKeyup(event) {
572
587
 
573
588
  if (event.key === 'Tab' && target?.nodeName?.toLowerCase() !== 'textarea') {
574
589
  event.stopImmediatePropagation();
575
- onTab(event);
576
590
  return;
577
591
  }
578
592
 
@@ -0,0 +1,91 @@
1
+ import { onBeforeUnmount, unref } from 'vue';
2
+
3
+ /**
4
+ * @typedef {import('vue').Ref<HTMLElement>} ElementRef
5
+ */
6
+
7
+ /**
8
+ * Reactive infinite scroll via IntersectionObserver.
9
+ * Observes a sentinel element and calls `onLoadMore` when it enters
10
+ * the visible area of the scroll container.
11
+ * Automatically disconnects the observer on component unmount.
12
+ * Handles edge cases when the results are shorter than the viewport,
13
+ * with exposed `recheck()` method to force a fresh intersection check after
14
+ * content changes. See `AposRecentlyEditedManager.vue` for an implementation
15
+ * of infinite loading with rechecks after appending items.
16
+ *
17
+ * @param {ElementRef} sentinel
18
+ * Template ref for the observed element
19
+ * @param {() => Promise<void>|void} onLoadMore
20
+ * Called when sentinel is visible; the caller is responsible
21
+ * for its own guards (e.g. "already loading")
22
+ * @param {{ rootMargin?: string, root?: ElementRef|string }} [options]
23
+ * `root`: Ref to the scroll container element, or a CSS selector
24
+ * string resolved relative to the sentinel's ancestors.
25
+ * Defaults to the viewport when omitted.
26
+ * @returns {{ start: () => void, stop: () => void }}
27
+ */
28
+ export function useInfiniteScroll(sentinel, onLoadMore, options = {}) {
29
+ const { rootMargin = '100px', root = null } = options;
30
+ let observer = null;
31
+
32
+ function handleIntersect(entries) {
33
+ if (entries[0]?.isIntersecting) {
34
+ onLoadMore();
35
+ }
36
+ }
37
+
38
+ function resolveRoot(sentinelEl) {
39
+ const raw = unref(root);
40
+ if (raw instanceof HTMLElement) {
41
+ return raw;
42
+ }
43
+ // CSS selector: walk up from sentinel to find the scroll container.
44
+ if (typeof raw === 'string' && sentinelEl) {
45
+ return sentinelEl.closest(raw) || null;
46
+ }
47
+ return null;
48
+ }
49
+
50
+ function start() {
51
+ stop();
52
+ const el = unref(sentinel);
53
+ if (!el) {
54
+ return;
55
+ }
56
+ const rootEl = resolveRoot(el);
57
+ observer = new IntersectionObserver(handleIntersect, {
58
+ root: rootEl,
59
+ rootMargin,
60
+ threshold: 0
61
+ });
62
+ observer.observe(el);
63
+ }
64
+
65
+ function stop() {
66
+ if (observer) {
67
+ observer.disconnect();
68
+ observer = null;
69
+ }
70
+ }
71
+
72
+ onBeforeUnmount(stop);
73
+
74
+ // Force a fresh intersection check. Useful after content changes
75
+ // (e.g. items appended) when the sentinel may already be visible
76
+ // but no state transition occurred.
77
+ function recheck() {
78
+ const el = unref(sentinel);
79
+ if (!observer || !el) {
80
+ return;
81
+ }
82
+ observer.unobserve(el);
83
+ observer.observe(el);
84
+ }
85
+
86
+ return {
87
+ start,
88
+ stop,
89
+ recheck
90
+ };
91
+ }
@@ -39,6 +39,7 @@
39
39
  --a-background-inverted: #323232;
40
40
  --a-overlay: #1f004ce0;
41
41
  --a-overlay-modal: #0202025e;
42
+ --a-background-overlay: #ffffff80;
42
43
 
43
44
  // Universal
44
45
  --a-white: #fff;
@@ -128,6 +128,9 @@ export const useModalStore = defineStore('modal', () => {
128
128
  props = await apos.ui.transformers[transformer](props);
129
129
  }
130
130
  return new Promise((resolve) => {
131
+ const modalLocale = props?.locale ||
132
+ activeModal.value?.locale ||
133
+ apos.i18n.locale;
131
134
  const item = {
132
135
  id: `modal:${createId()}`,
133
136
  componentName,
@@ -135,11 +138,11 @@ export const useModalStore = defineStore('modal', () => {
135
138
  props: props || {},
136
139
  elementsToFocus: [],
137
140
  focusedElement: null,
138
- locale: activeModal.value?.locale || apos.i18n.locale,
141
+ locale: modalLocale,
139
142
  hasContextLocale: activeModal.value
140
143
  ? (activeModal.value.hasContextLocale ||
141
144
  activeModal.value.locale !== apos.i18n.locale)
142
- : false
145
+ : modalLocale !== apos.i18n.locale
143
146
  };
144
147
 
145
148
  activeId.value = item.id;
@@ -176,6 +176,13 @@ class Queue {
176
176
  });
177
177
  }
178
178
 
179
+ clear() {
180
+ const pending = this.queue.splice(0);
181
+ for (const item of pending) {
182
+ item.reject(new Error('queue:cleared'));
183
+ }
184
+ }
185
+
179
186
  async run() {
180
187
  if (this.running) {
181
188
  return;
@@ -216,6 +223,7 @@ class Queue {
216
223
  * ```
217
224
  * @returns {{
218
225
  * add: <T>(task: () => Promise<T>) => Promise<T>,
226
+ * clear: () => void,
219
227
  * count: () => number,
220
228
  * hasTasks: () => boolean
221
229
  * }}
@@ -224,6 +232,7 @@ function asyncTaskQueue() {
224
232
  const queue = new Queue();
225
233
  return {
226
234
  add: (task) => queue.add(task),
235
+ clear: () => queue.clear(),
227
236
  count: () => queue.count(),
228
237
  hasTasks: () => queue.hasTasks()
229
238
  };
@@ -129,7 +129,27 @@ module.exports = {
129
129
  // without the prefix. This is the legacy behavior of
130
130
  // `apos.page.getBaseUrl(req)` before the delegation to this
131
131
  // method.
132
- getBaseUrl(req, { strict = false, prefix = true } = {}) {
132
+ //
133
+ // ### `options.relative`
134
+ //
135
+ // When `true`, the returned URL is relative and prefix-qualified.
136
+ // The `prefix` option and i18n hosts are ignored in this case.
137
+ //
138
+ // ### `options.localePrefix`
139
+ //
140
+ // When `true`, the locale prefix (e.g. `/fr`) is appended
141
+ // after the global prefix. Defaults to `false` for
142
+ // backward compatibility.
143
+ getBaseUrl(req, {
144
+ strict = false, prefix = true, relative = false,
145
+ localePrefix = false
146
+ } = {}) {
147
+ if (relative) {
148
+ const result = self.apos.prefix || '';
149
+ return localePrefix
150
+ ? result + (self.apos.i18n.locales[req.locale]?.prefix || '')
151
+ : result;
152
+ }
133
153
  const hostname = self.apos.i18n.locales?.[req.locale]?.hostname;
134
154
  if (hostname) {
135
155
  // Locale hostnames are fully qualified origins;
@@ -137,13 +157,16 @@ module.exports = {
137
157
  return `${req.protocol}://${hostname}`;
138
158
  }
139
159
  const aposPrefix = prefix ? (self.apos.prefix || '') : '';
160
+ const lPrefix = localePrefix
161
+ ? (self.apos.i18n.locales[req.locale]?.prefix || '')
162
+ : '';
140
163
  if (self.isStaticBuild(req)) {
141
164
  const staticUrl = req.staticBaseUrl || '';
142
165
  if (staticUrl || !strict) {
143
- return staticUrl + aposPrefix;
166
+ return staticUrl + aposPrefix + lPrefix;
144
167
  }
145
168
  }
146
- return (self.apos.baseUrl || '') + aposPrefix;
169
+ return (self.apos.baseUrl || '') + aposPrefix + lPrefix;
147
170
  },
148
171
 
149
172
  // Build filter URLs. `data` is an object whose properties
@@ -362,7 +385,7 @@ module.exports = {
362
385
  // attachments: { // null when not requested
363
386
  // uploadsUrl: '/uploads',
364
387
  // results: [
365
- // { _id: 'abc', urls: [{ size?, path }] },
388
+ // { _id: 'abc', urls: [{ size?, path }], base? },
366
389
  // ...
367
390
  // ]
368
391
  // }
@@ -502,6 +525,14 @@ module.exports = {
502
525
  // - `_id` (string): the attachment record ID.
503
526
  // - `urls` (array): `{ size, path }` objects where `path`
504
527
  // is the uploadfs-relative file path.
528
+ // - `base` (string, optional): when present, overrides the
529
+ // global `uploadsUrl` for this entry. Set by
530
+ // `@apostrophecms/file.applyPrettyUrlPaths` for file
531
+ // pieces with pretty URLs enabled. The value is a
532
+ // relative, prefix-qualified path (e.g. `/files` or
533
+ // `/cms/files`). Consumers should use
534
+ // `entry.base || attachments.uploadsUrl` as the download
535
+ // and output base for each entry.
505
536
  //
506
537
  // After attachment metadata is collected, the
507
538
  // `@apostrophecms/url:getAllAttachmentMetadata` event is
@@ -563,6 +594,9 @@ module.exports = {
563
594
  skipSizes
564
595
  })
565
596
  };
597
+ await self.apos.file.applyPrettyUrlPaths(
598
+ req, response.attachments
599
+ );
566
600
  await self.emit(
567
601
  'getAllAttachmentMetadata',
568
602
  req,