apostrophe 4.27.1 → 4.28.1

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 (57) hide show
  1. package/.claude/settings.local.json +15 -0
  2. package/CHANGELOG.md +40 -0
  3. package/README.md +142 -0
  4. package/index.js +3 -0
  5. package/lib/stream-proxy.js +49 -0
  6. package/modules/@apostrophecms/admin-bar/ui/apos/components/TheAposContextTitle.vue +2 -11
  7. package/modules/@apostrophecms/area/ui/apos/apps/AposAreas.js +38 -6
  8. package/modules/@apostrophecms/area/ui/apos/components/AposAreaEditor.vue +12 -1
  9. package/modules/@apostrophecms/area/ui/apos/components/AposAreaWidget.vue +111 -41
  10. package/modules/@apostrophecms/area/ui/apos/components/AposBreadcrumbOperations.vue +1 -0
  11. package/modules/@apostrophecms/area/ui/apos/components/AposWidgetControls.vue +22 -10
  12. package/modules/@apostrophecms/area/ui/apos/logic/AposAreaEditor.js +40 -0
  13. package/modules/@apostrophecms/asset/index.js +3 -2
  14. package/modules/@apostrophecms/attachment/index.js +270 -0
  15. package/modules/@apostrophecms/doc/index.js +8 -2
  16. package/modules/@apostrophecms/doc-type/index.js +81 -1
  17. package/modules/@apostrophecms/doc-type/ui/apos/components/AposDocEditor.vue +18 -2
  18. package/modules/@apostrophecms/express/index.js +30 -1
  19. package/modules/@apostrophecms/file/index.js +70 -6
  20. package/modules/@apostrophecms/i18n/index.js +20 -1
  21. package/modules/@apostrophecms/image/index.js +11 -0
  22. package/modules/@apostrophecms/layout-widget/ui/apos/components/AposAreaLayoutEditor.vue +31 -6
  23. package/modules/@apostrophecms/layout-widget/ui/apos/components/AposGridLayout.vue +12 -10
  24. package/modules/@apostrophecms/login/index.js +43 -11
  25. package/modules/@apostrophecms/modal/ui/apos/components/AposDocsManagerToolbar.vue +2 -1
  26. package/modules/@apostrophecms/modal/ui/apos/components/AposModal.vue +5 -0
  27. package/modules/@apostrophecms/page/index.js +9 -11
  28. package/modules/@apostrophecms/page-type/index.js +6 -1
  29. package/modules/@apostrophecms/piece-page-type/index.js +100 -13
  30. package/modules/@apostrophecms/rich-text-widget/ui/apos/components/AposImageControlDialog.vue +1 -0
  31. package/modules/@apostrophecms/rich-text-widget/ui/apos/components/AposRichTextWidgetEditor.vue +28 -12
  32. package/modules/@apostrophecms/rich-text-widget/ui/apos/components/AposTiptapLink.vue +1 -0
  33. package/modules/@apostrophecms/schema/ui/apos/components/AposSearchList.vue +1 -1
  34. package/modules/@apostrophecms/styles/lib/apiRoutes.js +25 -5
  35. package/modules/@apostrophecms/styles/lib/handlers.js +19 -0
  36. package/modules/@apostrophecms/styles/lib/methods.js +35 -12
  37. package/modules/@apostrophecms/styles/ui/apos/components/TheAposStyles.vue +7 -2
  38. package/modules/@apostrophecms/task/index.js +9 -1
  39. package/modules/@apostrophecms/template/views/outerLayoutBase.html +3 -0
  40. package/modules/@apostrophecms/ui/index.js +2 -0
  41. package/modules/@apostrophecms/ui/ui/apos/components/AposButtonGroup.vue +1 -1
  42. package/modules/@apostrophecms/ui/ui/apos/components/AposContextMenu.vue +5 -0
  43. package/modules/@apostrophecms/ui/ui/apos/components/AposContextMenuDialog.vue +5 -0
  44. package/modules/@apostrophecms/ui/ui/apos/lib/vue.js +2 -0
  45. package/modules/@apostrophecms/ui/ui/apos/stores/widget.js +12 -7
  46. package/modules/@apostrophecms/ui/ui/apos/stores/widgetGraph.js +461 -0
  47. package/modules/@apostrophecms/ui/ui/apos/universal/graph.js +452 -0
  48. package/modules/@apostrophecms/ui/ui/apos/universal/widgetGraph.js +10 -0
  49. package/modules/@apostrophecms/uploadfs/index.js +15 -1
  50. package/modules/@apostrophecms/url/index.js +419 -1
  51. package/package.json +5 -5
  52. package/test/add-missing-schema-fields-project/node_modules/.package-lock.json +131 -0
  53. package/test/external-front.js +1 -0
  54. package/test/files.js +264 -0
  55. package/test/login-requirements.js +145 -3
  56. package/test/static-build.js +2701 -0
  57. package/test/universal-graph.js +1135 -0
