apostrophe 4.30.0 → 4.31.0-alpha.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (125) hide show
  1. package/CHANGELOG.md +67 -2
  2. package/claude-tools/detect-handles.js +46 -0
  3. package/claude-tools/minimal-hang-test.js +28 -0
  4. package/claude-tools/mongo-close-test.js +11 -0
  5. package/claude-tools/stdin-ref-test.js +14 -0
  6. package/eslint.config.js +3 -1
  7. package/modules/@apostrophecms/area/index.js +94 -2
  8. package/modules/@apostrophecms/area/lib/custom-tags/area.js +1 -40
  9. package/modules/@apostrophecms/area/ui/apos/components/AposBreadcrumbOperations.vue +0 -1
  10. package/modules/@apostrophecms/area/ui/apos/components/AposWidgetControls.vue +0 -1
  11. package/modules/@apostrophecms/attachment/index.js +4 -1
  12. package/modules/@apostrophecms/db/index.js +68 -27
  13. package/modules/@apostrophecms/doc-type/ui/apos/logic/AposDocContextMenu.js +5 -3
  14. package/modules/@apostrophecms/express/index.js +2 -0
  15. package/modules/@apostrophecms/http/index.js +1 -1
  16. package/modules/@apostrophecms/i18n/i18n/en.json +3 -0
  17. package/modules/@apostrophecms/image/ui/apos/components/AposMediaManagerEditor.vue +2 -2
  18. package/modules/@apostrophecms/job/index.js +9 -7
  19. package/modules/@apostrophecms/layout-widget/ui/apos/components/AposGridColumn.vue +0 -1
  20. package/modules/@apostrophecms/layout-widget/ui/apos/components/AposGridManager.vue +0 -1
  21. package/modules/@apostrophecms/login/ui/apos/components/TheAposLogin.vue +10 -2
  22. package/modules/@apostrophecms/login/ui/apos/components/TheAposLoginHeader.vue +3 -3
  23. package/modules/@apostrophecms/modal/ui/apos/components/AposModal.vue +52 -23
  24. package/modules/@apostrophecms/modal/ui/apos/components/AposModalTabs.vue +6 -1
  25. package/modules/@apostrophecms/oembed/index.js +2 -1
  26. package/modules/@apostrophecms/piece-page-type/index.js +7 -0
  27. package/modules/@apostrophecms/piece-type/index.js +2 -1
  28. package/modules/@apostrophecms/piece-type/ui/apos/components/AposDocsManagerDisplay.vue +7 -2
  29. package/modules/@apostrophecms/recently-edited/ui/apos/components/AposCellTitle.vue +1 -0
  30. package/modules/@apostrophecms/rich-text-widget/ui/apos/components/AposRichTextWidgetEditor.vue +21 -4
  31. package/modules/@apostrophecms/schema/ui/apos/components/AposArrayEditor.vue +1 -0
  32. package/modules/@apostrophecms/schema/ui/apos/components/AposInputDateAndTime.vue +7 -2
  33. package/modules/@apostrophecms/schema/ui/apos/components/AposInputSelect.vue +1 -0
  34. package/modules/@apostrophecms/schema/ui/apos/components/AposInputWrapper.vue +1 -1
  35. package/modules/@apostrophecms/schema/ui/apos/components/AposSubform.vue +1 -0
  36. package/modules/@apostrophecms/schema/ui/apos/logic/AposSubform.js +10 -0
  37. package/modules/@apostrophecms/styles/ui/apos/components/TheAposStyles.vue +1 -0
  38. package/modules/@apostrophecms/template/index.js +117 -11
  39. package/modules/@apostrophecms/template/lib/jsxLoader.js +128 -0
  40. package/modules/@apostrophecms/template/lib/jsxRender.js +490 -0
  41. package/modules/@apostrophecms/template/lib/jsxRuntime.js +276 -0
  42. package/modules/@apostrophecms/template/lib/nunjucksLoader.js +11 -36
  43. package/modules/@apostrophecms/template/lib/viewWatcher.js +113 -0
  44. package/modules/@apostrophecms/ui/ui/apos/components/AposButtonGroup.vue +1 -1
  45. package/modules/@apostrophecms/ui/ui/apos/components/AposCellLastEdited.vue +1 -1
  46. package/modules/@apostrophecms/ui/ui/apos/components/AposSelect.vue +1 -0
  47. package/modules/@apostrophecms/ui/ui/apos/components/AposSlat.vue +10 -4
  48. package/modules/@apostrophecms/ui/ui/apos/components/AposSlatList.vue +6 -1
  49. package/modules/@apostrophecms/ui/ui/apos/components/AposSubformPreview.vue +1 -1
  50. package/modules/@apostrophecms/ui/ui/apos/components/AposTreeHeader.vue +1 -1
  51. package/modules/@apostrophecms/ui/ui/apos/scss/global/_theme.scss +1 -0
  52. package/modules/@apostrophecms/uploadfs/index.js +3 -0
  53. package/modules/@apostrophecms/util/index.js +3 -3
  54. package/package.json +14 -10
  55. package/test/add-missing-schema-fields-project/test.js +22 -3
  56. package/test/assets.js +110 -67
  57. package/test/db-tools.js +365 -0
  58. package/test/db.js +24 -15
  59. package/test/default-adapter.js +256 -0
  60. package/test/external-front.js +419 -1
  61. package/test/job.js +1 -1
  62. package/test/modules/jsx-area-test/index.js +23 -0
  63. package/test/modules/jsx-area-test/views/bad-area.jsx +7 -0
  64. package/test/modules/jsx-area-test/views/with-area-ctx.jsx +13 -0
  65. package/test/modules/jsx-area-test/views/with-area.jsx +7 -0
  66. package/test/modules/jsx-area-test/views/with-widget-ctx.jsx +12 -0
  67. package/test/modules/jsx-area-test/views/with-widget.jsx +7 -0
  68. package/test/modules/jsx-async-widget/index.js +6 -0
  69. package/test/modules/jsx-async-widget/views/widget.jsx +11 -0
  70. package/test/modules/jsx-bridge-test/index.js +1 -0
  71. package/test/modules/jsx-bridge-test/views/cross-module.jsx +7 -0
  72. package/test/modules/jsx-bridge-test/views/disambig-name-only.jsx +7 -0
  73. package/test/modules/jsx-bridge-test/views/disambig-target.jsx +8 -0
  74. package/test/modules/jsx-bridge-test/views/disambig-with-template-name.jsx +7 -0
  75. package/test/modules/jsx-bridge-test/views/include-html.jsx +7 -0
  76. package/test/modules/jsx-bridge-test/views/include-target.html +4 -0
  77. package/test/modules/jsx-bridge-test/views/jsx-extends-via-extend.jsx +9 -0
  78. package/test/modules/jsx-bridge-test/views/jsx-extends.jsx +9 -0
  79. package/test/modules/jsx-bridge-test/views/jsx-layout.jsx +14 -0
  80. package/test/modules/jsx-bridge-test/views/njk-extends.jsx +14 -0
  81. package/test/modules/jsx-bridge-test/views/njk-layout.html +9 -0
  82. package/test/modules/jsx-bridge-test/views/short-form.jsx +7 -0
  83. package/test/modules/jsx-bridge-test/views/short-target.jsx +3 -0
  84. package/test/modules/jsx-component-test/index.js +15 -0
  85. package/test/modules/jsx-component-test/views/greet.html +1 -0
  86. package/test/modules/jsx-component-test/views/uses-component.jsx +8 -0
  87. package/test/modules/jsx-ctx-widget/index.js +6 -0
  88. package/test/modules/jsx-ctx-widget/views/widget.jsx +4 -0
  89. package/test/modules/jsx-mixed-test/index.js +9 -0
  90. package/test/modules/jsx-mixed-test/views/apos-full.jsx +21 -0
  91. package/test/modules/jsx-mixed-test/views/async-list.jsx +12 -0
  92. package/test/modules/jsx-mixed-test/views/lib/format.js +3 -0
  93. package/test/modules/jsx-mixed-test/views/localized.jsx +3 -0
  94. package/test/modules/jsx-mixed-test/views/partial.jsx +3 -0
  95. package/test/modules/jsx-mixed-test/views/safe-helper.jsx +3 -0
  96. package/test/modules/jsx-mixed-test/views/syntax-error.jsx +3 -0
  97. package/test/modules/jsx-mixed-test/views/throws.jsx +5 -0
  98. package/test/modules/jsx-mixed-test/views/uses-import.jsx +5 -0
  99. package/test/modules/jsx-mixed-test/views/uses-require.jsx +5 -0
  100. package/test/modules/jsx-watcher-cross-test/index.js +5 -0
  101. package/test/modules/jsx-watcher-cross-test/views/cross-template.jsx +3 -0
  102. package/test/modules/jsx-watcher-test/index.js +5 -0
  103. package/test/modules/jsx-watcher-test/views/watcher-test.jsx +3 -0
  104. package/test/modules/template-jsx-options-test/index.js +12 -0
  105. package/test/modules/template-jsx-options-test/views/options-test.jsx +9 -0
  106. package/test/modules/template-jsx-subclass-test/index.js +3 -0
  107. package/test/modules/template-jsx-subclass-test/views/override-test.jsx +3 -0
  108. package/test/modules/template-jsx-test/index.js +9 -0
  109. package/test/modules/template-jsx-test/views/boolean-attrs.jsx +11 -0
  110. package/test/modules/template-jsx-test/views/class-and-for.jsx +7 -0
  111. package/test/modules/template-jsx-test/views/dangerously-set.jsx +3 -0
  112. package/test/modules/template-jsx-test/views/escape-attr.jsx +3 -0
  113. package/test/modules/template-jsx-test/views/escape-body.jsx +3 -0
  114. package/test/modules/template-jsx-test/views/inherit-test.jsx +3 -0
  115. package/test/modules/template-jsx-test/views/list.jsx +7 -0
  116. package/test/modules/template-jsx-test/views/override-test.jsx +3 -0
  117. package/test/modules/template-jsx-test/views/svg-attrs.jsx +27 -0
  118. package/test/modules/template-jsx-test/views/test.jsx +3 -0
  119. package/test/modules/template-jsx-test/views/void-elements.jsx +9 -0
  120. package/test/templates-jsx-watcher.js +135 -0
  121. package/test/templates-jsx.js +537 -0
  122. package/test-lib/util.js +50 -14
  123. package/.claude/settings.local.json +0 -15
  124. package/lib/mongodb-connect.js +0 -62
  125. package/test/add-missing-schema-fields-project/node_modules/.package-lock.json +0 -131
