apostrophe 4.30.1-beta.1 → 4.31.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 (129) hide show
  1. package/.claude/settings.local.json +15 -0
  2. package/CHANGELOG.md +22 -2
  3. package/claude-tools/detect-handles.js +46 -0
  4. package/claude-tools/minimal-hang-test.js +28 -0
  5. package/claude-tools/mongo-close-test.js +11 -0
  6. package/claude-tools/stdin-ref-test.js +14 -0
  7. package/eslint.config.js +3 -1
  8. package/modules/@apostrophecms/area/index.js +94 -2
  9. package/modules/@apostrophecms/area/lib/custom-tags/area.js +1 -40
  10. package/modules/@apostrophecms/area/ui/apos/components/AposBreadcrumbOperations.vue +0 -1
  11. package/modules/@apostrophecms/area/ui/apos/components/AposWidgetControls.vue +0 -1
  12. package/modules/@apostrophecms/attachment/index.js +4 -1
  13. package/modules/@apostrophecms/db/index.js +68 -27
  14. package/modules/@apostrophecms/doc-type/ui/apos/logic/AposDocContextMenu.js +5 -3
  15. package/modules/@apostrophecms/express/index.js +2 -0
  16. package/modules/@apostrophecms/file/index.js +9 -8
  17. package/modules/@apostrophecms/http/index.js +1 -1
  18. package/modules/@apostrophecms/i18n/i18n/en.json +3 -0
  19. package/modules/@apostrophecms/image/ui/apos/components/AposMediaManagerEditor.vue +2 -2
  20. package/modules/@apostrophecms/image/ui/apos/components/AposMediaUploader.vue +3 -0
  21. package/modules/@apostrophecms/job/index.js +9 -7
  22. package/modules/@apostrophecms/layout-widget/ui/apos/components/AposGridColumn.vue +0 -1
  23. package/modules/@apostrophecms/layout-widget/ui/apos/components/AposGridManager.vue +0 -1
  24. package/modules/@apostrophecms/login/ui/apos/components/TheAposLogin.vue +10 -2
  25. package/modules/@apostrophecms/login/ui/apos/components/TheAposLoginHeader.vue +3 -3
  26. package/modules/@apostrophecms/modal/ui/apos/components/AposModal.vue +52 -23
  27. package/modules/@apostrophecms/modal/ui/apos/components/AposModalTabs.vue +6 -1
  28. package/modules/@apostrophecms/oembed/index.js +2 -1
  29. package/modules/@apostrophecms/piece-type/index.js +2 -1
  30. package/modules/@apostrophecms/piece-type/ui/apos/components/AposDocsManagerDisplay.vue +7 -2
  31. package/modules/@apostrophecms/recently-edited/ui/apos/components/AposCellTitle.vue +1 -0
  32. package/modules/@apostrophecms/rich-text-widget/ui/apos/components/AposRichTextWidgetEditor.vue +21 -4
  33. package/modules/@apostrophecms/schema/ui/apos/components/AposArrayEditor.vue +1 -0
  34. package/modules/@apostrophecms/schema/ui/apos/components/AposInputDateAndTime.vue +7 -2
  35. package/modules/@apostrophecms/schema/ui/apos/components/AposInputSelect.vue +1 -0
  36. package/modules/@apostrophecms/schema/ui/apos/components/AposInputWrapper.vue +1 -1
  37. package/modules/@apostrophecms/schema/ui/apos/components/AposSubform.vue +1 -0
  38. package/modules/@apostrophecms/schema/ui/apos/logic/AposSubform.js +10 -0
  39. package/modules/@apostrophecms/styles/ui/apos/components/TheAposStyles.vue +1 -0
  40. package/modules/@apostrophecms/template/index.js +117 -11
  41. package/modules/@apostrophecms/template/lib/jsxLoader.js +128 -0
  42. package/modules/@apostrophecms/template/lib/jsxRender.js +490 -0
  43. package/modules/@apostrophecms/template/lib/jsxRuntime.js +276 -0
  44. package/modules/@apostrophecms/template/lib/nunjucksLoader.js +11 -36
  45. package/modules/@apostrophecms/template/lib/viewWatcher.js +113 -0
  46. package/modules/@apostrophecms/ui/ui/apos/components/AposButtonGroup.vue +1 -1
  47. package/modules/@apostrophecms/ui/ui/apos/components/AposCellLastEdited.vue +1 -1
  48. package/modules/@apostrophecms/ui/ui/apos/components/AposSelect.vue +1 -0
  49. package/modules/@apostrophecms/ui/ui/apos/components/AposSlat.vue +10 -4
  50. package/modules/@apostrophecms/ui/ui/apos/components/AposSlatList.vue +6 -1
  51. package/modules/@apostrophecms/ui/ui/apos/components/AposSubformPreview.vue +1 -1
  52. package/modules/@apostrophecms/ui/ui/apos/components/AposTreeHeader.vue +1 -1
  53. package/modules/@apostrophecms/ui/ui/apos/scss/global/_inputs.scss +2 -0
  54. package/modules/@apostrophecms/ui/ui/apos/scss/global/_theme.scss +1 -0
  55. package/modules/@apostrophecms/uploadfs/index.js +3 -0
  56. package/modules/@apostrophecms/util/index.js +20 -3
  57. package/package.json +14 -10
  58. package/test/add-missing-schema-fields-project/node_modules/.package-lock.json +131 -0
  59. package/test/add-missing-schema-fields-project/test.js +22 -3
  60. package/test/assets.js +110 -67
  61. package/test/db-tools.js +365 -0
  62. package/test/db.js +24 -15
  63. package/test/default-adapter.js +256 -0
  64. package/test/external-front.js +419 -1
  65. package/test/files.js +28 -0
  66. package/test/job.js +1 -1
  67. package/test/modules/jsx-area-test/index.js +23 -0
  68. package/test/modules/jsx-area-test/views/bad-area.jsx +7 -0
  69. package/test/modules/jsx-area-test/views/with-area-ctx.jsx +13 -0
  70. package/test/modules/jsx-area-test/views/with-area.jsx +7 -0
  71. package/test/modules/jsx-area-test/views/with-widget-ctx.jsx +12 -0
  72. package/test/modules/jsx-area-test/views/with-widget.jsx +7 -0
  73. package/test/modules/jsx-async-widget/index.js +6 -0
  74. package/test/modules/jsx-async-widget/views/widget.jsx +11 -0
  75. package/test/modules/jsx-bridge-test/index.js +1 -0
  76. package/test/modules/jsx-bridge-test/views/cross-module.jsx +7 -0
  77. package/test/modules/jsx-bridge-test/views/disambig-name-only.jsx +7 -0
  78. package/test/modules/jsx-bridge-test/views/disambig-target.jsx +8 -0
  79. package/test/modules/jsx-bridge-test/views/disambig-with-template-name.jsx +7 -0
  80. package/test/modules/jsx-bridge-test/views/include-html.jsx +7 -0
  81. package/test/modules/jsx-bridge-test/views/include-target.html +4 -0
  82. package/test/modules/jsx-bridge-test/views/jsx-extends-via-extend.jsx +9 -0
  83. package/test/modules/jsx-bridge-test/views/jsx-extends.jsx +9 -0
  84. package/test/modules/jsx-bridge-test/views/jsx-layout.jsx +14 -0
  85. package/test/modules/jsx-bridge-test/views/njk-extends.jsx +14 -0
  86. package/test/modules/jsx-bridge-test/views/njk-layout.html +9 -0
  87. package/test/modules/jsx-bridge-test/views/short-form.jsx +7 -0
  88. package/test/modules/jsx-bridge-test/views/short-target.jsx +3 -0
  89. package/test/modules/jsx-component-test/index.js +15 -0
  90. package/test/modules/jsx-component-test/views/greet.html +1 -0
  91. package/test/modules/jsx-component-test/views/uses-component.jsx +8 -0
  92. package/test/modules/jsx-ctx-widget/index.js +6 -0
  93. package/test/modules/jsx-ctx-widget/views/widget.jsx +4 -0
  94. package/test/modules/jsx-mixed-test/index.js +9 -0
  95. package/test/modules/jsx-mixed-test/views/apos-full.jsx +21 -0
  96. package/test/modules/jsx-mixed-test/views/async-list.jsx +12 -0
  97. package/test/modules/jsx-mixed-test/views/lib/format.js +3 -0
  98. package/test/modules/jsx-mixed-test/views/localized.jsx +3 -0
  99. package/test/modules/jsx-mixed-test/views/partial.jsx +3 -0
  100. package/test/modules/jsx-mixed-test/views/safe-helper.jsx +3 -0
  101. package/test/modules/jsx-mixed-test/views/syntax-error.jsx +3 -0
  102. package/test/modules/jsx-mixed-test/views/throws.jsx +5 -0
  103. package/test/modules/jsx-mixed-test/views/uses-import.jsx +5 -0
  104. package/test/modules/jsx-mixed-test/views/uses-require.jsx +5 -0
  105. package/test/modules/jsx-watcher-cross-test/index.js +5 -0
  106. package/test/modules/jsx-watcher-cross-test/views/cross-template.jsx +3 -0
  107. package/test/modules/jsx-watcher-test/index.js +5 -0
  108. package/test/modules/jsx-watcher-test/views/watcher-test.jsx +3 -0
  109. package/test/modules/template-jsx-options-test/index.js +12 -0
  110. package/test/modules/template-jsx-options-test/views/options-test.jsx +9 -0
  111. package/test/modules/template-jsx-subclass-test/index.js +3 -0
  112. package/test/modules/template-jsx-subclass-test/views/override-test.jsx +3 -0
  113. package/test/modules/template-jsx-test/index.js +9 -0
  114. package/test/modules/template-jsx-test/views/boolean-attrs.jsx +11 -0
  115. package/test/modules/template-jsx-test/views/class-and-for.jsx +7 -0
  116. package/test/modules/template-jsx-test/views/dangerously-set.jsx +3 -0
  117. package/test/modules/template-jsx-test/views/escape-attr.jsx +3 -0
  118. package/test/modules/template-jsx-test/views/escape-body.jsx +3 -0
  119. package/test/modules/template-jsx-test/views/inherit-test.jsx +3 -0
  120. package/test/modules/template-jsx-test/views/list.jsx +7 -0
  121. package/test/modules/template-jsx-test/views/override-test.jsx +3 -0
  122. package/test/modules/template-jsx-test/views/svg-attrs.jsx +27 -0
  123. package/test/modules/template-jsx-test/views/test.jsx +3 -0
  124. package/test/modules/template-jsx-test/views/void-elements.jsx +9 -0
  125. package/test/templates-jsx-watcher.js +135 -0
  126. package/test/templates-jsx.js +537 -0
  127. package/test/utils.js +103 -0
  128. package/test-lib/util.js +50 -14
  129. package/lib/mongodb-connect.js +0 -62
