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/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,52 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 4.32.0 (2026-07-10)
|
|
4
|
+
|
|
5
|
+
### Adds
|
|
6
|
+
|
|
7
|
+
- Added support for modules to declare _literal content_ routes - URLs that serve non-page files such as `robots.txt`, `sitemap.xml`, or `llms.txt` rather than rendered pages. External front-end integrations (such as the Astro integration) can now read these routes and serve such files correctly instead of attempting to render them as pages. Custom modules can contribute their own routes by handling the new `@apostrophecms/url:getLiteralContentRoutes` event.
|
|
8
|
+
|
|
9
|
+
### Changes
|
|
10
|
+
|
|
11
|
+
- The server-side HTTP client (`apos.http`) now uses Node's built-in `fetch` instead of `node-fetch`.
|
|
12
|
+
|
|
13
|
+
`node-fetch` is no longer maintained, and Node's built-in `fetch` is its standard, actively maintained successor, available in every Node.js version Apostrophe supports - so this is the right time to adopt it. We do not consider this a breaking change: common `apos.http.*` usage is unchanged, and we deliberately preserved compatibility where it mattered - `form-data` request bodies, cookie jars, the `timeout` option (now backed by an `AbortSignal`), and absolute redirect `Location` headers all behave as before.
|
|
14
|
+
|
|
15
|
+
Most code that calls `apos.http.get()`, `apos.http.post()`, etc. needs no changes. A few things to be aware of if you use advanced options or read raw responses:
|
|
16
|
+
|
|
17
|
+
- The `agent` option is no longer supported (the built-in `fetch` has no equivalent). Pass an undici `dispatcher` instead; `apos.http` throws if `agent` is given.
|
|
18
|
+
- A `Host` request header can no longer be set (it is disallowed by the fetch standard and is silently ignored).
|
|
19
|
+
- `originalResponse: true` now resolves with the built-in `fetch` `Response`. Its `body` is a web `ReadableStream` (use `require('node:stream').Readable.fromWeb()` to read it as a Node stream), and node-fetch-only helpers such as `.buffer()` are no longer available.
|
|
20
|
+
- Requests that send a conditional header (`If-None-Match` / `If-Modified-Since`) now also send `Cache-Control: no-cache`, as required by the fetch standard. An endpoint that returns `304 Not Modified` based on those headers may return `200` to such a request.
|
|
21
|
+
|
|
22
|
+
New capabilities:
|
|
23
|
+
|
|
24
|
+
- The `timeout` option (in milliseconds) and the standard `signal` (`AbortSignal`) and undici `dispatcher` options are supported.
|
|
25
|
+
- A request `body` may be a native `FormData`, in addition to a `form-data` package instance.
|
|
26
|
+
|
|
27
|
+
- Bumped `glob` to `^13` (core) and `rimraf` to `^6` (uploadfs) to clear the deprecated `glob@10` warning shown on every install. The old `glob@10` arrived both directly from core and transitively through `uploadfs` → `rimraf@5`; both now resolve to the current, supported `glob@13` (`rimraf@6` depends on `glob@13` as well). No API or behavior changes.
|
|
28
|
+
|
|
29
|
+
### Fixes
|
|
30
|
+
|
|
31
|
+
- Fixed the tag popover in the media library (used to apply tags to images in bulk) so it loads all image tags instead of only the first 50. Tags beyond the first 50 can now be found and applied, and creating a tag whose name already exists no longer produces a duplicate.
|
|
32
|
+
- The lock file dependency check that forces a full rebuild now runs once per process. Watcher-triggered rebuilds stay scoped to the detected changes instead of rebuilding everything on every file change when the lock file changed or is absent. This bug was in effect only for projects missing a lock file in their `npmRoot` (e.g. npm monorepos).
|
|
33
|
+
- Fixed the admin UI sometimes serving a stale build after dependencies changed (for example after `npm install` or `npm update`). Apostrophe now detects dependency changes from the content of the lock file rather than its modified time, which could be misleading after a fresh checkout or a restored CI/Docker build cache.
|
|
34
|
+
|
|
35
|
+
For external build module authors: lock file change detection now happens in the core and is passed to the build module via the `lockChanged` build option. The `apos.asset.getSystemLastChangeMs()` helper is deprecated and the build manifest no longer includes a `ts` timestamp.
|
|
36
|
+
|
|
37
|
+
- Fixed pressing Backspace right after typing `/` in a rich text widget deleting the entire widget. Backspace now removes the slash and closes the insert menu. Global command menu shortcuts also no longer fire for key events already handled and prevented by other UI components.
|
|
38
|
+
- Fix invalid HTML output for <col> elements in sanitize-html (treat void elements correctly)
|
|
39
|
+
- Fixed a layout issue where `dateAndTime` schema fields could overflow and trigger horizontal scrolling in narrow containers.
|
|
40
|
+
- fromRichText adds metatype to new widget
|
|
41
|
+
- Fixed the widget copy shortcut (Ctrl+C / Cmd+C) hijacking native text copy in edit mode. With an active text selection, the cut, copy and remove (Backspace) widget shortcuts now defer to the browser. Pasting a widget with Ctrl+V / Cmd+V now checks that the widget copy is still the most recent thing in the system clipboard, so text copied elsewhere in the meantime is no longer shadowed by a stale widget paste. The widget clipboard storage remains backward compatible with entries written by previous releases.
|
|
42
|
+
- Batch jobs now reliably record their total item count, so completion notifications no longer occasionally report a null total.
|
|
43
|
+
|
|
44
|
+
### Security
|
|
45
|
+
|
|
46
|
+
- Completed the fix for CVE-2026-39857 (GHSA-xmpp-f9v3-r7qh). The `.choices()` / `.counts()` query builders (`?choices=` / `?counts=` on the public REST API) guarded against leaking distinct values of fields excluded from `publicApiProjection` by resolving the schema field with an exact-name match. A relationship field registers extra query builders whose names differ from the field name — the "slug" alias builders that drop the leading underscore (`author` / `authorAnd` for a field named `_author`) and the `_authorAnd` operation builder — so those aliases were not gated and could still be used by an unauthenticated caller to extract the relationship's distinct choices (the referenced, publicly visible related documents by title/slug, plus per-value counts via `?counts=`) for a relationship an operator intentionally excluded from `publicApiProjection`. Relationship alias builders are now resolved back to their underlying schema field (matching the field name or its `idsStorage`) before the `publicApiProjection` and `viewPermission` checks are applied, so the alias names are gated exactly like the field itself. Thanks to Ta Duc Thien ([thientd](https://github.com/thientd)) for reporting this issue.
|
|
47
|
+
- Restored destination-parent authorization in the page `move()` operation (GHSA-wr5r-wqp2-x4fh). A regression had gated the destination "create" permission check on the source page being restored out of the archive, which silently disabled that check for every ordinary move. As a result a low-privileged but content-editing user (for example an editor) who could edit at least one page could relocate that page under a parent of a restricted page type they have no create/edit rights over (such as one declaring a higher `editRole`/`publishRole`), and in doing so trigger an unchecked re-ranking of the restricted parent's existing children. A cross-parent move into a non-archive destination now again requires "create" permission on the destination, with the archive-restore path handled as an explicit exception. Thanks to 5ud0 / Tarmo Technologies for reporting the issue.
|
|
48
|
+
- Bumped the `nodemailer` dependency from 8.x to 9.x to pick up the fix for GHSA-p6gq-j5cr-w38f, where a message's `raw` option could bypass nodemailer's `disableFileAccess`/`disableUrlAccess` controls and enable arbitrary file reads or SSRF. The real-world risk to Apostrophe is low: core only sends mail from trusted server-side code (such as password-reset emails), never sets those controls, and gives site visitors no way to control a message's `raw` field. nodemailer 9 is a security-only major release with one behavior change worth noting for projects: outbound HTTPS used to fetch remote content (remote-URL attachments, OAuth2 token endpoints, HTTP/HTTPS proxies) now validates TLS certificates by default — if you depend on self-signed or otherwise invalid certificates, opt out per request with `tls.rejectUnauthorized: false`. As a precaution, make sure your own project code never forwards untrusted input into the `raw` field of a module's `email()` call.
|
|
49
|
+
|
|
3
50
|
## 4.31.0 (2026-06-10)
|
|
4
51
|
|
|
5
52
|
### Adds
|
|
@@ -12,7 +59,7 @@
|
|
|
12
59
|
### Fixes
|
|
13
60
|
|
|
14
61
|
- Fixed an issue where using the Tab key to navigate within modals could incorrectly jump focus to a wrong element instead of the next input field.
|
|
15
|
-
Fixed Tab navigation escaping out of modals when the form contained hidden sections or elements that became disabled after editing.
|
|
62
|
+
Fixed Tab navigation escaping out of modals when the form contained hidden sections or elements that became disabled after editing.
|
|
16
63
|
- Fixed adding or removing an area field from a schema breaking existing documents on an external front such as Astro.
|
|
17
64
|
- For Astro: `AposArea` now renders only schema-backed areas. A missing area no longer throws, and an area orphaned by removing its field from the schema (while its content remains in the document) renders nothing instead of breaking sibling areas in edit mode. Logged-in editors get a diagnostic message in place of an orphaned area; anonymous visitors see nothing.
|
|
18
65
|
- Editable documents sent to an external front (Asgtro) now materialize empty area objects for schema area fields added after the document was created, so they can be edited in context.
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Run one (or more) mocha test files serially, logging full stdout+stderr to a
|
|
3
|
+
# file, then print a concise report of which specific tests passed/failed.
|
|
4
|
+
#
|
|
5
|
+
# Usage: claude-tools/run-mocha-file.sh <logfile> <test/file.js> [more files...]
|
|
6
|
+
# Env: APOS_TEST_DB_PROTOCOL (mongodb|postgres|sqlite) is honored by the suite.
|
|
7
|
+
set -uo pipefail
|
|
8
|
+
|
|
9
|
+
if [ "$#" -lt 2 ]; then
|
|
10
|
+
echo "usage: $0 <logfile> <test/file.js> [more files...]" >&2
|
|
11
|
+
exit 2
|
|
12
|
+
fi
|
|
13
|
+
|
|
14
|
+
LOG="$1"; shift
|
|
15
|
+
cd "$(dirname "$0")/.." || exit 1
|
|
16
|
+
|
|
17
|
+
# One at a time. NEVER run our suites in parallel.
|
|
18
|
+
npx mocha -t 10000 "$@" > "$LOG" 2>&1
|
|
19
|
+
CODE=$?
|
|
20
|
+
|
|
21
|
+
echo "===== exit code: $CODE (log: $LOG) ====="
|
|
22
|
+
echo "----- passing (✓/√) -----"
|
|
23
|
+
grep -aE '^\s*(✓|√|[0-9]+ passing)' "$LOG" | sed 's/^/ /'
|
|
24
|
+
echo "----- FAILING -----"
|
|
25
|
+
# Mocha marks failures as " 1) suite: title" and a summary "N failing".
|
|
26
|
+
grep -aE '^\s*[0-9]+\) |[0-9]+ failing' "$LOG" | sed 's/^/ /'
|
|
27
|
+
echo "----- last lines -----"
|
|
28
|
+
tail -n 5 "$LOG"
|
|
29
|
+
exit $CODE
|
|
@@ -20,7 +20,8 @@ module.exports = {
|
|
|
20
20
|
type: 'command-menu-area-cut-widget'
|
|
21
21
|
},
|
|
22
22
|
shortcut: 'Ctrl+X Meta+X',
|
|
23
|
-
requireWidgetFocus: true
|
|
23
|
+
requireWidgetFocus: true,
|
|
24
|
+
skipOnTextSelection: true
|
|
24
25
|
},
|
|
25
26
|
[`${self.__meta.name}:copy-widget`]: {
|
|
26
27
|
type: 'item',
|
|
@@ -29,7 +30,8 @@ module.exports = {
|
|
|
29
30
|
type: 'command-menu-area-copy-widget'
|
|
30
31
|
},
|
|
31
32
|
shortcut: 'Ctrl+C Meta+C',
|
|
32
|
-
requireWidgetFocus: true
|
|
33
|
+
requireWidgetFocus: true,
|
|
34
|
+
skipOnTextSelection: true
|
|
33
35
|
},
|
|
34
36
|
[`${self.__meta.name}:paste-widget`]: {
|
|
35
37
|
type: 'item',
|
|
@@ -38,7 +40,12 @@ module.exports = {
|
|
|
38
40
|
type: 'command-menu-area-paste-widget'
|
|
39
41
|
},
|
|
40
42
|
shortcut: 'Ctrl+V Meta+V',
|
|
41
|
-
requireWidgetFocus: true
|
|
43
|
+
requireWidgetFocus: true,
|
|
44
|
+
// Dispatched by the native paste event listener (see AposAreas.js),
|
|
45
|
+
// not by the command menu keydown listener, so the clipboard
|
|
46
|
+
// contents can arbitrate; keydown remains a fallback when the
|
|
47
|
+
// Clipboard API is unavailable
|
|
48
|
+
trigger: 'native'
|
|
42
49
|
},
|
|
43
50
|
[`${self.__meta.name}:duplicate-widget`]: {
|
|
44
51
|
type: 'item',
|
|
@@ -56,7 +63,8 @@ module.exports = {
|
|
|
56
63
|
type: 'command-menu-area-remove-widget'
|
|
57
64
|
},
|
|
58
65
|
shortcut: 'Backspace',
|
|
59
|
-
requireWidgetFocus: true
|
|
66
|
+
requireWidgetFocus: true,
|
|
67
|
+
skipOnTextSelection: true
|
|
60
68
|
}
|
|
61
69
|
},
|
|
62
70
|
modal: {
|
|
@@ -886,7 +894,8 @@ module.exports = {
|
|
|
886
894
|
area.items.push({
|
|
887
895
|
_id: self.apos.util.generateId(),
|
|
888
896
|
type: '@apostrophecms/rich-text',
|
|
889
|
-
content: html
|
|
897
|
+
content: html,
|
|
898
|
+
metaType: 'widget'
|
|
890
899
|
});
|
|
891
900
|
}
|
|
892
901
|
return area;
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
import createApp, { pinia } from 'Modules/@apostrophecms/ui/lib/vue';
|
|
3
3
|
import { useWidgetGraphStore } from 'Modules/@apostrophecms/ui/stores/widgetGraph.js';
|
|
4
4
|
import { nextTick } from 'vue';
|
|
5
|
+
import { createId } from '@paralleldrive/cuid2';
|
|
5
6
|
|
|
6
7
|
export default function() {
|
|
7
8
|
const mountedApps = new Map();
|
|
@@ -182,22 +183,90 @@ export default function() {
|
|
|
182
183
|
}
|
|
183
184
|
|
|
184
185
|
function createWidgetClipboardApp() {
|
|
186
|
+
const key = 'aposWidgetClipboard';
|
|
187
|
+
const marker = 'apos-widget:';
|
|
185
188
|
|
|
186
189
|
// Simpler and more reliable to just talk to localStorage always and avoid the
|
|
187
190
|
// storage event handle
|
|
188
191
|
class Clipboard {
|
|
189
192
|
set(widget) {
|
|
190
|
-
|
|
193
|
+
const id = createId();
|
|
194
|
+
localStorage.setItem(key, JSON.stringify({
|
|
195
|
+
id,
|
|
196
|
+
widget
|
|
197
|
+
}));
|
|
198
|
+
// Stamp the OS clipboard so a later paste can tell whether this
|
|
199
|
+
// widget copy is still the most recent thing the user copied
|
|
200
|
+
navigator.clipboard?.writeText(`${marker}${id}`).catch(e => {
|
|
201
|
+
// eslint-disable-next-line no-console
|
|
202
|
+
console.warn('Unable to write the widget marker to the clipboard', e);
|
|
203
|
+
});
|
|
191
204
|
}
|
|
192
205
|
|
|
193
206
|
get() {
|
|
194
|
-
|
|
195
|
-
|
|
207
|
+
return this.getEntry()?.widget || null;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
getId() {
|
|
211
|
+
return this.getEntry()?.id || null;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// Returns { id, widget } or null. Entries written by older releases
|
|
215
|
+
// hold a bare widget object and are returned with a null id.
|
|
216
|
+
getEntry() {
|
|
217
|
+
const existing = window.localStorage.getItem(key);
|
|
218
|
+
if (!existing) {
|
|
219
|
+
return null;
|
|
220
|
+
}
|
|
221
|
+
let parsed;
|
|
222
|
+
try {
|
|
223
|
+
parsed = JSON.parse(existing);
|
|
224
|
+
} catch (e) {
|
|
225
|
+
return null;
|
|
226
|
+
}
|
|
227
|
+
if (!parsed || typeof parsed !== 'object') {
|
|
228
|
+
return null;
|
|
229
|
+
}
|
|
230
|
+
if (typeof parsed.id === 'string' && parsed.widget?.type) {
|
|
231
|
+
return parsed;
|
|
232
|
+
}
|
|
233
|
+
return {
|
|
234
|
+
id: null,
|
|
235
|
+
widget: parsed
|
|
236
|
+
};
|
|
196
237
|
}
|
|
197
238
|
}
|
|
198
239
|
|
|
199
240
|
apos.area.widgetClipboard = new Clipboard();
|
|
200
241
|
|
|
242
|
+
// Widget paste arrives through the native paste event rather than a
|
|
243
|
+
// Ctrl+V keydown interception, so the clipboard contents decide: a
|
|
244
|
+
// widget is pasted only when our marker is still the most recent copy
|
|
245
|
+
document.addEventListener('paste', e => {
|
|
246
|
+
if (isInsideEditable()) {
|
|
247
|
+
// User is typing, native paste wins
|
|
248
|
+
return;
|
|
249
|
+
}
|
|
250
|
+
const text = e.clipboardData?.getData('text/plain') || '';
|
|
251
|
+
if (!text.startsWith(marker)) {
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
254
|
+
// Never paste the marker itself as text at area level
|
|
255
|
+
e.preventDefault();
|
|
256
|
+
if (text.slice(marker.length) === apos.area.widgetClipboard.getId()) {
|
|
257
|
+
apos.bus.$emit('command-menu-area-paste-widget');
|
|
258
|
+
}
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
function isInsideEditable() {
|
|
262
|
+
const el = document.activeElement;
|
|
263
|
+
return !!el && (
|
|
264
|
+
el.nodeName === 'INPUT' ||
|
|
265
|
+
el.nodeName === 'TEXTAREA' ||
|
|
266
|
+
el.isContentEditable ||
|
|
267
|
+
!!el.closest?.('[contenteditable]')
|
|
268
|
+
);
|
|
269
|
+
}
|
|
201
270
|
}
|
|
202
271
|
|
|
203
272
|
function cleanupOrphanedApps() {
|
|
@@ -123,6 +123,9 @@ module.exports = {
|
|
|
123
123
|
self.externalBuildModuleConfig = {};
|
|
124
124
|
|
|
125
125
|
self.restartId = self.apos.util.generateId();
|
|
126
|
+
// Set to `true` once a build in this process has run the lock file
|
|
127
|
+
// dependency check and succeeded. See `checkLockFile()`.
|
|
128
|
+
self.lockFileChecked = false;
|
|
126
129
|
self.iconMap = {
|
|
127
130
|
...globalIcons
|
|
128
131
|
};
|
|
@@ -254,20 +257,35 @@ module.exports = {
|
|
|
254
257
|
afterModuleInit: true,
|
|
255
258
|
async task(argv = {}) {
|
|
256
259
|
self.inBuildTask = true;
|
|
260
|
+
// If the lock file changed since the last successful build, force a
|
|
261
|
+
// full rebuild (and clear the build module cache) so a stale admin
|
|
262
|
+
// UI is never served after a dependency change (npm install/update).
|
|
263
|
+
// The check runs once per process, so watcher-triggered rebuilds
|
|
264
|
+
// stay scoped to the detected changes.
|
|
265
|
+
const lockCheck = await self.checkLockFile();
|
|
266
|
+
argv = {
|
|
267
|
+
...argv,
|
|
268
|
+
lockChanged: lockCheck.changed
|
|
269
|
+
};
|
|
270
|
+
let result;
|
|
257
271
|
if (self.hasBuildModule()) {
|
|
258
|
-
|
|
272
|
+
result = await self.build(argv);
|
|
273
|
+
} else {
|
|
274
|
+
// Debugging but only if we don't have an external build module.
|
|
275
|
+
// If we do, the debug output is handled by the respective setter.
|
|
276
|
+
self.printDebug('setWebpackExtensions', {
|
|
277
|
+
builds: self.builds,
|
|
278
|
+
extraBundles: self.extraBundles,
|
|
279
|
+
webpackExtensions: self.webpackExtensions,
|
|
280
|
+
webpackExtensionOptions: self.webpackExtensionOptions,
|
|
281
|
+
verifiedBundles: self.verifiedBundles,
|
|
282
|
+
rebundleModules: self.rebundleModules
|
|
283
|
+
});
|
|
284
|
+
result = await webpackBuild.task(argv);
|
|
259
285
|
}
|
|
260
|
-
//
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
builds: self.builds,
|
|
264
|
-
extraBundles: self.extraBundles,
|
|
265
|
-
webpackExtensions: self.webpackExtensions,
|
|
266
|
-
webpackExtensionOptions: self.webpackExtensionOptions,
|
|
267
|
-
verifiedBundles: self.verifiedBundles,
|
|
268
|
-
rebundleModules: self.rebundleModules
|
|
269
|
-
});
|
|
270
|
-
return webpackBuild.task(argv);
|
|
286
|
+
// Record the check now that the build has succeeded.
|
|
287
|
+
await self.commitLockFileCheck(lockCheck);
|
|
288
|
+
return result;
|
|
271
289
|
}
|
|
272
290
|
},
|
|
273
291
|
|
|
@@ -402,12 +420,13 @@ module.exports = {
|
|
|
402
420
|
// development related properties: * `devServerUrl` (optional, string or
|
|
403
421
|
// null) the base server URL for the dev server when available. *
|
|
404
422
|
// `hmrTypes` (optional, array of strings) the entrypoint types that are
|
|
405
|
-
// currently served with HMR.
|
|
406
|
-
//
|
|
407
|
-
//
|
|
408
|
-
//
|
|
409
|
-
//
|
|
410
|
-
//
|
|
423
|
+
// currently served with HMR.
|
|
424
|
+
//
|
|
425
|
+
// Lock file (dependency) changes are detected by the core (by hashing the
|
|
426
|
+
// lock file content) and signalled to the build module via the
|
|
427
|
+
// `lockChanged` build option, which should force a rebuild. The
|
|
428
|
+
// `getSystemLastChangeMs()` modified-time helper is deprecated and no
|
|
429
|
+
// longer needed for that purpose.
|
|
411
430
|
//
|
|
412
431
|
// ** `async watch(watcher, options)`: the method to attach the watcher
|
|
413
432
|
// to the external build module.
|
|
@@ -500,11 +519,16 @@ module.exports = {
|
|
|
500
519
|
// - `changes` is an array of changed files. This is reserved for the
|
|
501
520
|
// legacy build system and should never appear in the external build
|
|
502
521
|
// module.
|
|
522
|
+
// - `lockChanged` is a boolean flag set by the core when the lock file
|
|
523
|
+
// content changed since the last build (or no lock file was found). The
|
|
524
|
+
// build module should force a rebuild when it is `true`.
|
|
503
525
|
//
|
|
504
526
|
// Returns an object:
|
|
505
527
|
// - `isTask`: if `true`, the build is executed as a task. If false
|
|
506
528
|
// optimization can be applied (e.g. build apostrophe admin UI only
|
|
507
|
-
// once). - `
|
|
529
|
+
// once). - `lockChanged`: if `true`, dependencies changed since the last
|
|
530
|
+
// build (or there is no lock file); the build module should force a
|
|
531
|
+
// rebuild. - `hmr`: if `true`, the hot module replacement is enabled. -
|
|
508
532
|
// `hmrPort`: the port for the HMR WS server. If not set, the default port
|
|
509
533
|
// is used. - `devServer`: if `false`, the dev server is disabled.
|
|
510
534
|
// Otherwise, it's a string (enum) `public` or `apos`. Note that if `hmr`
|
|
@@ -527,6 +551,7 @@ module.exports = {
|
|
|
527
551
|
}
|
|
528
552
|
const options = {
|
|
529
553
|
isTask: !argv['check-apos-build'],
|
|
554
|
+
lockChanged: !!argv.lockChanged,
|
|
530
555
|
hmr: self.hasHMR(),
|
|
531
556
|
hmrPort: self.options.hmrPort,
|
|
532
557
|
modulePreloadPolyfill: self.options.modulePreloadPolyfill,
|
|
@@ -845,22 +870,146 @@ module.exports = {
|
|
|
845
870
|
},
|
|
846
871
|
getAssetBaseUrl() {
|
|
847
872
|
const namespace = self.getNamespace();
|
|
873
|
+
const prefix = self.apos.prefix || '';
|
|
848
874
|
if (self.isProductionMode()) {
|
|
849
875
|
const releaseId = self.getReleaseId();
|
|
850
876
|
const releaseDir = `/apos-frontend/releases/${releaseId}/${namespace}`;
|
|
851
877
|
if (process.env.APOS_UPLOADFS_ASSETS) {
|
|
852
878
|
return `${self.uploadfs.getUrl()}${releaseDir}`;
|
|
853
879
|
} else {
|
|
854
|
-
return releaseDir
|
|
880
|
+
return `${prefix}${releaseDir}`;
|
|
855
881
|
}
|
|
856
882
|
}
|
|
857
|
-
return
|
|
883
|
+
return `${prefix}/apos-frontend/${namespace}`;
|
|
858
884
|
},
|
|
859
885
|
getCacheBasePath() {
|
|
860
886
|
return process.env.APOS_ASSET_CACHE ||
|
|
861
887
|
path.join(self.apos.rootDir, 'data/temp/webpack-cache');
|
|
862
888
|
},
|
|
863
889
|
|
|
890
|
+
// Absolute path to the project package manager lock file, or `false`
|
|
891
|
+
// when none is found. Shared by the webpack and external build paths.
|
|
892
|
+
async findPackageLockPath() {
|
|
893
|
+
const candidates = [ 'package-lock.json', 'yarn.lock', 'pnpm-lock.yaml' ];
|
|
894
|
+
for (const name of candidates) {
|
|
895
|
+
const candidate = path.join(self.apos.npmRootDir, name);
|
|
896
|
+
if (await fs.pathExists(candidate)) {
|
|
897
|
+
return candidate;
|
|
898
|
+
}
|
|
899
|
+
}
|
|
900
|
+
return false;
|
|
901
|
+
},
|
|
902
|
+
|
|
903
|
+
// md5 of the current lock file content, or `null` when there is no lock
|
|
904
|
+
// file (e.g. in tests). Used to detect dependency changes reliably,
|
|
905
|
+
// where modified times cannot be trusted (fresh checkouts, restored
|
|
906
|
+
// build artifacts, clock skew).
|
|
907
|
+
async getLockFileHash() {
|
|
908
|
+
const lockPath = await self.findPackageLockPath();
|
|
909
|
+
if (!lockPath) {
|
|
910
|
+
return null;
|
|
911
|
+
}
|
|
912
|
+
return self.apos.util.md5(await fs.readFile(lockPath, 'utf8'));
|
|
913
|
+
},
|
|
914
|
+
|
|
915
|
+
// Where the lock file hash from the last build is persisted.
|
|
916
|
+
getLockFileHashPath() {
|
|
917
|
+
return path.join(
|
|
918
|
+
self.apos.rootDir, 'data/temp', self.getNamespace(), 'lock-file-hash'
|
|
919
|
+
);
|
|
920
|
+
},
|
|
921
|
+
|
|
922
|
+
async readSavedLockFileHash() {
|
|
923
|
+
try {
|
|
924
|
+
const value = (await fs.readFile(self.getLockFileHashPath(), 'utf8')).trim();
|
|
925
|
+
return value || null;
|
|
926
|
+
} catch (e) {
|
|
927
|
+
return null;
|
|
928
|
+
}
|
|
929
|
+
},
|
|
930
|
+
|
|
931
|
+
async saveLockFileHash(hash) {
|
|
932
|
+
const file = self.getLockFileHashPath();
|
|
933
|
+
if (!hash) {
|
|
934
|
+
await fs.remove(file);
|
|
935
|
+
return;
|
|
936
|
+
}
|
|
937
|
+
await fs.mkdirp(path.dirname(file));
|
|
938
|
+
await fs.writeFile(file, hash, 'utf8');
|
|
939
|
+
},
|
|
940
|
+
|
|
941
|
+
// Clear the external build module's cache (e.g. @apostrophecms/vite),
|
|
942
|
+
// whose cache directory is not keyed on the lock file content. The
|
|
943
|
+
// webpack file system cache is keyed on the lock file content and
|
|
944
|
+
// self-invalidates, so it is left in place; forcing a rebuild is enough
|
|
945
|
+
// for it to produce a fresh bundle.
|
|
946
|
+
async clearBuildModuleCache() {
|
|
947
|
+
if (self.hasBuildModule()) {
|
|
948
|
+
await self.getBuildModule().clearCache();
|
|
949
|
+
}
|
|
950
|
+
},
|
|
951
|
+
|
|
952
|
+
// Decide whether the build should be forced because of a dependency
|
|
953
|
+
// change, returning `true` so callers can force a full rebuild.
|
|
954
|
+
// `currentHash` is accepted to avoid reading the lock file twice; it is
|
|
955
|
+
// `null` when no lock file was found.
|
|
956
|
+
//
|
|
957
|
+
// - No lock file (e.g. some monorepo setups): force a rebuild so a stale
|
|
958
|
+
// admin UI is never served, but do NOT clear the build module cache -
|
|
959
|
+
// there is no evidence dependencies changed and clearing it on every
|
|
960
|
+
// run would be needlessly expensive.
|
|
961
|
+
// - Lock content changed, or there is no record of a previous build:
|
|
962
|
+
// clear the build module cache so the rebuild cannot reuse stale
|
|
963
|
+
// modules, and force a rebuild.
|
|
964
|
+
// - Lock content unchanged: no forced rebuild.
|
|
965
|
+
//
|
|
966
|
+
// The new hash is persisted by the caller via `saveLockFileHash()` only
|
|
967
|
+
// after the build succeeds, so a failed build is retried.
|
|
968
|
+
async checkLockFileChanged(currentHash) {
|
|
969
|
+
if (currentHash === null) {
|
|
970
|
+
return true;
|
|
971
|
+
}
|
|
972
|
+
const saved = await self.readSavedLockFileHash();
|
|
973
|
+
if (saved === currentHash) {
|
|
974
|
+
return false;
|
|
975
|
+
}
|
|
976
|
+
await self.clearBuildModuleCache();
|
|
977
|
+
return true;
|
|
978
|
+
},
|
|
979
|
+
|
|
980
|
+
// Run the dependency (lock file) check that drives the `lockChanged`
|
|
981
|
+
// build option, once per process. The first build performs the real
|
|
982
|
+
// comparison; later invocations (e.g. watcher-triggered rebuilds)
|
|
983
|
+
// report no change, because a dependency change while the process is
|
|
984
|
+
// running requires a restart anyway. The result must be handed back
|
|
985
|
+
// to `commitLockFileCheck()` after the build succeeds.
|
|
986
|
+
async checkLockFile() {
|
|
987
|
+
if (self.lockFileChecked) {
|
|
988
|
+
return {
|
|
989
|
+
committed: true,
|
|
990
|
+
changed: false,
|
|
991
|
+
hash: null
|
|
992
|
+
};
|
|
993
|
+
}
|
|
994
|
+
const hash = await self.getLockFileHash();
|
|
995
|
+
const changed = await self.checkLockFileChanged(hash);
|
|
996
|
+
return {
|
|
997
|
+
committed: false,
|
|
998
|
+
changed,
|
|
999
|
+
hash
|
|
1000
|
+
};
|
|
1001
|
+
},
|
|
1002
|
+
|
|
1003
|
+
// Persist the result of `checkLockFile()`. Called only after a
|
|
1004
|
+
// successful build, so a failed build repeats the forced rebuild.
|
|
1005
|
+
async commitLockFileCheck({ committed, hash }) {
|
|
1006
|
+
if (committed) {
|
|
1007
|
+
return;
|
|
1008
|
+
}
|
|
1009
|
+
await self.saveLockFileHash(hash);
|
|
1010
|
+
self.lockFileChecked = true;
|
|
1011
|
+
},
|
|
1012
|
+
|
|
864
1013
|
// Override to set externally a build watcher (a `chokidar` instance).
|
|
865
1014
|
// This method will be invoked only if/when needed.
|
|
866
1015
|
// Example:
|
|
@@ -212,28 +212,19 @@ function invoke() {
|
|
|
212
212
|
// Helper function for external build modules to find the last package
|
|
213
213
|
// change timestamp in milliseconds. Works with Node.js and npm, yarn, and
|
|
214
214
|
// pnpm package managers. Might be extended if a need arises.
|
|
215
|
+
//
|
|
216
|
+
// @deprecated Lock file modified times are unreliable (fresh checkouts,
|
|
217
|
+
// restored build artifacts, clock skew). The core now detects dependency
|
|
218
|
+
// changes by hashing the lock file content and forces a rebuild via the
|
|
219
|
+
// `lockChanged` build option, so build modules no longer need this. Kept
|
|
220
|
+
// for backwards compatibility and will be removed in the next major version.
|
|
215
221
|
async getSystemLastChangeMs() {
|
|
216
|
-
const packageLock = await
|
|
222
|
+
const packageLock = await self.findPackageLockPath();
|
|
217
223
|
if (!packageLock) {
|
|
218
224
|
return false;
|
|
219
225
|
}
|
|
220
226
|
|
|
221
227
|
return (await fs.stat(packageLock)).mtimeMs;
|
|
222
|
-
|
|
223
|
-
async function findPackageLock() {
|
|
224
|
-
const packageLockPath = path.join(self.apos.npmRootDir, 'package-lock.json');
|
|
225
|
-
const yarnPath = path.join(self.apos.npmRootDir, 'yarn.lock');
|
|
226
|
-
const pnpmPath = path.join(self.apos.npmRootDir, 'pnpm-lock.yaml');
|
|
227
|
-
if (await fs.pathExists(packageLockPath)) {
|
|
228
|
-
return packageLockPath;
|
|
229
|
-
} else if (await fs.pathExists(yarnPath)) {
|
|
230
|
-
return yarnPath;
|
|
231
|
-
} else if (await fs.pathExists(pnpmPath)) {
|
|
232
|
-
return pnpmPath;
|
|
233
|
-
} else {
|
|
234
|
-
return false;
|
|
235
|
-
}
|
|
236
|
-
}
|
|
237
228
|
},
|
|
238
229
|
|
|
239
230
|
// Retrieve saved during build core metadata. The metadata is saved in the
|
|
@@ -168,7 +168,7 @@ module.exports = (self) => {
|
|
|
168
168
|
// more information.
|
|
169
169
|
async saveBuildManifest(manifest) {
|
|
170
170
|
const {
|
|
171
|
-
entrypoints,
|
|
171
|
+
entrypoints, devServerUrl, hmrTypes
|
|
172
172
|
} = manifest;
|
|
173
173
|
const content = [];
|
|
174
174
|
|
|
@@ -186,11 +186,9 @@ module.exports = (self) => {
|
|
|
186
186
|
bundles: Array.from(bundles ?? [])
|
|
187
187
|
});
|
|
188
188
|
}
|
|
189
|
-
const current = await self.loadSavedBuildManifest(true);
|
|
190
189
|
await fs.outputJson(
|
|
191
190
|
path.join(self.getBundleRootDir(), '.manifest.json'),
|
|
192
191
|
{
|
|
193
|
-
ts: ts || current.ts,
|
|
194
192
|
devServerUrl,
|
|
195
193
|
hmrTypes,
|
|
196
194
|
manifest: content
|
|
@@ -106,6 +106,11 @@ module.exports = (self) => ({
|
|
|
106
106
|
}
|
|
107
107
|
}
|
|
108
108
|
|
|
109
|
+
// A lock file change forces a full rebuild of every build.
|
|
110
|
+
if (argv && argv.lockChanged) {
|
|
111
|
+
rebuild = true;
|
|
112
|
+
}
|
|
113
|
+
|
|
109
114
|
if (rebuild) {
|
|
110
115
|
await fs.mkdirp(bundleDir);
|
|
111
116
|
await build({
|
|
@@ -754,19 +759,8 @@ module.exports = (self) => ({
|
|
|
754
759
|
return pkgTimestamp > parseInt(timestamp);
|
|
755
760
|
}
|
|
756
761
|
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
const yarnPath = path.join(self.apos.npmRootDir, 'yarn.lock');
|
|
760
|
-
const pnpmPath = path.join(self.apos.npmRootDir, 'pnpm-lock.yaml');
|
|
761
|
-
if (await fs.pathExists(packageLockPath)) {
|
|
762
|
-
return packageLockPath;
|
|
763
|
-
} else if (await fs.pathExists(yarnPath)) {
|
|
764
|
-
return yarnPath;
|
|
765
|
-
} else if (await fs.pathExists(pnpmPath)) {
|
|
766
|
-
return pnpmPath;
|
|
767
|
-
} else {
|
|
768
|
-
return false;
|
|
769
|
-
}
|
|
762
|
+
function findPackageLock() {
|
|
763
|
+
return self.findPackageLockPath();
|
|
770
764
|
}
|
|
771
765
|
|
|
772
766
|
function getComponentName(component, { enumerateImports } = {}, i) {
|
|
@@ -55,7 +55,9 @@ export default {
|
|
|
55
55
|
shortcut.toUpperCase(),
|
|
56
56
|
{
|
|
57
57
|
...command.action,
|
|
58
|
-
requireWidgetFocus: command.requireWidgetFocus || false
|
|
58
|
+
requireWidgetFocus: command.requireWidgetFocus || false,
|
|
59
|
+
skipOnTextSelection: command.skipOnTextSelection || false,
|
|
60
|
+
trigger: command.trigger || 'keydown'
|
|
59
61
|
}
|
|
60
62
|
]);
|
|
61
63
|
});
|
|
@@ -96,7 +98,15 @@ export default {
|
|
|
96
98
|
? this.getFirstNonShortcutModal(index + -1)
|
|
97
99
|
: properties.itemName || 'default';
|
|
98
100
|
},
|
|
101
|
+
hasTextSelection() {
|
|
102
|
+
const selection = window.getSelection();
|
|
103
|
+
return !!selection && !selection.isCollapsed;
|
|
104
|
+
},
|
|
99
105
|
keyboardShortcutListener(event) {
|
|
106
|
+
// Keys already handled by more specific UI must not trigger shortcuts
|
|
107
|
+
if (event.defaultPrevented) {
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
100
110
|
if (event.target.nodeName !== 'INPUT' && event.target.nodeName !== 'TEXTAREA' && document.activeElement.contentEditable !== 'true') {
|
|
101
111
|
const key = [
|
|
102
112
|
[ 'ALT', event.altKey ],
|
|
@@ -121,6 +131,16 @@ export default {
|
|
|
121
131
|
if (action.requireWidgetFocus && !useWidgetStore().focusedWidget) {
|
|
122
132
|
return;
|
|
123
133
|
}
|
|
134
|
+
// A text selection wins over the shortcut, the browser
|
|
135
|
+
// performs its native action (e.g. copy the selected text)
|
|
136
|
+
if (action.skipOnTextSelection && this.hasTextSelection()) {
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
// Handled by a native event listener (e.g. paste) when the
|
|
140
|
+
// Clipboard API is available; keydown is the legacy fallback
|
|
141
|
+
if (action.trigger === 'native' && navigator.clipboard) {
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
124
144
|
event.preventDefault();
|
|
125
145
|
apos.bus.$emit(action.type, action.payload);
|
|
126
146
|
return;
|