@@ -17,12 +17,430 @@ describe('External Front', function() {
17
17
 
18
18
  it('apostrophe should initialize normally', async function() {
19
19
  apos = await t.create({
20
- root: module
20
+ root: module,
21
+ modules: {
22
+ product: {
23
+ extend: '@apostrophecms/piece-type',
24
+ options: {
25
+ alias: 'product'
26
+ },
27
+ fields: {
28
+ add: {
29
+ main: {
30
+ type: 'area',
31
+ options: {
32
+ widgets: {
33
+ '@apostrophecms/rich-text': {}
34
+ }
35
+ }
36
+ },
37
+ extra: {
38
+ type: 'area',
39
+ options: {
40
+ widgets: {
41
+ '@apostrophecms/rich-text': {}
42
+ }
43
+ }
44
+ }
45
+ }
46
+ }
47
+ }
48
+ }
21
49
  });
22
50
 
23
51
  assert(apos.page.__meta.name === '@apostrophecms/page');
24
52
  });
25
53
 
54
+ // A doc as it would look in memory, with `extra` simulating an area field
55
+ // added to the schema after the doc was created (so it has no value).
56
+ function productMissingExtra() {
57
+ const doc = apos.product.newInstance();
58
+ doc._id = 'product1:en:published';
59
+ doc.metaType = 'doc';
60
+ doc.title = 'Product 1';
61
+ doc.slug = 'product-1';
62
+ delete doc.extra;
63
+ return doc;
64
+ }
65
+
66
+ it('missingSchemaAreas returns unfilled area fields, ignores non-schema objects', function() {
67
+ const missing = apos.template.missingSchemaAreas(productMissingExtra());
68
+ assert.strictEqual(missing.length, 1);
69
+ assert.strictEqual(missing[0].name, 'extra');
70
+
71
+ // An area object carries `_edit` but has no schema manager: returns []
72
+ // quietly (getManagerOf called with log: false).
73
+ const area = {
74
+ metaType: 'area',
75
+ _edit: true
76
+ };
77
+ assert.deepStrictEqual(apos.template.missingSchemaAreas(area), []);
78
+ });
79
+
80
+ it('annotateDocForExternalFront materializes missing areas on an editable doc', async function() {
81
+ const doc = productMissingExtra();
82
+ doc._edit = true;
83
+ // As loaded for an editor, existing areas carry _edit too
84
+ doc.main._edit = true;
85
+
86
+ await apos.template.annotateDocForExternalFront(doc);
87
+
88
+ // Existing area still annotated, unchanged behavior
89
+ assert(doc.main.field && doc.main.field.name === 'main');
90
+ assert(doc.main.options);
91
+ // Carries the provenance signal, and is never flagged as orphan
92
+ assert.strictEqual(doc.main._aposAnnotated, true);
93
+ assert.strictEqual(doc.main._isOrphan, undefined);
94
+
95
+ // Missing area added as an empty, editable, annotated area
96
+ assert(doc.extra, 'extra area was materialized');
97
+ assert.strictEqual(doc.extra.metaType, 'area');
98
+ assert.deepStrictEqual(doc.extra.items, []);
99
+ assert.strictEqual(doc.extra._edit, true);
100
+ assert.strictEqual(doc.extra._docId, doc._id);
101
+ assert(doc.extra._id, 'has an id');
102
+ assert(doc.extra.field && doc.extra.field.name === 'extra');
103
+ assert(doc.extra.options, 'annotated with options');
104
+ assert(Array.isArray(doc.extra.choices));
105
+ assert.strictEqual(doc.extra._aposAnnotated, true);
106
+ assert.strictEqual(doc.extra._isOrphan, undefined);
107
+ });
108
+
109
+ it('flags a genuine orphan area, leaves valid areas alone, and never persists the flag', async function() {
110
+ // Insert a doc with an area whose field is no longer in the schema.
111
+ const docId = 'orphan-test:en:draft';
112
+ await apos.doc.db.deleteOne({ _id: docId });
113
+ await apos.doc.db.insertOne({
114
+ _id: docId,
115
+ type: 'product',
116
+ metaType: 'doc',
117
+ aposMode: 'draft',
118
+ aposDocId: 'orphan-test',
119
+ aposLocale: 'en:draft',
120
+ title: 'Orphan Test',
121
+ slug: 'orphan-test',
122
+ main: {
123
+ metaType: 'area',
124
+ _id: 'orphan-main',
125
+ items: []
126
+ },
127
+ // `ghost` is not a field in the product schema (simulates a removed field)
128
+ ghost: {
129
+ metaType: 'area',
130
+ _id: 'orphan-ghost',
131
+ items: []
132
+ }
133
+ });
134
+
135
+ const doc = await apos.doc.db.findOne({ _id: docId });
136
+ doc._edit = true;
137
+ doc.main._edit = true;
138
+ doc.ghost._edit = true;
139
+
140
+ await apos.template.annotateDocForExternalFront(doc);
141
+
142
+ // The annotator owns the doc, so the orphan is flagged — and has no field.
143
+ // It is NOT marked `_aposAnnotated` (that signals a fully annotated area).
144
+ assert.strictEqual(doc.ghost._isOrphan, true, 'orphan flagged');
145
+ assert.strictEqual(doc.ghost.field, undefined, 'orphan has no schema field');
146
+ assert.strictEqual(doc.ghost._aposAnnotated, undefined, 'orphan is not _aposAnnotated');
147
+ // The valid area is annotated normally and never flagged orphan.
148
+ assert(doc.main.field && doc.main._isOrphan === undefined);
149
+ assert.strictEqual(doc.main._aposAnnotated, true);
150
+
151
+ // Neither flag is written to the database.
152
+ const persisted = await apos.doc.db.findOne({ _id: docId });
153
+ assert.strictEqual(persisted.ghost._isOrphan, undefined, 'flag not persisted');
154
+ assert.strictEqual(persisted.main._isOrphan, undefined, 'flag not persisted');
155
+ assert.strictEqual(persisted.main._aposAnnotated, undefined, 'signal not persisted');
156
+
157
+ await apos.doc.db.deleteOne({ _id: docId });
158
+ });
159
+
160
+ it('annotateDocForExternalFront leaves missing areas alone on a non-editable doc', async function() {
161
+ const doc = productMissingExtra();
162
+
163
+ await apos.template.annotateDocForExternalFront(doc);
164
+
165
+ assert.strictEqual(doc.extra, undefined, 'extra not added for anonymous');
166
+ // Existing area annotated as before
167
+ assert(doc.main.field && doc.main.field.name === 'main');
168
+ });
169
+
170
+ it('annotateDocForExternalFront persists materialized areas at their schema path with the same _id sent to the UI', async function() {
171
+ // Insert a doc directly with only `main`, no `extra`, simulating a field
172
+ // added to the schema after the doc was created.
173
+ const docId = 'persist-test:en:draft';
174
+ await apos.doc.db.deleteOne({ _id: docId });
175
+ await apos.doc.db.insertOne({
176
+ _id: docId,
177
+ type: 'product',
178
+ metaType: 'doc',
179
+ aposMode: 'draft',
180
+ aposDocId: 'persist-test',
181
+ aposLocale: 'en:draft',
182
+ title: 'Persist Test',
183
+ slug: 'persist-test',
184
+ main: {
185
+ metaType: 'area',
186
+ _id: 'main-id-persist',
187
+ items: []
188
+ }
189
+ });
190
+
191
+ // Load the doc and mark editable (mirroring what doc-type load does)
192
+ const doc = await apos.doc.db.findOne({ _id: docId });
193
+ doc._edit = true;
194
+ doc.main._edit = true;
195
+
196
+ await apos.template.annotateDocForExternalFront(doc);
197
+
198
+ // In-memory: extra was materialized and annotated
199
+ assert(doc.extra && doc.extra._id, 'extra has an id in memory');
200
+ const inMemoryId = doc.extra._id;
201
+
202
+ // In the DB: extra was written at the schema path with the same _id, so a
203
+ // subsequent editor patch using @<inMemoryId>.items will resolve
204
+ const persisted = await apos.doc.db.findOne({ _id: docId });
205
+ assert(persisted.extra, 'extra was persisted');
206
+ assert.strictEqual(persisted.extra.metaType, 'area');
207
+ assert.deepStrictEqual(persisted.extra.items, []);
208
+ assert.strictEqual(
209
+ persisted.extra._id, inMemoryId,
210
+ 'persisted _id matches the one sent to the UI'
211
+ );
212
+
213
+ // Idempotent: a second annotate keeps the same persisted _id (the
214
+ // $eq: null condition prevents overwrite)
215
+ const doc2 = await apos.doc.db.findOne({ _id: docId });
216
+ doc2._edit = true;
217
+ delete doc2.extra; // simulate the missing-area branch firing again
218
+ await apos.template.annotateDocForExternalFront(doc2);
219
+ const persistedAgain = await apos.doc.db.findOne({ _id: docId });
220
+ assert.strictEqual(
221
+ persistedAgain.extra._id, inMemoryId,
222
+ 'persisted _id is unchanged on re-annotate'
223
+ );
224
+
225
+ await apos.doc.db.deleteOne({ _id: docId });
226
+ });
227
+
228
+ it('does not write missing areas at an in-memory path when a relationship target needs one (regression)', async function() {
229
+ // The editor's in-memory graph contains loaded relationship data. A widget
230
+ // in the host doc relates to a SEPARATE editable doc that is missing a
231
+ // schema area (added after it was created). The area must be stubbed at the
232
+ // related doc's OWN path — never at a path derived from the host's
233
+ // in-memory traversal, which would write into the wrong document and pad
234
+ // arrays with nulls. This reproduces the production corruption that left
235
+ // null items and stray fragments in published docs.
236
+ const hostId = 'reg-host:en:draft';
237
+ const relatedId = 'reg-related:en:draft';
238
+ await apos.doc.db.deleteMany({ _id: { $in: [ hostId, relatedId ] } });
239
+
240
+ // Host has both areas filled (nothing missing of its own) and a single
241
+ // rich-text widget in `main`.
242
+ await apos.doc.db.insertOne({
243
+ _id: hostId,
244
+ type: 'product',
245
+ metaType: 'doc',
246
+ aposMode: 'draft',
247
+ aposDocId: 'reg-host',
248
+ aposLocale: 'en:draft',
249
+ title: 'Host',
250
+ slug: 'reg-host',
251
+ main: {
252
+ metaType: 'area',
253
+ _id: 'reg-host-main',
254
+ items: [
255
+ {
256
+ _id: 'reg-host-widget',
257
+ metaType: 'widget',
258
+ type: '@apostrophecms/rich-text',
259
+ content: '<p>hi</p>'
260
+ }
261
+ ]
262
+ },
263
+ extra: {
264
+ metaType: 'area',
265
+ _id: 'reg-host-extra',
266
+ items: []
267
+ }
268
+ });
269
+ // Related has `main` but no `extra` (field added to the schema later).
270
+ await apos.doc.db.insertOne({
271
+ _id: relatedId,
272
+ type: 'product',
273
+ metaType: 'doc',
274
+ aposMode: 'draft',
275
+ aposDocId: 'reg-related',
276
+ aposLocale: 'en:draft',
277
+ title: 'Related',
278
+ slug: 'reg-related',
279
+ main: {
280
+ metaType: 'area',
281
+ _id: 'reg-related-main',
282
+ items: []
283
+ }
284
+ });
285
+
286
+ const hostStored = await apos.doc.db.findOne({ _id: hostId });
287
+
288
+ // Build the in-memory graph: the host widget carries a loaded relationship
289
+ // to the related doc, which is editable and missing `extra`.
290
+ const related = await apos.doc.db.findOne({ _id: relatedId });
291
+ related._edit = true;
292
+ related._docId = relatedId;
293
+ related.main._edit = true;
294
+ delete related.extra;
295
+
296
+ const host = await apos.doc.db.findOne({ _id: hostId });
297
+ host._edit = true;
298
+ host._docId = hostId;
299
+ host.main._edit = true;
300
+ host.main.items[0]._docId = hostId;
301
+ host.main.items[0]._related = [ related ];
302
+
303
+ await apos.template.annotateDocForExternalFront(host);
304
+
305
+ // Related doc: `extra` stubbed at its OWN top-level path, clean, and its
306
+ // existing `main` is untouched (no host-relative path leaked in).
307
+ const relatedAfter = await apos.doc.db.findOne({ _id: relatedId });
308
+ assert(relatedAfter.extra, 'extra stubbed on the related doc');
309
+ assert.strictEqual(relatedAfter.extra.metaType, 'area');
310
+ assert.deepStrictEqual(relatedAfter.extra.items, []);
311
+ assert.strictEqual(relatedAfter.extra._id, related.extra._id, 'same _id as in memory');
312
+ assert.strictEqual(relatedAfter.main.items.length, 0, 'related main not padded');
313
+ assert.ok(!Object.prototype.hasOwnProperty.call(relatedAfter, '0'), 'no numeric-key fragment');
314
+
315
+ // Host doc: completely untouched in the database.
316
+ const hostAfter = await apos.doc.db.findOne({ _id: hostId });
317
+ assert.strictEqual(hostAfter.main.items.length, 1, 'host main.items not extended');
318
+ assert(
319
+ hostAfter.main.items.every(i => i && i.metaType === 'widget'),
320
+ 'no null/typeless items in host'
321
+ );
322
+ assert.deepStrictEqual(hostAfter, hostStored, 'host doc unchanged in the DB');
323
+
324
+ await apos.doc.db.deleteMany({ _id: { $in: [ hostId, relatedId ] } });
325
+ });
326
+
327
+ it('annotateAreaForExternalFront drops corrupt items so they never reach the front end', function() {
328
+ const field = {
329
+ name: 'main',
330
+ options: {
331
+ widgets: { '@apostrophecms/rich-text': {} }
332
+ }
333
+ };
334
+ const area = {
335
+ metaType: 'area',
336
+ _id: 'guard-area',
337
+ _docId: 'guard-doc:en:published',
338
+ items: [
339
+ {
340
+ _id: 'w1',
341
+ metaType: 'widget',
342
+ type: '@apostrophecms/rich-text',
343
+ content: '<p>ok</p>'
344
+ },
345
+ // The two shapes the production corruption produced. A `null` left in
346
+ // place would crash the Astro area renderer (`...item._options`).
347
+ null,
348
+ {
349
+ _id: 'frag',
350
+ foo: 'bar'
351
+ }
352
+ ]
353
+ };
354
+
355
+ assert.doesNotThrow(() => {
356
+ apos.template.annotateAreaForExternalFront(field, area, { scene: 'apos' });
357
+ });
358
+
359
+ // Corrupt items are removed; only the valid, annotated widget remains.
360
+ assert.strictEqual(area.items.length, 1, 'corrupt items dropped');
361
+ assert.strictEqual(area.items[0]._id, 'w1');
362
+ assert(area.items[0]._options, 'valid widget annotated');
363
+ assert.strictEqual(area.items[0]._docId, area._docId, 'valid widget got _docId');
364
+ });
365
+
366
+ it('annotateAreaForExternalFront keeps an unknown widget type un-annotated instead of throwing', function() {
367
+ const field = {
368
+ name: 'main',
369
+ options: {
370
+ widgets: { '@apostrophecms/rich-text': {} }
371
+ }
372
+ };
373
+ const area = {
374
+ metaType: 'area',
375
+ _id: 'unknown-area',
376
+ _docId: 'unknown-doc:en:published',
377
+ items: [
378
+ {
379
+ _id: 'w1',
380
+ metaType: 'widget',
381
+ type: '@apostrophecms/rich-text',
382
+ content: '<p>ok</p>'
383
+ },
384
+ // A real widget whose module is not registered (e.g. `custom-layout`).
385
+ {
386
+ _id: 'w2',
387
+ metaType: 'widget',
388
+ type: 'definitely-not-a-registered-widget'
389
+ }
390
+ ]
391
+ };
392
+
393
+ // No throw — a missing widget module must not 500 the whole render.
394
+ assert.doesNotThrow(() => {
395
+ apos.template.annotateAreaForExternalFront(field, area, { scene: 'apos' });
396
+ });
397
+
398
+ // The unknown widget is preserved (so its content survives a save) but left
399
+ // un-annotated; the front end skips it.
400
+ assert.strictEqual(area.items.length, 2, 'unknown-type item preserved');
401
+ assert(area.items[0]._options, 'valid widget annotated');
402
+ assert.strictEqual(area.items[1].type, 'definitely-not-a-registered-widget');
403
+ assert.strictEqual(area.items[1]._options, undefined, 'unknown widget not annotated');
404
+ });
405
+
406
+ it('addMissingArea honors throwIfNotFound (tag behavior) and defaults to graceful (annotator)', async function() {
407
+ // Doc-backed parent whose document is not in the database (the missing-doc
408
+ // race the `{% area %}` tag historically treated as notfound).
409
+ const ghost = {
410
+ _id: 'ghost:en:published',
411
+ metaType: 'doc',
412
+ type: 'product'
413
+ };
414
+
415
+ // Opt-in (tag): throws notfound rather than persisting nothing silently.
416
+ await assert.rejects(
417
+ () => apos.area.addMissingArea(ghost, 'extra', { throwIfNotFound: true }),
418
+ err => err && err.name === 'notfound'
419
+ );
420
+ // The in-memory stub is still attached for the caller.
421
+ assert(ghost.extra && ghost.extra.metaType === 'area');
422
+
423
+ // Default (annotator): degrades to an in-memory stub, never throws.
424
+ const ghost2 = {
425
+ _id: 'ghost2:en:published',
426
+ metaType: 'doc',
427
+ type: 'product'
428
+ };
429
+ const area = await apos.area.addMissingArea(ghost2, 'extra');
430
+ assert.strictEqual(area.metaType, 'area');
431
+ assert(area._id, 'in-memory stub has an _id');
432
+ assert.deepStrictEqual(area.items, []);
433
+ assert.strictEqual(ghost2.extra, area, 'stub attached to the parent');
434
+
435
+ // A parent with no docId is never an error, even with throwIfNotFound.
436
+ const unsaved = {
437
+ metaType: 'doc',
438
+ type: 'product'
439
+ };
440
+ const unsavedArea = await apos.area.addMissingArea(unsaved, 'extra', { throwIfNotFound: true });
441
+ assert.strictEqual(unsavedArea.metaType, 'area');
442
+ });
443
+
26
444
  it('fetch home with external front', async function() {
27
445
  const data = await await apos.http.get('/', {
28
446
  headers: {
package/test/job.js CHANGED
@@ -311,7 +311,7 @@ describe('Job module', function() {
311
311
  req,
312
312
  async function(_req, reporters) {
313
313
  let count = 1;
314
- reporters.setTotal(articleIds.length);
314
+ await reporters.setTotal(articleIds.length);
315
315
 
316
316
  for (const id of articleIds) {
317
317
  await delay(3);
@@ -0,0 +1,23 @@
1
+ module.exports = {
2
+ extend: '@apostrophecms/piece-type',
3
+ options: {
4
+ alias: 'jsxAreaTest',
5
+ name: 'jsx-area-test',
6
+ label: 'JSX Area Test'
7
+ },
8
+ fields: {
9
+ add: {
10
+ main: {
11
+ type: 'area',
12
+ label: 'Main',
13
+ options: {
14
+ widgets: {
15
+ '@apostrophecms/rich-text': {},
16
+ 'jsx-async': {},
17
+ 'jsx-ctx': {}
18
+ }
19
+ }
20
+ }
21
+ }
22
+ }
23
+ };
@@ -0,0 +1,7 @@
1
+ export default function (data, { Area }) {
2
+ return (
3
+ <main>
4
+ <Area doc={data.piece} name='nope' />
5
+ </main>
6
+ );
7
+ }
@@ -0,0 +1,13 @@
1
+ export default function (data, { Area }) {
2
+ return (
3
+ <main>
4
+ <Area
5
+ doc={data.piece}
6
+ name='main'
7
+ with={{
8
+ 'jsx-ctx': { tag: 'from-area-with' }
9
+ }}
10
+ />
11
+ </main>
12
+ );
13
+ }
@@ -0,0 +1,7 @@
1
+ export default function (data, { Area }) {
2
+ return (
3
+ <main>
4
+ <Area doc={data.piece} name='main' />
5
+ </main>
6
+ );
7
+ }
@@ -0,0 +1,12 @@
1
+ export default function (data, { Widget }) {
2
+ return (
3
+ <section class='wrapper'>
4
+ <Widget
5
+ widget={data.widget}
6
+ with={{
7
+ 'jsx-ctx': { tag: 'from-widget-with' }
8
+ }}
9
+ />
10
+ </section>
11
+ );
12
+ }
@@ -0,0 +1,7 @@
1
+ export default function (data, { Widget }) {
2
+ return (
3
+ <section class='wrapper'>
4
+ <Widget widget={data.widget} />
5
+ </section>
6
+ );
7
+ }
@@ -0,0 +1,6 @@
1
+ module.exports = {
2
+ extend: '@apostrophecms/widget-type',
3
+ options: {
4
+ label: 'JSX Async Widget'
5
+ }
6
+ };
@@ -0,0 +1,11 @@
1
+ export default function (data, { Component }) {
2
+ return (
3
+ <div class='async-widget'>
4
+ <Component
5
+ module='jsx-component-test'
6
+ name='greet'
7
+ who={data.widget.who}
8
+ />
9
+ </div>
10
+ );
11
+ }
@@ -0,0 +1 @@
1
+ module.exports = {};
@@ -0,0 +1,7 @@
1
+ export default function (data, { Template }) {
2
+ return (
3
+ <div>
4
+ <Template name='jsx-mixed-test:partial' item='from-cross-module' />
5
+ </div>
6
+ );
7
+ }
@@ -0,0 +1,7 @@
1
+ export default function (data, { Template }) {
2
+ return (
3
+ <div>
4
+ <Template name='disambig-target' foo='bar' />
5
+ </div>
6
+ );
7
+ }
@@ -0,0 +1,8 @@
1
+ export default function (data) {
2
+ return (
3
+ <p
4
+ data-name={String(data.name)}
5
+ data-template-name={String(data.templateName)}
6
+ />
7
+ );
8
+ }
@@ -0,0 +1,7 @@
1
+ export default function (data, { Template }) {
2
+ return (
3
+ <div>
4
+ <Template templateName='disambig-target.jsx' name='forwarded-value' />
5
+ </div>
6
+ );
7
+ }
@@ -0,0 +1,7 @@
1
+ export default function (data, { Template }) {
2
+ return (
3
+ <div>
4
+ <Template templateName='include-target.html' content='prop-content' />
5
+ </div>
6
+ );
7
+ }
@@ -0,0 +1,4 @@
1
+ <section class="include-target">
2
+ <p class="block-output">{% block content %}default-block{% endblock %}</p>
3
+ <p class="data-output">{{ data.content }}</p>
4
+ </section>
@@ -0,0 +1,9 @@
1
+ export default function (data, { Extend }) {
2
+ return (
3
+ <Extend templateName='jsx-layout.jsx' title='Extend-against-JSX'>
4
+ <main>
5
+ <p>{data.message}</p>
6
+ </main>
7
+ </Extend>
8
+ );
9
+ }
@@ -0,0 +1,9 @@
1
+ export default function (data, { Template }) {
2
+ return (
3
+ <Template templateName='jsx-layout.jsx' title='JSX-in-JSX'>
4
+ <main>
5
+ <p>{data.message}</p>
6
+ </main>
7
+ </Template>
8
+ );
9
+ }
@@ -0,0 +1,14 @@
1
+ export default function (data) {
2
+ return (
3
+ <html>
4
+ <head>
5
+ <title>{data.title || 'default title'}</title>
6
+ </head>
7
+ <body>
8
+ <header>shared header</header>
9
+ {data.children}
10
+ <footer>shared footer</footer>
11
+ </body>
12
+ </html>
13
+ );
14
+ }
@@ -0,0 +1,14 @@
1
+ export default function (data, { Extend }) {
2
+ return (
3
+ <Extend
4
+ templateName='njk-layout.html'
5
+ title='A JSX page'
6
+ main={
7
+ <main>
8
+ <h1>I am from JSX</h1>
9
+ <p>{data.message}</p>
10
+ </main>
11
+ }
12
+ />
13
+ );
14
+ }
@@ -0,0 +1,9 @@
1
+ <!doctype html>
2
+ <html>
3
+ <head>
4
+ <title>{% block title %}default title{% endblock %}</title>
5
+ </head>
6
+ <body data-page="{{ data.pageSlug }}">
7
+ {% block main %}default body{% endblock %}
8
+ </body>
9
+ </html>
@@ -0,0 +1,7 @@
1
+ export default function (data, { Template }) {
2
+ return (
3
+ <div>
4
+ <Template name='short-target' message='from-short' />
5
+ </div>
6
+ );
7
+ }
@@ -0,0 +1,3 @@
1
+ export default function (data) {
2
+ return <p>short:{data.message}</p>;
3
+ }