@@ -0,0 +1,276 @@
1
+ // JSX runtime used by Apostrophe `.jsx` templates. The classic Babel
2
+ // transform (`@babel/plugin-transform-react-jsx`) compiles `<Tag a={x}>...</Tag>`
3
+ // into `h(Tag, { a: x }, ...children)` and `<>...</>` into
4
+ // `h(Fragment, null, ...children)`. The `h` function and `Fragment` symbol
5
+ // are injected into every compiled module by `jsxLoader.js`.
6
+ //
7
+ // Output model: `h` returns a nested array of strings, `Raw` markers,
8
+ // promises, and arrays. The array is flattened by `flatten()` which awaits
9
+ // every promise, escapes plain string values, and joins everything into a
10
+ // final HTML string. This array+promise model is what allows JSX templates
11
+ // to call asynchronous helpers (`Area`, `Component`, `Template`, `Extend`)
12
+ // directly inside markup without wrapping them in `await`.
13
+
14
+ const voidElements = require('void-elements');
15
+
16
+ const Fragment = Symbol('AposJsxFragment');
17
+
18
+ // Marker class for already-rendered raw HTML. Anything wrapped in a `Raw`
19
+ // is emitted into the final output without any additional escaping.
20
+ class Raw {
21
+ constructor(html) {
22
+ this.html = (html == null) ? '' : String(html);
23
+ }
24
+ }
25
+
26
+ // Compatibility hook: Nunjucks `SafeString` instances (returned by Apostrophe
27
+ // helpers like `apos.area.html()` and the `safe` filter) need to flow through
28
+ // JSX templates without being escaped a second time. `template/index.js`
29
+ // registers Nunjucks's `SafeString` class via `registerSafeClass()`; any
30
+ // instance of a registered class is treated as raw HTML.
31
+ const safeClasses = [];
32
+
33
+ function registerSafeClass(cls) {
34
+ if (cls && !safeClasses.includes(cls)) {
35
+ safeClasses.push(cls);
36
+ }
37
+ }
38
+
39
+ function isSafeInstance(value) {
40
+ if (value == null || typeof value !== 'object') {
41
+ return false;
42
+ }
43
+ for (const cls of safeClasses) {
44
+ if (value instanceof cls) {
45
+ return true;
46
+ }
47
+ }
48
+ return false;
49
+ }
50
+
51
+ function escapeHtml(value) {
52
+ return String(value)
53
+ .replace(/&/g, '&amp;')
54
+ .replace(/</g, '&lt;')
55
+ .replace(/>/g, '&gt;');
56
+ }
57
+
58
+ function escapeAttr(value) {
59
+ return String(value)
60
+ .replace(/&/g, '&amp;')
61
+ .replace(/"/g, '&quot;')
62
+ .replace(/</g, '&lt;')
63
+ .replace(/>/g, '&gt;');
64
+ }
65
+
66
+ // Build the JSX node for an element, function component, or fragment.
67
+ // For function types (including the runtime helpers `Area`, `Component`,
68
+ // `Template`, `Extend`, `Widget`) we invoke the function with a single
69
+ // `props` object that includes `children`, matching React conventions.
70
+ function h(type, props, ...children) {
71
+ props = props || {};
72
+ if (type === Fragment) {
73
+ return children;
74
+ }
75
+ if (typeof type === 'function') {
76
+ const finalProps = { ...props };
77
+ if (children.length > 0) {
78
+ finalProps.children = (children.length === 1) ? children[0] : children;
79
+ }
80
+ return type(finalProps);
81
+ }
82
+ if (typeof type !== 'string') {
83
+ throw new Error(`Invalid JSX element type: ${typeof type}`);
84
+ }
85
+ return buildElement(type, props, children);
86
+ }
87
+
88
+ function buildElement(tag, props, children) {
89
+ let attrs = '';
90
+ let dangerous = null;
91
+ for (const key of Object.keys(props)) {
92
+ if (key === 'children' || key === 'key' || key === 'ref') {
93
+ continue;
94
+ }
95
+ if (key === 'dangerouslySetInnerHTML') {
96
+ const v = props[key];
97
+ if (v && typeof v.__html === 'string') {
98
+ dangerous = v.__html;
99
+ } else if (v && v.__html != null) {
100
+ dangerous = String(v.__html);
101
+ }
102
+ continue;
103
+ }
104
+ const value = props[key];
105
+ if (value == null || value === false) {
106
+ continue;
107
+ }
108
+ const attrName = jsxAttrName(key);
109
+ if (value === true) {
110
+ attrs += ` ${attrName}`;
111
+ } else {
112
+ attrs += ` ${attrName}="${escapeAttr(value)}"`;
113
+ }
114
+ }
115
+
116
+ if (dangerous != null) {
117
+ return [
118
+ new Raw(`<${tag}${attrs}>`),
119
+ new Raw(dangerous),
120
+ new Raw(`</${tag}>`)
121
+ ];
122
+ }
123
+
124
+ if (voidElements[tag] && children.length === 0) {
125
+ return [ new Raw(`<${tag}${attrs} />`) ];
126
+ }
127
+
128
+ return [
129
+ new Raw(`<${tag}${attrs}>`),
130
+ ...children,
131
+ new Raw(`</${tag}>`)
132
+ ];
133
+ }
134
+
135
+ // Match React's friendly attribute names to standard HTML/SVG attribute
136
+ // names. `data-*` and `aria-*` props pass through verbatim; SVG presentation
137
+ // attributes (`strokeWidth`, `fillRule`, etc.) are converted to the
138
+ // kebab-case form actually understood by browsers when the document is
139
+ // parsed as text/html. The handful of SVG attributes that are *natively*
140
+ // camelCase (e.g. `viewBox`, `preserveAspectRatio`) stay as-is.
141
+ const svgAttrMap = {
142
+ // Stroke
143
+ strokeWidth: 'stroke-width',
144
+ strokeLinecap: 'stroke-linecap',
145
+ strokeLinejoin: 'stroke-linejoin',
146
+ strokeDasharray: 'stroke-dasharray',
147
+ strokeDashoffset: 'stroke-dashoffset',
148
+ strokeMiterlimit: 'stroke-miterlimit',
149
+ strokeOpacity: 'stroke-opacity',
150
+ // Fill
151
+ fillOpacity: 'fill-opacity',
152
+ fillRule: 'fill-rule',
153
+ // Clip / mask
154
+ clipPath: 'clip-path',
155
+ clipRule: 'clip-rule',
156
+ // Text
157
+ textAnchor: 'text-anchor',
158
+ textDecoration: 'text-decoration',
159
+ alignmentBaseline: 'alignment-baseline',
160
+ baselineShift: 'baseline-shift',
161
+ dominantBaseline: 'dominant-baseline',
162
+ fontFamily: 'font-family',
163
+ fontSize: 'font-size',
164
+ fontStyle: 'font-style',
165
+ fontVariant: 'font-variant',
166
+ fontWeight: 'font-weight',
167
+ letterSpacing: 'letter-spacing',
168
+ wordSpacing: 'word-spacing',
169
+ // Generic
170
+ colorInterpolation: 'color-interpolation',
171
+ colorInterpolationFilters: 'color-interpolation-filters',
172
+ colorProfile: 'color-profile',
173
+ colorRendering: 'color-rendering',
174
+ fillRendering: 'fill-rendering',
175
+ imageRendering: 'image-rendering',
176
+ shapeRendering: 'shape-rendering',
177
+ textRendering: 'text-rendering',
178
+ pointerEvents: 'pointer-events',
179
+ unicodeBidi: 'unicode-bidi',
180
+ vectorEffect: 'vector-effect',
181
+ writingMode: 'writing-mode',
182
+ enableBackground: 'enable-background',
183
+ floodColor: 'flood-color',
184
+ floodOpacity: 'flood-opacity',
185
+ glyphOrientationHorizontal: 'glyph-orientation-horizontal',
186
+ glyphOrientationVertical: 'glyph-orientation-vertical',
187
+ lightingColor: 'lighting-color',
188
+ markerEnd: 'marker-end',
189
+ markerMid: 'marker-mid',
190
+ markerStart: 'marker-start',
191
+ overflowWrap: 'overflow-wrap',
192
+ paintOrder: 'paint-order',
193
+ stopColor: 'stop-color',
194
+ stopOpacity: 'stop-opacity',
195
+ // Linked references
196
+ xlinkHref: 'xlink:href',
197
+ xlinkRole: 'xlink:role',
198
+ xlinkTitle: 'xlink:title',
199
+ xlinkType: 'xlink:type',
200
+ xlinkArcrole: 'xlink:arcrole',
201
+ xlinkActuate: 'xlink:actuate',
202
+ xlinkShow: 'xlink:show',
203
+ xmlBase: 'xml:base',
204
+ xmlLang: 'xml:lang',
205
+ xmlSpace: 'xml:space',
206
+ xmlnsXlink: 'xmlns:xlink'
207
+ };
208
+
209
+ function jsxAttrName(name) {
210
+ if (name === 'className') {
211
+ return 'class';
212
+ }
213
+ if (name === 'htmlFor') {
214
+ return 'for';
215
+ }
216
+ const svg = svgAttrMap[name];
217
+ if (svg) {
218
+ return svg;
219
+ }
220
+ return name;
221
+ }
222
+
223
+ // Walk the tree of strings, Raw markers, promises, and arrays, awaiting
224
+ // every promise and producing a final HTML string. Plain strings/numbers
225
+ // are escaped; Raw and Nunjucks SafeString values are not.
226
+ async function flatten(node) {
227
+ const out = [];
228
+ await walk(node, out);
229
+ return out.join('');
230
+ }
231
+
232
+ async function walk(node, out) {
233
+ if (node == null || node === false || node === true) {
234
+ return;
235
+ }
236
+ if (Array.isArray(node)) {
237
+ for (const item of node) {
238
+ await walk(item, out);
239
+ }
240
+ return;
241
+ }
242
+ if (node instanceof Raw) {
243
+ out.push(node.html);
244
+ return;
245
+ }
246
+ if (isSafeInstance(node)) {
247
+ out.push(node.toString());
248
+ return;
249
+ }
250
+ if (typeof node === 'object' && typeof node.then === 'function') {
251
+ const resolved = await node;
252
+ await walk(resolved, out);
253
+ return;
254
+ }
255
+ if (typeof node === 'string') {
256
+ out.push(escapeHtml(node));
257
+ return;
258
+ }
259
+ if (typeof node === 'number' || typeof node === 'bigint') {
260
+ out.push(escapeHtml(String(node)));
261
+ return;
262
+ }
263
+ // Anything else (objects without a known handler) is coerced to string
264
+ // and escaped, mirroring how React stringifies unexpected children.
265
+ out.push(escapeHtml(String(node)));
266
+ }
267
+
268
+ module.exports = {
269
+ h,
270
+ Fragment,
271
+ Raw,
272
+ flatten,
273
+ escapeHtml,
274
+ escapeAttr,
275
+ registerSafeClass
276
+ };
@@ -7,23 +7,26 @@
7
7
  // Note that if @apostrophecms/template has a project-level override
8
8
  // of outerLayout.html, that will be loaded instead. This is
9
9
  // intentional.
10
+ //
11
+ // File watching and cache invalidation are deliberately NOT handled here:
12
+ // the template module wires up `viewWatcher.js` once for both Nunjucks and
13
+ // JSX, and clears every loader's `cache` (read by Nunjucks itself) when a
14
+ // view file changes.
10
15
 
11
16
  const fs = require('fs');
12
17
  const path = require('path');
13
18
  const _ = require('lodash');
14
19
  const { stripIndent } = require('common-tags');
15
- const chokidar = require('chokidar');
16
20
 
17
21
  module.exports = function(moduleName, searchPaths, noWatch, templates, options) {
18
22
 
19
23
  const self = this;
20
- self.watches = [];
21
24
  options = options || {};
22
25
  const extensions = options.extensions || [ 'njk', 'html' ];
23
26
  self.moduleName = moduleName;
24
27
  self.templates = templates;
25
28
 
26
- self.init = function(searchPaths, noWatch) {
29
+ self.init = function(searchPaths) {
27
30
  self.pathsToNames = {};
28
31
  if (searchPaths) {
29
32
  searchPaths = Array.isArray(searchPaths) ? searchPaths : [ searchPaths ];
@@ -32,34 +35,6 @@ module.exports = function(moduleName, searchPaths, noWatch, templates, options)
32
35
  } else {
33
36
  self.searchPaths = [];
34
37
  }
35
- // Unless and until chokidar declares this a supported config,
36
- // no watching in WSL (it doesn't work without chokidar either)
37
- if ((!noWatch) && (!require('is-wsl'))) {
38
- _.each(self.searchPaths, function(p) {
39
- if (fs.existsSync(p)) {
40
- try {
41
- const watcher = chokidar.watch(p);
42
- watcher.on('change', (path, stats) => {
43
- // Just blow the whole cache if anything is modified. Much
44
- // simpler, avoids several false negatives, and works well for a
45
- // CMS in dev. -Tom
46
- self.cache = {};
47
- });
48
- self.watches.push(watcher);
49
- } catch (e) {
50
- if (!self.firstWatchFailure) {
51
- // Don't crash in broken environments (not sure if any are left
52
- // thanks to chokidar, but still a useful warning to have if it
53
- // comes up)
54
- self.firstWatchFailure = true;
55
- self.templates.apos.util.warn('WARNING: fs.watch does not work on this system. That is OK but you\n' +
56
- 'will have to restart to see any template changes take effect.');
57
- }
58
- self.templates.apos.util.error(e);
59
- }
60
- }
61
- });
62
- }
63
38
  };
64
39
 
65
40
  self.isRelative = function(filename) {
@@ -183,11 +158,11 @@ module.exports = function(moduleName, searchPaths, noWatch, templates, options)
183
158
  }
184
159
  };
185
160
 
186
- self.destroy = async () => {
187
- for (const watch of self.watches) {
188
- await watch.close();
189
- }
190
- };
161
+ // Retained for backwards compatibility — file watching now lives in
162
+ // `viewWatcher.js`, owned by the template module itself, so there is
163
+ // nothing for the loader to dispose of. Callers (Apostrophe internals
164
+ // and tests) may still invoke `destroy()` and that must keep working.
165
+ self.destroy = async () => {};
191
166
 
192
167
  self.init(searchPaths, noWatch);
193
168
  };
@@ -0,0 +1,113 @@
1
+ // File-watching for view directories shared by Nunjucks and JSX.
2
+ //
3
+ // Owns one chokidar watcher per directory and dispatches change events to
4
+ // any number of registered handlers. The template module wires this up at
5
+ // init time with two handlers: clear every Nunjucks loader's cache, and
6
+ // invalidate compiled `.jsx` modules so the next render reloads them.
7
+ //
8
+ // WSL safety: chokidar's underlying file events are unreliable on WSL, so
9
+ // we deliberately skip watching there — same behavior as the previous
10
+ // in-loader code. Developers running on WSL will need to restart to pick
11
+ // up template edits, but the rest of Apostrophe still functions.
12
+ //
13
+ // Returns a methods mixin compatible with `@apostrophecms/module`'s
14
+ // `methods` factory pattern, the same way `bundlesLoader` and `jsxRender`
15
+ // are mixed in.
16
+
17
+ const fs = require('fs');
18
+ const chokidar = require('chokidar');
19
+ const isWsl = require('is-wsl');
20
+
21
+ module.exports = (self) => {
22
+ // Lazily-allocated state. Lives on `self` so it survives across method
23
+ // calls and can be inspected by tests if needed.
24
+ if (!self._viewWatchers) {
25
+ self._viewWatchers = [];
26
+ }
27
+ if (!self._viewWatchedDirs) {
28
+ self._viewWatchedDirs = new Set();
29
+ }
30
+ if (!self._viewChangeHandlers) {
31
+ self._viewChangeHandlers = [];
32
+ }
33
+
34
+ return {
35
+ // Begin watching the supplied directories for change events. Idempotent
36
+ // per absolute path: subsequent calls with the same dir do not create
37
+ // duplicate watchers. Honors the WSL skip flag and the loader's
38
+ // `noWatch` option (templates module exposes this via
39
+ // `options.loader.noWatch`).
40
+ watchViewFolders(dirs) {
41
+ if (self.options.loader && self.options.loader.noWatch) {
42
+ return;
43
+ }
44
+ // In production the file tree is static and watching only burns
45
+ // file descriptors. Honor NODE_ENV unconditionally.
46
+ if (process.env.NODE_ENV === 'production') {
47
+ return;
48
+ }
49
+ // chokidar's recursive watching is not reliable on WSL; preserve the
50
+ // historical behavior of skipping watch setup entirely there.
51
+ if (isWsl) {
52
+ return;
53
+ }
54
+ for (const dir of dirs) {
55
+ if (self._viewWatchedDirs.has(dir)) {
56
+ continue;
57
+ }
58
+ if (!fs.existsSync(dir)) {
59
+ continue;
60
+ }
61
+ self._viewWatchedDirs.add(dir);
62
+ try {
63
+ const watcher = chokidar.watch(dir);
64
+ watcher.on('change', (filePath) => {
65
+ for (const handler of self._viewChangeHandlers) {
66
+ try {
67
+ handler(filePath);
68
+ } catch (e) {
69
+ self.apos.util.error(e);
70
+ }
71
+ }
72
+ });
73
+ self._viewWatchers.push(watcher);
74
+ } catch (e) {
75
+ // Don't crash in broken environments. Warn at most once so the
76
+ // logs don't get spammed for every dir we fail to watch.
77
+ if (!self._viewFirstWatchFailure) {
78
+ self._viewFirstWatchFailure = true;
79
+ self.apos.util.warn(
80
+ 'WARNING: fs.watch does not work on this system. That is OK but you\n' +
81
+ 'will have to restart to see any template changes take effect.'
82
+ );
83
+ }
84
+ self.apos.util.error(e);
85
+ }
86
+ }
87
+ },
88
+
89
+ // Register a callback invoked with the absolute path of the changed
90
+ // file every time chokidar reports a `change` event. Handlers run in
91
+ // registration order. Errors thrown by a handler are logged but never
92
+ // stop other handlers from running.
93
+ onViewChange(handler) {
94
+ self._viewChangeHandlers.push(handler);
95
+ },
96
+
97
+ // Tear down every chokidar watcher created via `watchViewFolders`.
98
+ // Called from the `apostrophe:destroy` handler so a process that
99
+ // builds and tears down multiple Apostrophe instances (tests, the
100
+ // multisite harness) doesn't leak file descriptors.
101
+ async closeViewWatchers() {
102
+ const watchers = self._viewWatchers.splice(0, self._viewWatchers.length);
103
+ self._viewWatchedDirs.clear();
104
+ for (const watcher of watchers) {
105
+ try {
106
+ await watcher.close();
107
+ } catch (e) {
108
+ self.apos.util.error(e);
109
+ }
110
+ }
111
+ }
112
+ };
113
+ };
@@ -2,7 +2,7 @@
2
2
  <div
3
3
  class="apos-button-group"
4
4
  :class="modifierClass"
5
- role="menubar"
5
+ role="toolbar"
6
6
  >
7
7
  <div class="apos-button-group__inner">
8
8
  <slot />
@@ -70,6 +70,6 @@ export default {
70
70
 
71
71
  <style lang="scss" scoped>
72
72
  .apos-table__cell-field--relative-time {
73
- color: var(--a-base-4);
73
+ color: var(--a-base-2);
74
74
  }
75
75
  </style>
@@ -4,6 +4,7 @@
4
4
  :class="wrapperClasses"
5
5
  >
6
6
  <select
7
+ :id="uid"
7
8
  class="apos-input apos-input--select"
8
9
  :class="classes"
9
10
  :uid="uid"
@@ -8,8 +8,9 @@
8
8
  'apos-is-only-child': slatCount === 1,
9
9
  'apos-is-selected': selected,
10
10
  'apos-is-disabled': disabled,
11
+ 'apos-is-not-draggable': !draggable,
11
12
  }"
12
- :aria-pressed="engaged"
13
+ :aria-current="engaged"
13
14
  role="listitem"
14
15
  :aria-labelledby="parent"
15
16
  @keydown.space="toggleEngage"
@@ -22,7 +23,7 @@
22
23
  >
23
24
  <div class="apos-slat__main">
24
25
  <drag-icon
25
- v-if="slatCount > 1"
26
+ v-if="slatCount > 1 && draggable"
26
27
  class="apos-slat__control apos-slat__control--drag"
27
28
  :size="13"
28
29
  />
@@ -158,6 +159,10 @@ export default {
158
159
  editorIcon: {
159
160
  type: String,
160
161
  default: null
162
+ },
163
+ draggable: {
164
+ type: Boolean,
165
+ default: true
161
166
  }
162
167
  },
163
168
  emits: [ 'engage', 'disengage', 'move', 'remove', 'item-clicked', 'select' ],
@@ -209,7 +214,7 @@ export default {
209
214
  return e.target.click();
210
215
  },
211
216
  toggleEngage() {
212
- if (this.slatCount > 1) {
217
+ if (this.draggable && this.slatCount > 1) {
213
218
  if (this.engaged) {
214
219
  this.disengage();
215
220
  } else {
@@ -278,7 +283,8 @@ export default {
278
283
  }
279
284
 
280
285
  &.apos-slat-list__item--disabled,
281
- &.apos-is-only-child {
286
+ &.apos-is-only-child,
287
+ &.apos-is-not-draggable {
282
288
  &:hover,
283
289
  &:active {
284
290
  cursor: default;
@@ -26,6 +26,7 @@
26
26
  'apos-input--error': duplicate
27
27
  }"
28
28
  :disabled="disabled"
29
+ :draggable="draggable"
29
30
  :engaged="engaged === item._id"
30
31
  :parent="listId"
31
32
  :slat-count="next.length"
@@ -87,6 +88,10 @@ export default {
87
88
  duplicate: {
88
89
  type: String,
89
90
  default: null
91
+ },
92
+ draggable: {
93
+ type: Boolean,
94
+ default: true
90
95
  }
91
96
  },
92
97
  emits: [ 'item-clicked', 'select', 'update:modelValue' ],
@@ -104,7 +109,7 @@ export default {
104
109
  dragOptions() {
105
110
  return {
106
111
  animation: 0,
107
- disabled: this.disabled || this.next.length <= 1,
112
+ disabled: !this.draggable || this.disabled || this.next.length <= 1,
108
113
  ghostClass: 'apos-is-dragging'
109
114
  };
110
115
  }
@@ -140,7 +140,7 @@ function inferFieldValues(schema, values, $t) {
140
140
  & {
141
141
  display: inline-block;
142
142
  line-height: 1;
143
- color: var(--a-base-3);
143
+ color: var(--a-base-2);
144
144
  }
145
145
  }
146
146
  }
@@ -120,7 +120,7 @@ export default {
120
120
  @import '../scss/shared/_table-rows';
121
121
 
122
122
  .apos-tree__header {
123
- color: var(--a-base-3);
123
+ color: var(--a-base-2);
124
124
  }
125
125
 
126
126
  .apos-tree__header.apos-tree__header--hidden {
@@ -372,6 +372,8 @@
372
372
  @include type-base;
373
373
 
374
374
  & {
375
+ // Do not remove - it fixes unintended `sr-only` side effect.
376
+ position: relative;
375
377
  display: flex;
376
378
  align-items: center;
377
379
  color: var(--a-base-2);
@@ -3,6 +3,7 @@
3
3
  --a-danger: #eb443b;
4
4
  --a-danger-fade: #eb443b30;
5
5
  --a-success: #00bf9a;
6
+ --a-success-dark: #006650;
6
7
  --a-success-fade: #00bf9a30;
7
8
  --a-warning: #ffce00;
8
9
  --a-warning-dark: #a75c07;
@@ -41,6 +41,9 @@ module.exports = {
41
41
  const uploadfsSettings = {};
42
42
  _.merge(uploadfsSettings, uploadfsDefaultSettings);
43
43
  _.merge(uploadfsSettings, options);
44
+ if (process.env.APOS_UPLOADFS_DISABLED_FILE_KEY) {
45
+ uploadfsSettings.disabledFileKey = process.env.APOS_UPLOADFS_DISABLED_FILE_KEY;
46
+ }
44
47
  if (process.env.APOS_S3_BUCKET) {
45
48
  _.merge(uploadfsSettings, {
46
49
  backend: 's3',
@@ -35,6 +35,13 @@ const util = require('util');
35
35
  const { stripIndent } = require('common-tags');
36
36
  const glob = require('../../../lib/glob.js');
37
37
 
38
+ // Dot-path segments that must never be traversed when walking a
39
+ // user-supplied path in `apos.util.get` and `apos.util.set`. Following any
40
+ // of these reaches the prototype chain and enables server-side prototype
41
+ // pollution (CWE-1321), e.g. a PATCH `$pullAll` key of
42
+ // `__proto__.publicApiProjection`.
43
+ const unsafePathSegments = new Set([ '__proto__', 'constructor', 'prototype' ]);
44
+
38
45
  module.exports = {
39
46
  options: {
40
47
  alias: 'util',
@@ -724,7 +731,7 @@ module.exports = {
724
731
  },
725
732
  // Given a widget or doc, return the appropriate manager module. If the manager
726
733
  // cannot be determined for any reason, undefined is returned.
727
- getManagerOf(object) {
734
+ getManagerOf(object, { log = true } = {}) {
728
735
  if (object.metaType === 'doc') {
729
736
  return self.apos.doc.getManager(object.type);
730
737
  } else if (object.metaType === 'widget') {
@@ -733,10 +740,10 @@ module.exports = {
733
740
  return self.apos.schema.getArrayManager(object.scopedArrayName);
734
741
  } else if (object.metaType === 'object') {
735
742
  return self.apos.schema.getObjectManager(object.scopedObjectName);
736
- } else {
743
+ } else if (log) {
737
744
  self.apos.util.error(`Unsupported metaType in getManagerOf: ${object.metaType}`);
738
- return undefined;
739
745
  }
746
+ return undefined;
740
747
  },
741
748
  // fetch the value at the given path from the object or
742
749
  // array `o`. `path` supports dot notation like MongoDB, and
@@ -755,6 +762,10 @@ module.exports = {
755
762
  if (o == null) {
756
763
  return undefined;
757
764
  }
765
+ if (unsafePathSegments.has(p)) {
766
+ // Never read through the prototype chain (CWE-1321)
767
+ return undefined;
768
+ }
758
769
  o = o[p];
759
770
  }
760
771
  }
@@ -819,6 +830,12 @@ module.exports = {
819
830
  }
820
831
  }
821
832
  path = path.split('.');
833
+ for (p of path) {
834
+ if (unsafePathSegments.has(p)) {
835
+ // Refuse to write through the prototype chain (CWE-1321)
836
+ throw self.apos.error('invalid', `Unsafe property name "${p}" in dot path`);
837
+ }
838
+ }
822
839
  for (i = 0; (i < (path.length - 1)); i++) {
823
840
  p = path[i];
824
841
  o = o[p];