apostrophe 4.31.0 → 4.32.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 (33) hide show
  1. package/CHANGELOG.md +48 -1
  2. package/claude-tools/run-mocha-file.sh +29 -0
  3. package/modules/@apostrophecms/area/index.js +14 -5
  4. package/modules/@apostrophecms/area/ui/apos/apps/AposAreas.js +72 -3
  5. package/modules/@apostrophecms/asset/index.js +170 -21
  6. package/modules/@apostrophecms/asset/lib/build/external-module-api.js +7 -16
  7. package/modules/@apostrophecms/asset/lib/build/internals.js +1 -3
  8. package/modules/@apostrophecms/asset/lib/build/task.js +7 -13
  9. package/modules/@apostrophecms/command-menu/ui/apos/components/TheAposCommandMenu.vue +21 -1
  10. package/modules/@apostrophecms/doc-type/index.js +73 -15
  11. package/modules/@apostrophecms/http/index.js +175 -139
  12. package/modules/@apostrophecms/image/index.js +16 -7
  13. package/modules/@apostrophecms/image/ui/apos/components/AposMediaManager.vue +9 -1
  14. package/modules/@apostrophecms/job/index.js +3 -1
  15. package/modules/@apostrophecms/page/index.js +17 -1
  16. package/modules/@apostrophecms/rich-text-widget/ui/apos/components/AposRichTextWidgetEditor.vue +10 -1
  17. package/modules/@apostrophecms/rich-text-widget/ui/apos/components/AposTiptapInsertItem.vue +3 -14
  18. package/modules/@apostrophecms/rich-text-widget/ui/apos/lib/remove-slash.js +18 -0
  19. package/modules/@apostrophecms/schema/ui/apos/components/AposInputDateAndTime.vue +37 -6
  20. package/modules/@apostrophecms/url/index.js +54 -0
  21. package/package.json +11 -12
  22. package/test/asset-external.js +3 -0
  23. package/test/asset-lock-cache.js +243 -0
  24. package/test/assets.js +25 -2
  25. package/test/config-cascade-merge.js +73 -0
  26. package/test/files.js +10 -11
  27. package/test/image-tags.js +103 -0
  28. package/test/modules/collision-piece/index.js +23 -0
  29. package/test/pages-move-permissions.js +317 -0
  30. package/test/pages.js +12 -15
  31. package/test/pieces-public-api-relationship-choices.js +245 -0
  32. package/test/pieces.js +12 -15
  33. package/test-lib/test.js +32 -0
@@ -52,6 +52,7 @@
52
52
  :editor="editor"
53
53
  role="listbox"
54
54
  tabindex="0"
55
+ @keydown="closeInsertMenu"
55
56
  >
56
57
  <div class="apos-rich-text-insert-menu-heading">
57
58
  {{ $t('apostrophe:richTextInsertMenuHeading') }}
@@ -60,7 +61,6 @@
60
61
  class="apos-rich-text-insert-menu-wrapper"
61
62
  @keydown.prevent.arrow-up="focusInsertMenuItem(true)"
62
63
  @keydown.prevent.arrow-down="focusInsertMenuItem()"
63
- @keydown="closeInsertMenu"
64
64
  >
65
65
  <li
66
66
  v-for="(item, index) in insert"
@@ -152,6 +152,7 @@ import merge from 'lodash/merge';
152
152
  import { useAposStyles } from 'Modules/@apostrophecms/styles/composables/AposStyles.js';
153
153
  import { useModalStore } from 'Modules/@apostrophecms/ui/stores/modal';
154
154
  import { useWidgetStore } from 'Modules/@apostrophecms/ui/stores/widget';
155
+ import removeSlash from 'Modules/@apostrophecms/rich-text-widget/lib/remove-slash.js';
155
156
 