@@ -1,5 +1,3 @@
1
- const _ = require('lodash');
2
-
3
1
  // A subclass of `@apostrophecms/piece-type`, `@apostrophecms/file` establishes
4
2
  // a library of uploaded files, which may be of any type acceptable to the
5
3
  // [@apostrophecms/attachment](../@apostrophecms/attachment/index.html) module.
@@ -8,6 +6,8 @@ const _ = require('lodash');
8
6
  // module provides a simple way to add downloadable PDFs and the like to a
9
7
  // website, and to manage a library of them for reuse.
10
8
 
9
+ const streamProxy = require('../../../lib/stream-proxy.js');
10
+
11
11
  module.exports = {
12
12
  extend: '@apostrophecms/piece-type',
13
13
  options: {
@@ -26,7 +26,9 @@ module.exports = {
26
26
  // Files should by default be considered "related documents" when localizing
27
27
  // another document that references them
28
28
  relatedDocument: true,
29
- relationshipSuggestionIcon: 'file-document-icon'
29
+ relationshipSuggestionIcon: 'file-document-icon',
30
+ prettyUrls: false,
31
+ prettyUrlDir: '/files'
30
32
  },
31
33
  fields: {
32
34
  remove: [ 'visibility' ],
@@ -92,10 +94,72 @@ module.exports = {
92
94
  },
93
95
  methods(self) {
94
96
  return {
97
+ // File docs are attachment containers themselves — their
98
+ // attachments are discovered via relationship walking from
99
+ // the content docs that reference them. Iterating all
100
+ // published files here would make the "used" scope
101
+ // equivalent to "all", defeating scoped attachment builds.
102
+ async getAllUrlMetadata() {
103
+ return {
104
+ metadata: [],
105
+ attachmentDocIds: []
106
+ };
107
+ },
95
108
  addUrls(req, files) {
96
- _.each(files, function (file) {
97
- file._url = self.apos.attachment.url(file.attachment);
98
- });
109
+ for (const file of files) {
110
+ // Watch out for projections with no attachment property
111
+ // (the slug-taken route does that)
112
+ if (self.options.prettyUrls && file.attachment) {
113
+ const { extension } = file.attachment;
114
+ file._url = `${req.prefix}${self.options.prettyUrlDir}/${file.slug.replace(self.options.slugPrefix || '', '')}.${extension}`;
115
+ file.attachment._prettyUrl = file._url;
116
+ } else {
117
+ file._url = self.apos.attachment.url(file.attachment);
118
+ }
119
+ }
120
+ }
121
+ };
122
+ },
123
+ routes(self) {
124
+ if (!self.options.prettyUrls) {
125
+ return;
126
+ }
127
+ return {
128
+ get: {
129
+ async [`${self.options.prettyUrlDir}/*`](req, res) {
130
+ try {
131
+ const matches = (req.params[0] || '').match(/^([^.]+)\.\w+$/);
132
+ if (!matches) {
133
+ return res.status(400).send('invalid');
134
+ }
135
+ const [ , slug ] = matches;
136
+ if (slug.includes('..') || slug.includes('/')) {
137
+ return res.status(403).send('forbidden');
138
+ }
139
+ const file = await self.find(req, {
140
+ slug: `${self.options.slugPrefix}${slug}`
141
+ }).toObject();
142
+ if (!file) {
143
+ return res.status(404).send('not found');
144
+ }
145
+
146
+ // Determine the normal, "ugly" URL and stream the
147
+ // response from it, passing on the most important
148
+ // headers. Supporting range requests, which PDF viewers
149
+ // like to use for pagination, was considered but
150
+ // the potential interacts with gzip encoding are complex
151
+ // and we currently do use it by default with s3 for PDFs.
152
+ // Viewers will still display the document after
153
+ // completing the download
154
+ const uglyUrl = self.apos.attachment.url(file.attachment, {
155
+ prettyUrl: false
156
+ });
157
+ return await streamProxy(req, uglyUrl, { error: self.apos.util.error });
158
+ } catch (e) {
159
+ self.apos.util.error('Error in pretty URL route:', e);
160
+ return res.status(500).send('error');
161
+ }
162
+ }
99
163
  }
100
164
  };
101
165
  }
@@ -222,7 +222,26 @@ module.exports = {
222
222
  const doc = localizations.find(doc => doc.aposLocale.split(':')[0] === name);
223
223
  if (doc && self.apos.permission.can(req, 'view', doc)) {
224
224
  doc.available = true;
225
- doc._url = `${self.apos.prefix}${manager.action}/${context._id}/locale/${name}`;
225
+ // WARNING: the `addUrls` call below has a serious
226
+ // performance impact (extra DB queries per locale).
227
+ // Keep this gated for static builds only.
228
+ if (self.apos.url.isExternalFront(req) && self.apos.url.options.static) {
229
+ // Static builds can't follow the API redirect route
230
+ // (there is no server to handle it), so compute the
231
+ // real URL using the same mechanisms the query builders
232
+ // would.
233
+ if (self.apos.page.isPage(doc)) {
234
+ doc._url = `${localeReq.prefix}${doc.slug}`;
235
+ } else if (manager.addUrls) {
236
+ await manager.addUrls(localeReq, [ doc ]);
237
+ }
238
+ }
239
+ // Fall back to the API redirect route when no real URL
240
+ // was resolved (e.g. traditional Nunjucks frontend, non static
241
+ // external frontend).
242
+ if (!doc._url) {
243
+ doc._url = `${self.apos.prefix}${manager.action}/${context._id}/locale/${name}`;
244
+ }
226
245
  if (doc._id === context._id) {
227
246
  doc.current = true;
228
247
  }
@@ -400,6 +400,17 @@ module.exports = {
400
400
  },
401
401
  methods(self) {
402
402
  return {
403
+ // Image docs are attachment containers themselves — their
404
+ // attachments are discovered via relationship walking from
405
+ // the content docs that reference them. Iterating all
406
+ // published images here would make the "used" scope
407
+ // equivalent to "all", defeating scoped attachment builds.
408
+ async getAllUrlMetadata() {
409
+ return {
410
+ metadata: [],
411
+ attachmentDocIds: []
412
+ };
413
+ },
403
414
  // This method is available as a template helper: apos.image.first
404
415
  //
405
416
  // Find the first image attachment referenced within an object that may
@@ -4,14 +4,19 @@
4
4
  :data-apos-area="areaId"
5
5
  data-tablet-full="true"
6
6
  class="apos-area"
7
- :class="themeClass"
7
+ :class="[
8
+ themeClass,
9
+ {
10
+ 'apos-area--empty': next.length === 0
11
+ }
12
+ ]"
8
13
  :style="{
9
14
  '--colspan': gridModuleOptions.columns,
10
15
  '--colstart': 1,
11
16
  '--justify': 'stretch',
12
17
  '--align': 'stretch'
13
18
  }"
14
- @click="setFocusedArea(areaId, $event)"
19
+ @click="setFocusedWidget(parentOptions.widgetId, areaId)"
15
20
  >
16
21
  <div
17
22
  v-if="next.length === 0 && !foreign && layoutMode === 'debug'"
@@ -92,6 +97,11 @@
92
97
  :rendering="rendering(widget)"
93
98
  :controls-disabled="true"
94
99
  :breadcrumb-disabled="true"
100
+ :style="{
101
+ 'z-index': raisedWidgets.has(widget._id)
102
+ ? next.length + 1
103
+ : null
104
+ }"
95
105
  @up="up"
96
106
  @down="down"
97
107
  @remove="remove"
@@ -203,12 +213,17 @@ export default {
203
213
  // Intercept the columns focus, and emphasize the layout widget instead.
204
214
  async focusedWidget(widgetId) {
205
215
  if (!widgetId || !this.parentOptions.widgetId) {
216
+ this.switchLayoutMode({
217
+ widgetId: this.parentOptions.widgetId,
218
+ data: { value: 'content' }
219
+ });
220
+ this.setFocusedWidget(null, null);
206
221
  return;
207
222
  }
208
223
 
209
224
  await this.$nextTick();
210
225
  if (widgetId === this.parentOptions.widgetId && this.layoutMode === 'layout') {
211
- this.setFocusedWidget(null, this.areaId);
226
+ apos.bus.$emit('suppress-focused-widget-controls');
212
227
  this.emphasizeGrid();
213
228
  return;
214
229
  }
@@ -218,6 +233,16 @@ export default {
218
233
  } else {
219
234
  this.deEmphasizeGrid();
220
235
  }
236
+
237
+ if (
238
+ !this.layoutColumnWidgetDeepIds.includes(widgetId) &&
239
+ widgetId !== this.parentOptions.widgetId
240
+ ) {
241
+ this.switchLayoutMode({
242
+ widgetId: this.parentOptions.widgetId,
243
+ data: { value: 'content' }
244
+ });
245
+ }
221
246
  },
222
247
  // Steal the columns hover, set it on the layout widget instead.
223
248
  hoveredWidget(widgetId) {
@@ -230,10 +255,9 @@ export default {
230
255
  },
231
256
  layoutMode(newMode) {
232
257
  if (newMode === 'layout') {
233
- this.setFocusedWidget(null, this.areaId);
258
+ apos.bus.$emit('suppress-focused-widget-controls');
234
259
  this.emphasizeGrid();
235
260
  } else {
236
- this.setFocusedWidget(this.parentOptions.widgetId, this.areaId);
237
261
  this.deEmphasizeGrid();
238
262
  }
239
263
  }
@@ -257,7 +281,8 @@ export default {
257
281
  'updateWidget',
258
282
  'setHoveredWidget',
259
283
  'addEmphasizedWidget',
260
- 'removeEmphasizedWidget'
284
+ 'removeEmphasizedWidget',
285
+ 'setFocusedWidget'
261
286
  ]),
262
287
  clickOnGrid() {
263
288
  if (this.parentOptions.widgetId) {
@@ -348,29 +348,31 @@ export default {
348
348
  margin-left: auto;
349
349
  }
350
350
 
351
- &__grid.manage :deep(.apos-area),
352
- &__grid.manage :deep(.apos-empty-area) {
351
+ &__grid.manage .apos-area--empty,
352
+ &__grid.manage .apos-empty-area {
353
353
  padding-top: 0;
354
354
  padding-bottom: 0;
355
355
  }
356
356
 
357
- &__grid.manage :deep(.apos-layout__item-content),
358
- &__grid.manage :deep(.apos-area-widget-wrapper),
359
- &__grid.manage :deep(.apos-area-widget-inner),
360
- &__grid.manage :deep(.apos-area-widget-inner > div),
361
- &__grid.manage :deep(.apos-area),
362
- &__grid.manage :deep(.apos-empty-area) {
357
+ &__grid.manage .apos-layout__item-content,
358
+ &__grid.manage .apos-area-widget-wrapper,
359
+ &__grid.manage .apos-area-widget-inner,
360
+ &__grid.manage .apos-area-widget-rendered-widget > div,
361
+ &__grid.manage .apos-area-widget-rendered-widget,
362
+ &__grid.manage .apos-area,
363
+ &__grid.manage .apos-empty-area {
363
364
  height: 100%;
364
365
  }
365
366
 
366
- &__grid.manage :deep(.apos-empty-area::before) {
367
+ &__grid.manage .apos-empty-area::before {
367
368
  position: absolute;
368
369
  font-family: var(--a-family-default);
370
+ font-size: var(--a-type-base);
369
371
  text-align: center;
370
372
  content: var(--empty-area-text);
371
373
  }
372
374
 
373
- &__grid.manage :deep(.apos-area-menu .apos-button) {
375
+ &__grid.manage .apos-area-menu .apos-button {
374
376
  display: none;
375
377
  }
376
378
 
@@ -61,6 +61,8 @@ module.exports = {
61
61
  localLogin: true,
62
62
  passwordReset: false,
63
63
  passwordResetHours: 48,
64
+ // Maximum time to complete login (1 hour)
65
+ incompleteLifetime: 60 * 60 * 1000,
64
66
  scene: 'apos',
65
67
  csrfExceptions: [
66
68
  'login'
@@ -85,6 +87,7 @@ module.exports = {
85
87
  await self.enableBearerTokens();
86
88
  self.addToAdminBar();
87
89
  self.addCaseInsensitiveMigration();
90
+ self.cleanupInterval = setInterval(self.cleanup, self.options.incompleteLifetime);
88
91
  },
89
92
  handlers(self) {
90
93
  return {
@@ -97,6 +100,14 @@ module.exports = {
97
100
  async checkForUser() {
98
101
  await self.checkForUserAndAlert();
99
102
  }
103
+ },
104
+ 'apostrophe:destroy': {
105
+ clearIntervals() {
106
+ if (self.cleanupInterval) {
107
+ clearInterval(self.cleanupInterval);
108
+ self.cleanupInterval = null;
109
+ }
110
+ }
100
111
  }
101
112
  };
102
113
  },
@@ -725,16 +736,12 @@ module.exports = {
725
736
 
726
737
  const user = await self.deserializeUser(token.userId);
727
738
  if (!user) {
728
- await self.bearerTokens.removeOne({
729
- _id: token.userId
730
- });
739
+ await removeToken();
731
740
  throw self.apos.error('notfound');
732
741
  }
733
742
 
734
743
  if (session) {
735
- await self.bearerTokens.removeOne({
736
- _id: token.userId
737
- });
744
+ await removeToken();
738
745
  await self.passportLogin(req, user);
739
746
  // No access to login attempts in the final phase.
740
747
  self.logInfo(req, 'complete', {
@@ -742,9 +749,12 @@ module.exports = {
742
749
  });
743
750
  } else {
744
751
  delete token.requirementsToVerify;
745
- self.bearerTokens.updateOne(token, {
752
+ await self.bearerTokens.updateOne(token, {
746
753
  $unset: {
747
754
  requirementsToVerify: 1
755
+ },
756
+ $set: {
757
+ expires: Date.now() + self.getBearerTokenLifetime()
748
758
  }
749
759
  });
750
760
  self.logInfo(req, 'complete', {
@@ -754,6 +764,12 @@ module.exports = {
754
764
  token
755
765
  };
756
766
  }
767
+
768
+ async function removeToken() {
769
+ await self.bearerTokens.removeOne({
770
+ _id: token._id
771
+ });
772
+ }
757
773
  },
758
774
 
759
775
  // Implementation detail of the login route and the requirementProps
@@ -765,7 +781,9 @@ module.exports = {
765
781
  _id: self.apos.launder.string(token),
766
782
  requirementsToVerify: {
767
783
  $exists: true,
768
- $ne: []
784
+ $not: {
785
+ $size: 0
786
+ }
769
787
  },
770
788
  expires: {
771
789
  $gte: new Date()
@@ -858,7 +876,7 @@ module.exports = {
858
876
  // Default lifetime of 1 hour is generous to permit situations
859
877
  // like installing a TOTP app for the first time
860
878
  expires: new Date(
861
- new Date().getTime() + (self.options.incompleteLifetime || 60 * 60 * 1000)
879
+ Date.now() + self.options.incompleteLifetime
862
880
  )
863
881
  });
864
882
 
@@ -884,8 +902,8 @@ module.exports = {
884
902
  _id: token,
885
903
  userId: user._id,
886
904
  expires: new Date(
887
- new Date().getTime() +
888
- (self.options.bearerTokens.lifetime || (86400 * 7 * 2)) * 1000
905
+ Date.now() +
906
+ self.getBearerTokenLifetime()
889
907
  )
890
908
  });
891
909
 
@@ -1071,6 +1089,20 @@ module.exports = {
1071
1089
  { failed: duplicatedUsernames }
1072
1090
  );
1073
1091
  }
1092
+ },
1093
+ getBearerTokenLifetime() {
1094
+ return (self.options.bearerTokens.lifetime || (86400 * 7 * 2)) * 1000;
1095
+ },
1096
+ // Periodic cleanup. Expired bearer tokens are only honored until they expire, but
1097
+ // it also makes sense to free the resources after expiration
1098
+ async cleanup() {
1099
+ return self.bearerTokens.deleteMany(
1100
+ {
1101
+ expires: {
1102
+ $lt: new Date()
1103
+ }
1104
+ }
1105
+ );
1074
1106
  }
1075
1107
  };
1076
1108
  },
@@ -48,7 +48,8 @@
48
48
  label,
49
49
  icon,
50
50
  iconOnly: true,
51
- type: 'outline'
51
+ type: 'outline',
52
+ modifiers: ['small']
52
53
  }"
53
54
  :disabled="!checkedCount"
54
55
  :menu="operations"
@@ -13,6 +13,7 @@
13
13
  aria-modal="true"
14
14
  :aria-labelledby="props.modalData.id"
15
15
  data-apos-modal
16
+ :data-apos-graph-key="props.graphKey || undefined"
16
17
  tabindex="0"
17
18
  @focus.capture="captureFocus"
18
19
  @keyup.tab="onKeyup"
@@ -188,6 +189,10 @@ const props = defineProps({
188
189
  modalData: {
189
190
  type: Object,
190
191
  required: true
192
+ },
193
+ graphKey: {
194
+ type: String,
195
+ default: null
191
196
  }
192
197
  });
193
198
 
@@ -2855,18 +2855,16 @@ database.`);
2855
2855
  );
2856
2856
  },
2857
2857
  // Returns the effective base URL for the given request.
2858
- // If Apostrophe's top-level `baseUrl` option is set, or a hostname is
2859
- // defined for the active locale, then that is consulted, otherwise the
2860
- // base URL is the empty string. This makes it easier to build absolute
2861
- // URLs (when `baseUrl` is configured), or to harmlessly prepend the empty
2862
- // string (when it is not configured). The Apostrophe queries used to
2863
- // fetch Apostrophe pages consult this method.
2858
+ // Delegates to `apos.url.getBaseUrl(req)` which is
2859
+ // now the single source of truth.
2860
+ // See `@apostrophecms/url` module for full documentation
2861
+ // and the `strict` option.
2862
+ //
2863
+ // Note: `prefix: false` preserves backward compatibility —
2864
+ // callers of `page.getBaseUrl` historically received only
2865
+ // the origin, without the global prefix.
2864
2866
  getBaseUrl(req) {
2865
- const hostname = self.apos.i18n.locales[req.locale]?.hostname;
2866
-
2867
- return hostname
2868
- ? `${req.protocol}://${hostname}`
2869
- : (self.apos.baseUrl || '');
2867
+ return self.apos.url.getBaseUrl(req, { prefix: false });
2870
2868
  },
2871
2869
 
2872
2870
  // Implements a simple batch operation like publish or unpublish.
@@ -107,12 +107,17 @@ module.exports = {
107
107
  'slug'
108
108
  ]);
109
109
  self.rules = {};
110
- self.dispatchAll();
111
110
  self.composeFilters();
112
111
  self.composeColumns();
113
112
  },
114
113
  handlers(self) {
115
114
  return {
115
+ 'apostrophe:modulesRegistered': {
116
+ dispatchAll() {
117
+ // Late enough that all subclasses have contributed things to self
118
+ self.dispatchAll();
119
+ }
120
+ },
116
121
  '@apostrophecms/page:serve': {
117
122
  async dispatchPage(req) {
118
123
  if (!req.data.bestPage) {
@@ -15,7 +15,7 @@
15
15
  //
16
16
  // ### `piecesFilters`
17
17
  //
18
- // If present, this is an array of objects with `name` properties.This works
18
+ // If present, this is an array of objects with `name` properties. This works
19
19
  // only if the corresponding query builders exist and have a `launder` method.
20
20
  // An array of choices for each is populated in `req.data.piecesFilters`. The
21
21
  // choices in the array are objects with `label` and `value` properties.
@@ -199,6 +199,26 @@ module.exports = {
199
199
 
200
200
  dispatchAll() {
201
201
  self.dispatch('/', self.indexPage);
202
+ if (self.apos.url.options.static) {
203
+ // 1. SEO friendly pagination URLs
204
+ self.dispatch('/page/:pagenum', req => {
205
+ req.query.page = req.params.pagenum;
206
+ return self.indexPage(req);
207
+ });
208
+ for (const filter of self.piecesFilters) {
209
+ // 2. SEO friendly filter URLs
210
+ self.dispatch(`/${filter.name}/:filterValue`, req => {
211
+ req.query[filter.name] = req.params.filterValue;
212
+ return self.indexPage(req);
213
+ });
214
+ // 3. SEO friendly filter + pagination URLs
215
+ self.dispatch(`/${filter.name}/:filterValue/page/:pagenum`, req => {
216
+ req.query[filter.name] = req.params.filterValue;
217
+ req.query.page = req.params.pagenum;
218
+ return self.indexPage(req);
219
+ });
220
+ }
221
+ }
202
222
  self.dispatch('/:slug', self.showPage);
203
223
  },
204
224
 
@@ -325,30 +345,62 @@ module.exports = {
325
345
  return !!(await self.find(req, {}).areas(false).relationships(false).toObject());
326
346
  },
327
347
 
328
- // Populate `req.data.piecesFilters` with arrays of choice objects,
329
- // with label and value properties, for each filter configured in the
330
- // `piecesFilters` array option. Each filter in that array must have a
331
- // `name` property. Distinct values are fetched for the corresponding
332
- // query builder (note that most schema fields automatically get a
333
- // corresponding query builder method). Each filter's choices are
334
- // reduced by the other filters; for instance, "tags" might only reveal
348
+ // Populate `req.data.filters` based on the `piecesFilters` option.
349
+ // Each entry in the option array must have a `name` property, and a
350
+ // corresponding query builder with a `launder` method must exist
351
+ // (note that most schema fields automatically get one).
352
+ //
353
+ // `req.data.filters` is an array of filter objects, each containing
354
+ // the original configuration properties (e.g. `name`, `counts`) plus
355
+ // a `choices` array. Each choice has `label`, `value`, `_url`, and
356
+ // optionally `active` and `count` properties. Choices are reduced by
357
+ // the other active filters; for instance, "tags" might only reveal
335
358
  // choices not ruled out by the current "topic" filter setting.
336
359
  //
337
- // If a filter in the array has its `counts` property set to true,
338
- // Apostrophe will supply a `count` property for each distinct value,
339
- // whenever possible. This has a performance impact.
360
+ // If a filter has its `counts` property set to `true`, each choice
361
+ // will also include a `count`. This has a performance impact.
362
+ //
363
+ // Legacy: `req.data.piecesFilters` is also populated as an object
364
+ // keyed by filter name, where each value is that filter's choices
365
+ // array. Still supported for backward compatibility but
366
+ // `req.data.filters` is preferred.
340
367
 
341
368
  async populatePiecesFilters(query) {
342
369
  const req = query.req;
343
- req.data.piecesFilters = req.data.piecesFilters || {};
370
+ const filtersWithChoices = await self.getFiltersWithChoices(query);
371
+ req.data.filters = filtersWithChoices;
372
+ // for bc (less useful)
373
+ req.data.piecesFilters = {};
374
+ for (const filter of filtersWithChoices) {
375
+ req.data.piecesFilters[filter.name] = filter.choices;
376
+ }
377
+ },
378
+
379
+ async getFiltersWithChoices(query, { allCounts = false } = {}) {
380
+ const results = [];
344
381
  for (const filter of self.piecesFilters) {
345
382
  // The choices for each filter should reflect the effect of all
346
383
  // filters except this one (filtering by topic pares down the list of
347
384
  // categories and vice versa)
348
385
  const _query = query.clone();
349
386
  _query[filter.name](undefined);
350
- req.data.piecesFilters[filter.name] = await _query.toChoices(filter.name, _.pick(filter, 'counts'));
387
+ const countsOption = allCounts ? { counts: true } : _.pick(filter, 'counts');
388
+ const choices = await _query.toChoices(filter.name, countsOption);
389
+ for (const choice of choices) {
390
+ if (query.req.data.page) {
391
+ choice._url = query.req.data.page._url +
392
+ self.apos.url.getChoiceFilter(filter.name, choice.value, 1);
393
+ }
394
+ if (query.req.query[filter.name] === choice.value) {
395
+ choice.active = true;
396
+ }
397
+ }
398
+ results.push({
399
+ ...filter,
400
+ choices
401
+ });
351
402
  }
403
+ return results;
352
404
  }
353
405
  };
354
406
 
@@ -368,6 +420,41 @@ module.exports = {
368
420
  } else {
369
421
  return data;
370
422
  }
423
+ },
424
+ async getUrlMetadata(_super, req, doc) {
425
+ const metadata = await _super(req, doc);
426
+ if (!metadata.length) {
427
+ return metadata;
428
+ }
429
+ const [ pm ] = metadata;
430
+ const query = self.indexQuery(req);
431
+ const filters = await self.getFiltersWithChoices(query, { allCounts: true });
432
+
433
+ // 1. Enumerate every filter + choice combination
434
+ for (const filter of filters) {
435
+ for (const choice of filter.choices) {
436
+ const totalPages = Math.max(1, Math.ceil(choice.count / self.perPage));
437
+ for (let p = 1; p <= totalPages; p++) {
438
+ metadata.push({
439
+ ...pm,
440
+ i18nId: `${pm.i18nId}.${self.apos.util.slugify(filter.name)}.${self.apos.util.slugify(choice.value)}.${p}`,
441
+ url: pm.url + self.apos.url
442
+ .getChoiceFilter(filter.name, choice.value, p)
443
+ });
444
+ }
445
+ }
446
+ }
447
+ await query.toCount();
448
+ const totalPages = query.get('totalPages');
449
+ // 2. Enumerate pagination (starting at page 2; page 1 is the base URL)
450
+ for (let p = 2; p <= totalPages; p++) {
451
+ metadata.push({
452
+ ...pm,
453
+ i18nId: `${pm.i18nId}.${p}`,
454
+ url: pm.url + self.apos.url.getPageFilter(p)
455
+ });
456
+ }
457
+ return metadata;
371
458
  }
372
459
  };
373
460
  }
@@ -191,6 +191,7 @@ export default {
191
191
  ...this.schemaHtmlAttributes.reduce((acc, field) => {
192
192
  const value = this.docFields.data[field.name];
193
193
  if (field.type === 'checkboxes' && !value?.[0]) {
194
+ acc[field.htmlAttribute] = null;
194
195
  return acc;
195
196
  }
196
197
  if (field.type === 'boolean') {