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,490 @@
1
+ // JSX render orchestration. Mixed into the `@apostrophecms/template`
2
+ // module by `index.js`, this file implements:
3
+ //
4
+ // * Template path resolution that walks the same module chain as Nunjucks
5
+ // but knows about `.jsx` and prefers it when present alongside `.html`.
6
+ // * `renderJsxTemplate(req, resolved, data, module)` which loads the
7
+ // compiled JSX module, invokes its default-exported function with
8
+ // `(data, helpers)`, and flattens the resulting node tree into HTML.
9
+ // * The `Area`, `Component`, `Template`, `Extend`, and `Widget` runtime
10
+ // helpers exposed to JSX templates as the second argument to their
11
+ // default function.
12
+ // * The cross-engine bridge: when a JSX template invokes `Template`/
13
+ // `Extend` against a Nunjucks `.html` layout, the helper synthesizes a
14
+ // Nunjucks string that extends the target and turns each named prop
15
+ // into a `{% block ... %}` override.
16
+
17
+ const fs = require('fs');
18
+ const path = require('path');
19
+ const _ = require('lodash');
20
+
21
+ const jsxLoader = require('./jsxLoader.js');
22
+ const {
23
+ Raw, flatten, registerSafeClass
24
+ } = require('./jsxRuntime.js');
25
+
26
+ const TEMPLATE_EXTENSIONS = [ 'jsx', 'njk', 'html' ];
27
+
28
+ module.exports = function(self) {
29
+ return {
30
+ // Install the global JSX `require` hook and teach the runtime about
31
+ // Nunjucks `SafeString` instances so they pass through unescaped.
32
+ initJsx() {
33
+ jsxLoader.install();
34
+ registerSafeClass(self.nunjucks.runtime.SafeString);
35
+ },
36
+
37
+ // Walk a module's view-folder chain and find the first file matching
38
+ // `name` with one of the supported extensions. JSX wins over Nunjucks
39
+ // when both exist in the same directory, but the chain ordering still
40
+ // takes precedence (a child module's `.html` still overrides a parent
41
+ // module's `.jsx` if it appears earlier in the chain).
42
+ //
43
+ // `name` may be `'localname'` (resolved against the supplied
44
+ // `module`'s chain) or `'modulename:localname'` (resolved against the
45
+ // named module's chain). An explicit extension is honored verbatim.
46
+ //
47
+ // Returns `{ kind: 'jsx' | 'nunjucks', path, ext, moduleName,
48
+ // baseName, ext, requestedName }` or `null` when nothing was found.
49
+ resolveTemplate(module, name) {
50
+ let moduleName = module.__meta.name;
51
+ let filename = name;
52
+ const colonAt = name.indexOf(':');
53
+ if (colonAt !== -1) {
54
+ moduleName = name.substring(0, colonAt);
55
+ filename = name.substring(colonAt + 1);
56
+ }
57
+ const targetModule = self.apos.modules[moduleName];
58
+ if (!targetModule) {
59
+ return null;
60
+ }
61
+ const dirs = self.getViewFolders(targetModule);
62
+ const m = filename.match(/^(.*)\.([^/.]+)$/);
63
+ let baseName = filename;
64
+ let requestedExt = null;
65
+ if (m) {
66
+ baseName = m[1];
67
+ requestedExt = m[2];
68
+ }
69
+ // For requests without an extension, or for the well-known template
70
+ // extensions, allow falling back to any of them. For an unknown
71
+ // extension (e.g. `.svg`) preserve current Nunjucks-loader behavior:
72
+ // try only the literal name.
73
+ const exts = (!requestedExt || TEMPLATE_EXTENSIONS.includes(requestedExt))
74
+ ? TEMPLATE_EXTENSIONS
75
+ : [ requestedExt ];
76
+
77
+ for (const dir of dirs) {
78
+ for (const ext of exts) {
79
+ const fullpath = path.join(dir, `${baseName}.${ext}`);
80
+ if (fs.existsSync(fullpath)) {
81
+ return {
82
+ kind: ext === 'jsx' ? 'jsx' : 'nunjucks',
83
+ path: fullpath,
84
+ ext,
85
+ moduleName,
86
+ baseName,
87
+ relativeName: `${baseName}.${ext}`,
88
+ requestedName: name
89
+ };
90
+ }
91
+ }
92
+ }
93
+ return null;
94
+ },
95
+
96
+ // Render the JSX module at `resolved.path` against `data`. Loads the
97
+ // compiled module via Node's require (the `.jsx` extension hook
98
+ // installed by `jsxLoader` does the Babel transform on first load),
99
+ // calls its default function, and flattens the returned node tree.
100
+ async renderJsxTemplate(req, resolved, data, module) {
101
+ self.watchJsxRenderTargets(module, resolved);
102
+ let mod;
103
+ try {
104
+ mod = require(resolved.path);
105
+ } catch (e) {
106
+ throw decorateJsxError(e, resolved.path);
107
+ }
108
+ const fn = (mod && mod.default) || mod;
109
+ if (typeof fn !== 'function') {
110
+ throw new Error(
111
+ `JSX template ${resolved.path} must export a default function. ` +
112
+ `Got ${typeof fn}.`
113
+ );
114
+ }
115
+ const helpers = self.buildJsxHelpers(req, module, data);
116
+ let result;
117
+ try {
118
+ result = await fn(data, helpers);
119
+ } catch (e) {
120
+ throw decorateJsxError(e, resolved.path);
121
+ }
122
+ try {
123
+ return await flatten(result);
124
+ } catch (e) {
125
+ throw decorateJsxError(e, resolved.path);
126
+ }
127
+ },
128
+
129
+ // Build the `{ apos, helpers, Area, Component, Extend, Template,
130
+ // Widget, ... }` object passed as the second argument to every JSX
131
+ // template. The closure captures `req`, `module` (the module whose
132
+ // template is currently being rendered, used to resolve `Template`
133
+ // names without a `module:` prefix), and `ambientData` (the merged
134
+ // render data for the current invocation, threaded through
135
+ // `Template`/`Extend` to mirror Nunjucks' `extends` semantics).
136
+ buildJsxHelpers(req, module, ambientData) {
137
+ const helpers = {
138
+ // Full `self.apos`. Unlike Nunjucks, JSX supports await, so
139
+ // there is no need to restrict access to this object.
140
+ apos: self.apos,
141
+ // The Nunjucks-compatible wrapper. Carries `addHelpers`-registered
142
+ // helpers under `helpers.modules['module-name'].method(...)` and
143
+ // module aliases, matching the `apos` object available in
144
+ // Nunjucks templates. Distinct from the JSX `apos` above.
145
+ helpers: self.templateApos,
146
+ // Localization helper, matching the Nunjucks `__t` global.
147
+ __t: req.t && req.t.bind(req),
148
+
149
+ Area: (props) => self.jsxArea(req, props),
150
+ Component: (props) => self.jsxComponent(req, props),
151
+ Widget: (props) => self.jsxWidget(req, props),
152
+ Template: (props) => self.jsxInvoke(req, module, props, {
153
+ mode: 'include',
154
+ ambientData
155
+ }),
156
+ Extend: (props) => self.jsxInvoke(req, module, props, {
157
+ mode: 'extend',
158
+ ambientData
159
+ })
160
+ };
161
+ return helpers;
162
+ },
163
+
164
+ // Implementation of the `<Area doc={..} name=".." with={..} />` helper.
165
+ // Mirrors the Nunjucks `{% area %}` custom tag closely so behavior
166
+ // (including stub-area persistence) is identical.
167
+ jsxArea(req, props) {
168
+ const {
169
+ doc, name, with: ctx
170
+ } = props || {};
171
+ return (async () => {
172
+ if (!doc || typeof doc !== 'object') {
173
+ throw new Error(
174
+ 'Area: the `doc` prop must be an existing doc or widget object.'
175
+ );
176
+ }
177
+ if (typeof name !== 'string') {
178
+ throw new Error('Area: the `name` prop must be a string.');
179
+ }
180
+ if (!name.match(/^\w+$/)) {
181
+ throw new Error(
182
+ 'Area: area names must consist only of letters, digits, and underscores.'
183
+ );
184
+ }
185
+ let area = doc[name];
186
+ if (!area) {
187
+ // Same stub-into-db logic as the {% area %} tag, so that newly
188
+ // added schema fields get a persistent `_id` on first render.
189
+ area = {
190
+ metaType: 'area',
191
+ _id: self.apos.util.generateId(),
192
+ items: []
193
+ };
194
+ doc[name] = area;
195
+ const docId = doc._docId || ((doc.metaType === 'doc') ? doc._id : null);
196
+ if (docId) {
197
+ let mainDoc = await self.apos.doc.db.findOne({ _id: docId });
198
+ if (!mainDoc) {
199
+ throw self.apos.error('notfound');
200
+ }
201
+ let docDotPath;
202
+ try {
203
+ docDotPath = (doc._id === docId)
204
+ ? ''
205
+ : self.apos.util.findNestedObjectAndDotPathById(mainDoc, doc._id).dotPath;
206
+ } catch (e) {
207
+ throw self.apos.error('notfound');
208
+ }
209
+ const areaDotPath = docDotPath ? `${docDotPath}.${name}` : name;
210
+ await self.apos.doc.db.updateOne({
211
+ _id: docId,
212
+ [areaDotPath]: { $eq: null }
213
+ }, {
214
+ $set: {
215
+ [areaDotPath]: self.apos.util.clonePermanent(area)
216
+ }
217
+ });
218
+ mainDoc = await self.apos.doc.db.findOne({ _id: docId });
219
+ area._id = self.apos.util.get(mainDoc, areaDotPath)._id;
220
+ }
221
+ }
222
+ const manager = self.apos.util.getManagerOf(doc);
223
+ const field = manager && manager.schema.find(f => f.name === name);
224
+ if (!field) {
225
+ throw new Error(
226
+ `Area: the doc of type ${doc.type} with the slug ${doc.slug} ` +
227
+ `has no field named ${name}.`
228
+ );
229
+ }
230
+ area._fieldId = field._id;
231
+ area._docId = doc._docId || ((doc.metaType === 'doc') ? doc._id : null);
232
+ area._edit = area._edit || doc._edit;
233
+ self.apos.area.prepForRender(area, doc, name);
234
+ const html = await self.apos.area.renderArea(req, area, ctx);
235
+ return new Raw(html);
236
+ })();
237
+ },
238
+
239
+ // Implementation of `<Component module="..." name="..." {...props} />`.
240
+ // Looks up the named async component, awaits it, then renders the
241
+ // component's matching template via the existing module render path.
242
+ jsxComponent(req, props) {
243
+ const {
244
+ module: moduleName, name, children, ...rest
245
+ } = props || {};
246
+ return (async () => {
247
+ if (typeof moduleName !== 'string' || typeof name !== 'string') {
248
+ throw new Error(
249
+ 'Component: both `module` and `name` props must be strings.'
250
+ );
251
+ }
252
+ const target = self.apos.modules[moduleName];
253
+ if (!target) {
254
+ throw new Error(
255
+ `Component: module "${moduleName}" does not exist. ` +
256
+ 'It must be a real, instantiated module, not a base class.'
257
+ );
258
+ }
259
+ if (!(target.components && target.components[name])) {
260
+ throw new Error(
261
+ `Component: ${moduleName}:${name} is not a registered async component.`
262
+ );
263
+ }
264
+ // Components receive plain data: any JSX nodes passed as props
265
+ // (including `children`) need to be flattened to HTML strings
266
+ // first so the underlying Nunjucks/JSX component template can
267
+ // safely render them.
268
+ const inputProps = await self.flattenJsxProps({
269
+ ...rest,
270
+ children
271
+ });
272
+ const result = await self.apos.util.recursionGuard(
273
+ req,
274
+ `component:${moduleName}:${name}`,
275
+ async () => {
276
+ const input = await target.components[name](req, inputProps);
277
+ return target.render(req, name, input);
278
+ }
279
+ );
280
+ if (result === undefined) {
281
+ // Recursion guard kicked in.
282
+ return new Raw('');
283
+ }
284
+ return new Raw(result);
285
+ })();
286
+ },
287
+
288
+ // Implementation of `<Widget widget={..} options={..} with={..} />`.
289
+ // Mirrors the Nunjucks `{% widget %}` tag, intended only for users
290
+ // reimplementing `area.html` in JSX.
291
+ jsxWidget(req, props) {
292
+ const {
293
+ widget, options, with: contextOptions
294
+ } = props || {};
295
+ return (async () => {
296
+ if (!widget) {
297
+ self.apos.util.warn('a null widget was encountered.');
298
+ return new Raw('');
299
+ }
300
+ const opts = options || {};
301
+ let ctxOpts = {};
302
+ if (contextOptions && typeof contextOptions === 'object' && contextOptions[widget.type]) {
303
+ ctxOpts = (typeof contextOptions[widget.type] === 'object')
304
+ ? contextOptions[widget.type]
305
+ : {};
306
+ }
307
+ const manager = self.apos.area.getWidgetManager(widget.type);
308
+ if (!manager) {
309
+ self.apos.area.warnMissingWidgetType(widget.type);
310
+ return new Raw('');
311
+ }
312
+ const html = await manager.output(req, widget, opts, ctxOpts);
313
+ return new Raw(html);
314
+ })();
315
+ },
316
+
317
+ // Shared implementation of `Template` and `Extend`. The rules:
318
+ //
319
+ // * Strip `templateName`/`name` per the spec — `templateName` always
320
+ // wins as the file selector, otherwise `name` is the file selector
321
+ // AND is *not* passed through as a data prop.
322
+ // * Resolve the file. JSX targets receive the props (plus ambient
323
+ // data) and `children` natively; Nunjucks targets receive props
324
+ // as block overrides (`extend` mode) or as data (`include` mode
325
+ // when called via `Template` against a Nunjucks file).
326
+ jsxInvoke(req, callerModule, props, { mode, ambientData }) {
327
+ const {
328
+ templateName, name, ...rest
329
+ } = props || {};
330
+ let targetName;
331
+ const dataProps = { ...rest };
332
+ if (templateName !== undefined) {
333
+ targetName = templateName;
334
+ // Per spec: `name` is forwarded as a normal prop only when
335
+ // `templateName` is also present.
336
+ if (name !== undefined) {
337
+ dataProps.name = name;
338
+ }
339
+ } else {
340
+ targetName = name;
341
+ }
342
+ if (typeof targetName !== 'string') {
343
+ throw new Error(
344
+ 'Template/Extend: pass a string to the `templateName` prop ' +
345
+ '(or `name` when no other prop named `name` is needed).'
346
+ );
347
+ }
348
+ return (async () => {
349
+ const resolved = self.resolveTemplate(callerModule, targetName);
350
+ if (!resolved) {
351
+ throw new Error(`Template/Extend: could not resolve template "${targetName}".`);
352
+ }
353
+ if (resolved.kind === 'jsx') {
354
+ // For JSX targets both Template and Extend behave the same:
355
+ // the props (with the parent's ambient data underneath) are
356
+ // passed straight through. JSX values like `children` flow as
357
+ // node trees — they are flattened only when the target template
358
+ // emits them.
359
+ const targetModule = self.apos.modules[resolved.moduleName];
360
+ const merged = {
361
+ ...(ambientData || {}),
362
+ ...dataProps
363
+ };
364
+ const html = await self.renderJsxTemplate(req, resolved, merged, targetModule);
365
+ return new Raw(html);
366
+ }
367
+ // Nunjucks target.
368
+ if (mode === 'extend') {
369
+ return self.renderNunjucksWithBlocks(
370
+ req, resolved, dataProps, ambientData
371
+ );
372
+ }
373
+ // mode === 'include': render the Nunjucks template with our
374
+ // props merged on top of ambient data, the same way a Nunjucks
375
+ // `{% include %}` would inherit data.
376
+ const flatProps = await self.flattenJsxProps(dataProps);
377
+ const targetModule = self.apos.modules[resolved.moduleName];
378
+ const data = {
379
+ ...(ambientData || {}),
380
+ ...flatProps
381
+ };
382
+ const html = await targetModule.render(req, resolved.relativeName, data);
383
+ return new Raw(html);
384
+ })();
385
+ },
386
+
387
+ // Invoke a Nunjucks template via `extends` so that JSX-supplied props
388
+ // become `{% block %}` overrides. The synthetic Nunjucks template
389
+ // declares one block per prop, each emitting the matching string
390
+ // marked safe so already-rendered HTML survives.
391
+ async renderNunjucksWithBlocks(req, resolved, props, ambientData) {
392
+ const targetModule = self.apos.modules[resolved.moduleName];
393
+ const blocks = {};
394
+ for (const key of Object.keys(props)) {
395
+ const html = await flattenToHtml(props[key]);
396
+ blocks[key] = html;
397
+ }
398
+ const blockNames = Object.keys(blocks);
399
+ const targetRef = `${resolved.moduleName}:${resolved.relativeName}`;
400
+ const lines = [ `{% extends ${JSON.stringify(targetRef)} %}` ];
401
+ for (const blockName of blockNames) {
402
+ if (!/^[A-Za-z_][\w]*$/.test(blockName)) {
403
+ throw new Error(
404
+ 'Template/Extend: prop names used as block overrides must be ' +
405
+ `valid identifiers (got "${blockName}").`
406
+ );
407
+ }
408
+ lines.push(
409
+ `{% block ${blockName} %}{{ data.aposJsxBlocks[${JSON.stringify(blockName)}] | safe }}{% endblock %}`
410
+ );
411
+ }
412
+ const synthetic = lines.join('\n');
413
+ // Layer ambient page data underneath so the Nunjucks layout has
414
+ // access to `data.outerLayout`, `data.page`, etc.; our blocks
415
+ // override only what they explicitly name.
416
+ const data = {
417
+ ...(ambientData || {}),
418
+ aposJsxBlocks: blocks
419
+ };
420
+ const html = await targetModule.renderString(req, synthetic, data);
421
+ return new Raw(html);
422
+ },
423
+
424
+ // Walk a props object, flattening any JSX node values to HTML strings
425
+ // so they can cross into Nunjucks (which can't deal with our internal
426
+ // node arrays). Plain primitive props pass through unchanged.
427
+ async flattenJsxProps(props) {
428
+ const out = {};
429
+ for (const key of Object.keys(props)) {
430
+ const value = props[key];
431
+ if (value === undefined) {
432
+ continue;
433
+ }
434
+ if (isJsxNode(value)) {
435
+ out[key] = self.safe(await flatten(value));
436
+ } else {
437
+ out[key] = value;
438
+ }
439
+ }
440
+ return out;
441
+ }
442
+ };
443
+ };
444
+
445
+ // Decide whether a value should be flattened by the JSX runtime before
446
+ // being handed to Nunjucks. Arrays, Raw markers, and thenables produced
447
+ // by our helpers all need flattening; everything else (strings, numbers,
448
+ // docs, options objects) is forwarded as-is.
449
+ function isJsxNode(value) {
450
+ if (value == null) {
451
+ return false;
452
+ }
453
+ if (Array.isArray(value)) {
454
+ return true;
455
+ }
456
+ if (value instanceof Raw) {
457
+ return true;
458
+ }
459
+ if (typeof value === 'object' && typeof value.then === 'function') {
460
+ return true;
461
+ }
462
+ return false;
463
+ }
464
+
465
+ // Convenience wrapper used when a JSX-supplied prop becomes a Nunjucks
466
+ // block override: regardless of input type, produce the final HTML string
467
+ // that the synthesized template will emit via the `safe` filter.
468
+ async function flattenToHtml(value) {
469
+ if (value == null || value === false) {
470
+ return '';
471
+ }
472
+ if (isJsxNode(value)) {
473
+ return await flatten(value);
474
+ }
475
+ return String(value);
476
+ }
477
+
478
+ // Annotate exceptions thrown out of a JSX template with the file path so
479
+ // the error log clearly identifies which template failed. Source maps
480
+ // (installed by `jsxLoader`) take care of accurate line/column info.
481
+ function decorateJsxError(error, file) {
482
+ if (!error || typeof error !== 'object') {
483
+ return error;
484
+ }
485
+ if (!error.aposJsxFile) {
486
+ error.aposJsxFile = file;
487
+ error.message = `[JSX template ${file}] ${error.message}`;
488
+ }
489
+ return error;
490
+ }