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.
- package/CHANGELOG.md +48 -1
- package/claude-tools/run-mocha-file.sh +29 -0
- package/modules/@apostrophecms/area/index.js +14 -5
- package/modules/@apostrophecms/area/ui/apos/apps/AposAreas.js +72 -3
- package/modules/@apostrophecms/asset/index.js +170 -21
- package/modules/@apostrophecms/asset/lib/build/external-module-api.js +7 -16
- package/modules/@apostrophecms/asset/lib/build/internals.js +1 -3
- package/modules/@apostrophecms/asset/lib/build/task.js +7 -13
- package/modules/@apostrophecms/command-menu/ui/apos/components/TheAposCommandMenu.vue +21 -1
- package/modules/@apostrophecms/doc-type/index.js +73 -15
- package/modules/@apostrophecms/http/index.js +175 -139
- package/modules/@apostrophecms/image/index.js +16 -7
- package/modules/@apostrophecms/image/ui/apos/components/AposMediaManager.vue +9 -1
- package/modules/@apostrophecms/job/index.js +3 -1
- package/modules/@apostrophecms/page/index.js +17 -1
- package/modules/@apostrophecms/rich-text-widget/ui/apos/components/AposRichTextWidgetEditor.vue +10 -1
- package/modules/@apostrophecms/rich-text-widget/ui/apos/components/AposTiptapInsertItem.vue +3 -14
- package/modules/@apostrophecms/rich-text-widget/ui/apos/lib/remove-slash.js +18 -0
- package/modules/@apostrophecms/schema/ui/apos/components/AposInputDateAndTime.vue +37 -6
- package/modules/@apostrophecms/url/index.js +54 -0
- package/package.json +11 -12
- package/test/asset-external.js +3 -0
- package/test/asset-lock-cache.js +243 -0
- package/test/assets.js +25 -2
- package/test/config-cascade-merge.js +73 -0
- package/test/files.js +10 -11
- package/test/image-tags.js +103 -0
- package/test/modules/collision-piece/index.js +23 -0
- package/test/pages-move-permissions.js +317 -0
- package/test/pages.js +12 -15
- package/test/pieces-public-api-relationship-choices.js +245 -0
- package/test/pieces.js +12 -15
- package/test-lib/test.js +32 -0
package/modules/@apostrophecms/rich-text-widget/ui/apos/components/AposRichTextWidgetEditor.vue
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
53
|
+
container-type: inline-size;
|
|
54
|
+
display: grid;
|
|
55
|
+
grid-template-columns: auto minmax(0, 1fr);
|
|
54
56
|
align-items: center;
|
|
55
|
-
|
|
57
|
+
gap: 8px 12px;
|
|
56
58
|
}
|
|
57
59
|
|
|
58
60
|
.apos-toggle {
|
|
59
|
-
|
|
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
|
-
&--
|
|
72
|
-
|
|
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.
|
|
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": ">=
|
|
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": "^
|
|
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
|
-
"
|
|
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
|
-
"
|
|
130
|
-
"
|
|
131
|
-
"
|
|
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",
|
package/test/asset-external.js
CHANGED
|
@@ -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 (
|
|
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 >=
|
|
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
|
+
});
|