156
157
  export default {
157
158
  name: 'AposRichTextWidgetEditor',
@@ -837,6 +838,14 @@ export default {
837
838
  ) {
838
839
  return;
839
840
  }
841
+ if (e.key === 'Backspace') {
842
+ // Don't let the global remove-widget shortcut see this key
843
+ e.preventDefault();
844
+ e.stopPropagation();
845
+ removeSlash(this.editor);
846
+ this.editor.commands.focus();
847
+ return;
848
+ }
840
849
  this.editor.commands.focus();
841
850
  this.activeInsertMenuComponent = false;
842
851
  // Only insert character keys
@@ -44,6 +44,8 @@
44
44
 
45
45
  <script setup>
46
46
  import { ref, computed } from 'vue';
47
+ import removeEditorSlash from 'Modules/@apostrophecms/rich-text-widget/lib/remove-slash.js';
48
+
47
49
  const props = defineProps({
48
50
  name: {
49
51
  type: String,
@@ -87,20 +89,7 @@ function activate() {
87
89
  }
88
90
 
89
91
  function removeSlash() {
90
- const state = props.editor.state;
91
- const { $to } = state.selection;
92
- if (state.selection.empty && $to?.nodeBefore?.text) {
93
- const text = $to.nodeBefore.text;
94
- if (text.slice(-1) === '/') {
95
- const pos = props.editor.view.state.selection.$anchor.pos;
96
- // Select the slash so an insert operation can replace it
97
- props.editor.commands.setTextSelection({
98
- from: pos - 1,
99
- to: pos
100
- });
101
- props.editor.commands.deleteSelection();
102
- }
103
- }
92
+ removeEditorSlash(props.editor);
104
93
  }
105
94
 
106
95
  function closeInsertMenuItem() {
@@ -0,0 +1,18 @@
1
+ // Delete the trailing "/" that opened the rich text insert menu.
2
+
3
+ export default function removeSlash(editor) {
4
+ const state = editor.state;
5
+ const { $to } = state.selection;
6
+ if (state.selection.empty && $to?.nodeBefore?.text) {
7
+ const text = $to.nodeBefore.text;
8
+ if (text.slice(-1) === '/') {
9
+ const pos = editor.view.state.selection.$anchor.pos;
10
+ // Select the slash so an insert operation can replace it
11
+ editor.commands.setTextSelection({
12
+ from: pos - 1,
13
+ to: pos
14
+ });
15
+ editor.commands.deleteSelection();
16
+ }
17
+ }
18
+ }
@@ -24,7 +24,7 @@
24
24
  >
25
25
  <span
26
26
  :id="`${uid}-at`"
27
- class="apos-input--label"
27
+ class="apos-input--label apos-input--at"
28
28
  >
29
29
  {{ $t('apostrophe:at') }}
30
30
  </span>
@@ -50,27 +50,58 @@ export default {
50
50
  </script>
51
51
  <style scoped lang='scss'>
52
52
  .apos-input-wrapper {
53
- display: flex;
53
+ container-type: inline-size;
54
+ display: grid;
55
+ grid-template-columns: auto minmax(0, 1fr);
54
56
  align-items: center;
55
- justify-content: space-around;
57
+ gap: 8px 12px;
56
58
  }
57
59
 
58
60
  .apos-toggle {
59
- margin-right: 12px;
61
+ grid-row: 1;
62
+ grid-column: 1;
60
63
  }
61
64
 
62
65
  .apos-input {
63
66
  padding: 10px;
64
67
 
68
+ &--date,
69
+ &--time {
70
+ grid-column: 2;
71
+ min-width: 0;
72
+ }
73
+
74
+ &--date {
75
+ grid-row: 1;
76
+ }
77
+
78
+ &--time {
79
+ grid-row: 2;
80
+ }
81
+
65
82
  &--disabled {
66
83
  background-color: var(--a-white);
67
84
  border-color: var(--a-base-8);
68
85
  color: var(--a-base-2);
69
86
  }
70
87
 
71
- &--label {
72
- margin: 0 12px;
88
+ &--at {
89
+ grid-row: 2;
90
+ grid-column: 1;
91
+ justify-self: center;
73
92
  font-family: var(--a-family-default);
74
93
  }
75
94
  }
95
+
96
+ @container (min-width: 280px) {
97
+ .apos-input--at {
98
+ grid-row: 1;
99
+ grid-column: 3;
100
+ }
101
+
102
+ .apos-input--time {
103
+ grid-row: 1;
104
+ grid-column: 4;
105
+ }
106
+ }
76
107
  </style>
@@ -58,6 +58,25 @@ module.exports = {
58
58
  };
59
59
  },
60
60
 
61
+ apiRoutes(self) {
62
+ return {
63
+ get: {
64
+ // GET /api/v1/@apostrophecms/url/literal-routes
65
+ //
66
+ // Returns `{ patterns: [ ... ] }`.
67
+ // See the `getLiteralContentRoutes` method.
68
+ 'literal-routes': async (req) => {
69
+ if (!self.isExternalFront(req)) {
70
+ throw self.apos.error('forbidden');
71
+ }
72
+ return {
73
+ patterns: await self.getLiteralContentRoutes(req)
74
+ };
75
+ }
76
+ }
77
+ };
78
+ },
79
+
61
80
  handlers(self) {
62
81
  return {
63
82
  '@apostrophecms/page:beforeSend': {
@@ -94,6 +113,41 @@ module.exports = {
94
113
  return !!req.aposExternalFront;
95
114
  },
96
115
 
116
+ // Returns the list of "literal content" route patterns declared by
117
+ // modules — URLs that serve non-page content (e.g. `/robots.txt`,
118
+ // `/sitemap.xml`, `/sitemaps/*`) and must be proxied raw by an external
119
+ // front rather than routed through its page renderer.
120
+ //
121
+ // Emits `@apostrophecms/url:getLiteralContentRoutes`; handlers in any
122
+ // module push prefix-free path patterns onto the `patterns` array
123
+ // (exact like `/robots.txt`, or glob like `/sitemaps/*`). Patterns are
124
+ // declared unconditionally and independent of `req`: the consumer only
125
+ // needs to know "this path is literal content", and a pattern that 404s
126
+ // is harmless. This differs from `getAllUrlMetadata`, which enumerates
127
+ // exact, existing URLs per locale for static builds and sitemaps.
128
+ //
129
+ // Example module level implementation:
130
+ // ```js
131
+ // handlers(self) {
132
+ // return {
133
+ // '@apostrophecms/url:getLiteralContentRoutes': {
134
+ // addSitemapRoutes(req, patterns) {
135
+ // patterns.push('/sitemap.xml');
136
+ // if (self.options.perLocale) {
137
+ // patterns.push('/sitemaps/*');
138
+ // }
139
+ // }
140
+ // }
141
+ // };
142
+ // }
143
+ // ```
144
+ //
145
+ async getLiteralContentRoutes(req) {
146
+ const patterns = [];
147
+ await self.emit('getLiteralContentRoutes', req, patterns);
148
+ return [ ...new Set(patterns) ];
149
+ },
150
+
97
151
  // Returns the effective base URL for the given request.
98
152
  //
99
153
  // Resolution order:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "apostrophe",
3
- "version": "4.31.0",
3
+ "version": "4.32.0",
4
4
  "description": "The Apostrophe Content Management System.",
5
5
  "main": "index.js",
6
6
  "repository": {
@@ -10,7 +10,7 @@
10
10
  },
11
11
  "homepage": "https://github.com/apostrophecms/apostrophe/tree/main/packages/apostrophe",
12
12
  "engines": {
13
- "node": ">=16.0.0"
13
+ "node": ">=22.0.0"
14
14
  },
15
15
  "keywords": [
16
16
  "apostrophe",
@@ -70,7 +70,7 @@
70
70
  "express-bearer-token": "^3.0.0",
71
71
  "express-session": "^1.18.2",
72
72
  "fs-extra": "^7.0.1",
73
- "glob": "^10.4.5",
73
+ "glob": "^13.0.0",
74
74
  "he": "^1.2.0",
75
75
  "html-to-text": "^9.0.5",
76
76
  "i18next": "^20.3.2",
@@ -84,8 +84,7 @@
84
84
  "minimatch": "^3.1.4",
85
85
  "mkdirp": "^0.5.5",
86
86
  "multer": "^2.1.1",
87
- "node-fetch": "^2.6.1",
88
- "nodemailer": "^8.0.5",
87
+ "nodemailer": "^9.0.1",
89
88
  "nunjucks": "^3.2.1",
90
89
  "parseurl": "^1.3.3",
91
90
  "passport": "^0.6.0",
@@ -120,15 +119,15 @@
120
119
  "webpack": "^5.106.1",
121
120
  "webpack-merge": "^5.7.3",
122
121
  "xregexp": "^2.0.0",
123
- "broadband": "^1.1.0",
124
- "launder": "^1.7.1",
125
122
  "@apostrophecms/db-connect": "^1.0.1",
126
- "oembetter": "^1.2.0",
127
- "boring": "^1.1.1",
128
123
  "express-cache-on-demand": "^1.0.4",
129
- "sanitize-html": "^2.17.5",
130
- "uploadfs": "^1.26.1",
131
- "postcss-viewport-to-container-toggle": "^2.3.0"
124
+ "launder": "^1.7.1",
125
+ "broadband": "^1.1.0",
126
+ "oembetter": "^1.2.1",
127
+ "boring": "^1.1.1",
128
+ "postcss-viewport-to-container-toggle": "^2.3.0",
129
+ "uploadfs": "^1.27.0",
130
+ "sanitize-html": "^2.17.6"
132
131
  },
133
132
  "devDependencies": {
134
133
  "chai": "^4.3.10",
@@ -55,6 +55,7 @@ describe('Asset - External Build', function () {
55
55
  };
56
56
  },
57
57
  async watch() { },
58
+ async clearCache() { },
58
59
  async startDevServer() {
59
60
  actualDevServer = true;
60
61
  return {
@@ -124,6 +125,7 @@ describe('Asset - External Build', function () {
124
125
  return {
125
126
  async build() { },
126
127
  async watch() { },
128
+ async clearCache() { },
127
129
  async startDevServer() {},
128
130
  async entrypoints() {
129
131
  return [];
@@ -291,6 +293,7 @@ describe('Asset - External Build', function () {
291
293
  };
292
294
  },
293
295
  async watch() { },
296
+ async clearCache() { },
294
297
  async startDevServer() {
295
298
  return {
296
299
  entrypoints: []
@@ -0,0 +1,243 @@
1
+ const t = require('../test-lib/test.js');
2
+ const assert = require('assert');
3
+ const fs = require('fs-extra');
4
+ const path = require('node:path');
5
+
6
+ // The build skip logic used to rely on the lock file modified time, which is
7
+ // unreliable (fresh checkouts, restored CI/Docker build artifacts, clock
8
+ // skew). When the lock file content changed but its mtime was not newer than a
9
+ // leftover build, the admin UI build was skipped and a stale bundle served.
10
+ // These tests cover the content-hash based detection that forces a rebuild and
11
+ // clears the bundler caches on a lock file change.
12
+ describe('Assets - lock file cache invalidation', function() {
13
+ this.timeout(5 * 60 * 1000);
14
+
15
+ let apos;
16
+ const lockPath = path.join(process.cwd(), 'test/package-lock.json');
17
+ let lockSnapshot;
18
+
19
+ before(async function() {
20
+ lockSnapshot = await fs.readFile(lockPath, 'utf8');
21
+ apos = await t.create({
22
+ root: module,
23
+ modules: {}
24
+ });
25
+ });
26
+
27
+ after(async function() {
28
+ await fs.writeFile(lockPath, lockSnapshot);
29
+ await fs.remove(apos.asset.getLockFileHashPath());
30
+ await t.destroy(apos);
31
+ });
32
+
33
+ afterEach(async function() {
34
+ // Restore the lock file after any test that mutated it.
35
+ await fs.writeFile(lockPath, lockSnapshot);
36
+ });
37
+
38
+ beforeEach(function() {
39
+ // Each test starts as if the process had just booted.
40
+ apos.asset.lockFileChecked = false;
41
+ });
42
+
43
+ function mutateLock(key) {
44
+ const lock = JSON.parse(fs.readFileSync(lockPath, 'utf8'));
45
+ lock[key] = (lock[key] || 0) + 1;
46
+ fs.writeFileSync(lockPath, JSON.stringify(lock, null, 2), 'utf8');
47
+ }
48
+
49
+ it('hashes the lock file content, stable across reads', async function() {
50
+ const hash = await apos.asset.getLockFileHash();
51
+ assert(typeof hash === 'string' && hash.length > 0);
52
+ assert.strictEqual(await apos.asset.getLockFileHash(), hash);
53
+ });
54
+
55
+ it('produces a different hash when the lock content changes', async function() {
56
+ const before = await apos.asset.getLockFileHash();
57
+ mutateLock('__test');
58
+ const after = await apos.asset.getLockFileHash();
59
+ assert.notStrictEqual(before, after);
60
+ });
61
+
62
+ it('round-trips the saved lock file hash', async function() {
63
+ await apos.asset.saveLockFileHash('abc123');
64
+ assert.strictEqual(await apos.asset.readSavedLockFileHash(), 'abc123');
65
+ await apos.asset.saveLockFileHash(null);
66
+ assert.strictEqual(await apos.asset.readSavedLockFileHash(), null);
67
+ });
68
+
69
+ it('reports no change when the saved hash matches', async function() {
70
+ await apos.asset.saveLockFileHash(await apos.asset.getLockFileHash());
71
+ const changed = await apos.asset.checkLockFileChanged(
72
+ await apos.asset.getLockFileHash()
73
+ );
74
+ assert.strictEqual(changed, false);
75
+ });
76
+
77
+ it('reports a change when content differs', async function() {
78
+ await apos.asset.saveLockFileHash('a-different-old-hash');
79
+ const changed = await apos.asset.checkLockFileChanged(
80
+ await apos.asset.getLockFileHash()
81
+ );
82
+ assert.strictEqual(changed, true);
83
+ });
84
+
85
+ it('treats a first build (no saved hash) as a change', async function() {
86
+ await apos.asset.saveLockFileHash(null);
87
+ const changed = await apos.asset.checkLockFileChanged(
88
+ await apos.asset.getLockFileHash()
89
+ );
90
+ assert.strictEqual(changed, true);
91
+ });
92
+
93
+ it('runs the lock check once per process', async function() {
94
+ await apos.asset.saveLockFileHash('an-old-hash');
95
+ const first = await apos.asset.checkLockFile();
96
+ assert.strictEqual(first.committed, false);
97
+ assert.strictEqual(first.changed, true);
98
+ await apos.asset.commitLockFileCheck(first);
99
+
100
+ // A watcher-triggered rebuild in the same process reports no change,
101
+ // even if the lock content mutated meanwhile.
102
+ mutateLock('__test-memo');
103
+ const again = await apos.asset.checkLockFile();
104
+ assert.strictEqual(again.committed, true);
105
+ assert.strictEqual(again.changed, false);
106
+ });
107
+
108
+ it('reports no change on rebuilds when there is no lock file', async function() {
109
+ const original = apos.asset.getLockFileHash;
110
+ apos.asset.getLockFileHash = async () => null;
111
+ try {
112
+ // No lock file: the first build of the process is forced...
113
+ const first = await apos.asset.checkLockFile();
114
+ assert.strictEqual(first.changed, true);
115
+ assert.strictEqual(first.hash, null);
116
+ await apos.asset.commitLockFileCheck(first);
117
+ // ...but watcher-triggered rebuilds are not.
118
+ const again = await apos.asset.checkLockFile();
119
+ assert.strictEqual(again.committed, true);
120
+ assert.strictEqual(again.changed, false);
121
+ } finally {
122
+ apos.asset.getLockFileHash = original;
123
+ }
124
+ });
125
+
126
+ it('forces a rebuild when content changed but the lock mtime is older', async function() {
127
+ const tsFile = path.join(
128
+ apos.asset.getBundleRootDir(), 'apos-build-timestamp.txt'
129
+ );
130
+
131
+ // Cold build: establishes the bundle, its timestamp and the saved hash.
132
+ await apos.asset.tasks.build.task({ 'check-apos-build': false });
133
+ const ts1 = parseInt(await fs.readFile(tsFile, 'utf8'), 10);
134
+ assert.strictEqual(
135
+ await apos.asset.readSavedLockFileHash(),
136
+ await apos.asset.getLockFileHash()
137
+ );
138
+
139
+ // Dependency change: the lock content changes, but its mtime is OLDER than
140
+ // the freshly built artifacts (fresh checkout / restored build cache).
141
+ // This happens across process starts, so reset the per-process check.
142
+ apos.asset.lockFileChecked = false;
143
+ mutateLock('__test2');
144
+ const older = new Date(Date.now() - 5 * 60 * 1000);
145
+ await fs.utimes(lockPath, older, older);
146
+
147
+ // The auto build (skip path) must not skip - it should rebuild.
148
+ await apos.asset.tasks.build.task({ 'check-apos-build': true });
149
+ const ts2 = parseInt(await fs.readFile(tsFile, 'utf8'), 10);
150
+
151
+ assert(ts2 > ts1, `expected a rebuild (ts2=${ts2} > ts1=${ts1})`);
152
+ assert.strictEqual(
153
+ await apos.asset.readSavedLockFileHash(),
154
+ await apos.asset.getLockFileHash()
155
+ );
156
+ });
157
+ });
158
+
159
+ // The webpack file system cache is keyed on the lock file content, but an
160
+ // external build module (e.g. @apostrophecms/vite) keeps a cache that is not,
161
+ // so it must be cleared explicitly when the lock file changes.
162
+ describe('Assets - build module cache cleared on lock change', function() {
163
+ this.timeout(t.timeout);
164
+
165
+ let apos;
166
+ let clearCalls = 0;
167
+
168
+ before(async function() {
169
+ apos = await t.create({
170
+ root: module,
171
+ autoBuild: false,
172
+ modules: {
173
+ 'asset-vite-mock': {
174
+ before: '@apostrophecms/asset',
175
+ handlers(self) {
176
+ return {
177
+ '@apostrophecms/asset:afterInit': {
178
+ async registerExternalBuild() {
179
+ self.apos.asset.configureBuildModule(self, {
180
+ alias: 'vite',
181
+ devServer: false,
182
+ hmr: false
183
+ });
184
+ }
185
+ }
186
+ };
187
+ },
188
+ methods(self) {
189
+ return {
190
+ async build() {
191
+ return { entrypoints: [] };
192
+ },
193
+ async watch() {},
194
+ async startDevServer() {
195
+ return { entrypoints: [] };
196
+ },
197
+ async entrypoints() {
198
+ return [];
199
+ },
200
+ async clearCache() {
201
+ clearCalls++;
202
+ }
203
+ };
204
+ }
205
+ }
206
+ }
207
+ });
208
+ });
209
+
210
+ after(async function() {
211
+ await fs.remove(apos.asset.getLockFileHashPath());
212
+ await t.destroy(apos);
213
+ });
214
+
215
+ it('clears the build module cache when the lock content changes', async function() {
216
+ await apos.asset.saveLockFileHash('an-old-hash');
217
+ clearCalls = 0;
218
+ const changed = await apos.asset.checkLockFileChanged(
219
+ await apos.asset.getLockFileHash()
220
+ );
221
+ assert.strictEqual(changed, true);
222
+ assert.strictEqual(clearCalls, 1);
223
+ });
224
+
225
+ it('leaves the build module cache alone when the lock is unchanged', async function() {
226
+ await apos.asset.saveLockFileHash(await apos.asset.getLockFileHash());
227
+ clearCalls = 0;
228
+ const changed = await apos.asset.checkLockFileChanged(
229
+ await apos.asset.getLockFileHash()
230
+ );
231
+ assert.strictEqual(changed, false);
232
+ assert.strictEqual(clearCalls, 0);
233
+ });
234
+
235
+ it('forces a rebuild but keeps the cache when no lock file is found', async function() {
236
+ await apos.asset.saveLockFileHash('an-old-hash');
237
+ clearCalls = 0;
238
+ // null hash => no lock file present
239
+ const changed = await apos.asset.checkLockFileChanged(null);
240
+ assert.strictEqual(changed, true);
241
+ assert.strictEqual(clearCalls, 0);
242
+ });
243
+ });
package/test/assets.js CHANGED
@@ -171,6 +171,29 @@ describe('Assets', function() {
171
171
 
172
172
  this.timeout(5 * 60 * 1000);
173
173
 
174
+ it('should include the site prefix in asset URLs', async function() {
175
+ let prefixApos;
176
+
177
+ try {
178
+ prefixApos = await t.create({
179
+ root: module,
180
+ prefix: '/apos'
181
+ });
182
+
183
+ assert.equal(
184
+ prefixApos.asset.getAssetBaseUrl(),
185
+ '/apos/apos-frontend/default'
186
+ );
187
+
188
+ assert.equal(
189
+ prefixApos.asset.url('/modules/foo/bar.js'),
190
+ '/apos/apos-frontend/default/modules/foo/bar.js'
191
+ );
192
+ } finally {
193
+ await t.destroy(prefixApos);
194
+ }
195
+ });
196
+
174
197
  it('should exist on the apos object', async function() {
175
198
  apos = await t.create({
176
199
  root: module,
@@ -409,10 +432,10 @@ describe('Assets', function() {
409
432
  assert(meta2['default:src']);
410
433
 
411
434
  // Caching should provide a measurable speedup. The threshold is kept
412
- // low (10%) to avoid flaky failures on loaded CI runners where the
435
+ // low (5%) to avoid flaky failures on loaded CI runners where the
413
436
  // cold run can be fast due to OS-level caching.
414
437
  const gain = (execTime - execTimeCached) / execTime * 100;
415
- assert(gain >= 10, `Expected gain >=10%, got ${gain}%`);
438
+ assert(gain >= 5, `Expected gain >=5%, got ${gain}%`);
416
439
 
417
440
  // Modification times
418
441
  assert(meta['default:apos'].mdate);
@@ -0,0 +1,73 @@
1
+ const t = require('../test-lib/test.js');
2
+ const assert = require('assert');
3
+
4
+ // PRO-9564
5
+ // This test pins an established moog-require contract so it is not "fixed" by
6
+ // accident — changing it would silently break existing projects.
7
+ //
8
+ // Scope: the SAME module configured BOTH via the app.js object passed to
9
+ // apostrophe() AND its project-level index.js. (NOT cascade merging across the
10
+ // module chain via extends/improvements, which merges field-by-field as usual.)
11
+ //
12
+ // moog-require collapses the two sources with a shallow `_.defaults` before the
13
+ // chain is built, so app.js wins for any top-level section declared in both.
14
+ // `options` then gets a second pass that gap-fills from index.js, but no other
15
+ // section does — so a cascade like `fields` is NOT merged: an app.js `fields`
16
+ // replaces the index.js `fields` wholesale, dropping even unrelated
17
+ // project-level fields. The options-only gap-fill is by design.
18
+ //
19
+ // Example — this test and its fixture:
20
+ // app.js modules: { 'collision-piece': { fields: { add: { configField } } } }
21
+ // index.js test/modules/collision-piece/index.js -> fields: { add: { projectField } }
22
+ // result schema has `configField`; `projectField` is gone, although the two
23
+ // fields are unrelated.
24
+ // options app.js { alias: ... } still merges with any index.js options
25
+ // (gap-fill), unlike `fields`.
26
+ //
27
+ // Files: collapse in packages/apostrophe/lib/moog-require.js (~`_.defaults(
28
+ // definition, projectLevelDefinition)`); cascade compiler that the dropped
29
+ // section never reaches in packages/apostrophe/lib/moog.js; project-level
30
+ // fixture in test/modules/collision-piece/index.js.
31
+
32
+ describe('config-object vs project-level cascade merge (moog-require contract)', function () {
33
+ this.timeout(t.timeout);
34
+ let apos;
35
+ after(async () => {
36
+ await t.destroy(apos);
37
+ });
38
+
39
+ it('app.js `fields` wins over the project-level file when both declare it', async function () {
40
+ apos = await t.create({
41
+ root: module,
42
+ modules: {
43
+ 'collision-piece': {
44
+ extend: '@apostrophecms/piece-type',
45
+ options: { alias: 'collisionPiece' },
46
+ fields: {
47
+ add: {
48
+ configField: {
49
+ type: 'string',
50
+ label: 'Config Field'
51
+ }
52
+ },
53
+ group: {
54
+ configGroup: {
55
+ label: 'Config Group',
56
+ fields: [ 'configField' ]
57
+ }
58
+ }
59
+ }
60
+ }
61
+ }
62
+ });
63
+
64
+ const names = apos.collisionPiece.schema.map(f => f.name);
65
+ // The app.js (config object) fields win.
66
+ assert(names.includes('configField'));
67
+ // The project-level file's fields are dropped wholesale — the contract.
68
+ assert(!names.includes('projectField'));
69
+ // Options are the exception: an index.js-only option is gap-filled in,
70
+ // where a project-level field would have been dropped.
71
+ assert.strictEqual(apos.collisionPiece.options.fromProjectFile, true);
72
+ });
73
+ });