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.
- package/.claude/settings.local.json +15 -0
- package/CHANGELOG.md +22 -2
- package/claude-tools/detect-handles.js +46 -0
- package/claude-tools/minimal-hang-test.js +28 -0
- package/claude-tools/mongo-close-test.js +11 -0
- package/claude-tools/stdin-ref-test.js +14 -0
- package/eslint.config.js +3 -1
- package/modules/@apostrophecms/area/index.js +94 -2
- package/modules/@apostrophecms/area/lib/custom-tags/area.js +1 -40
- package/modules/@apostrophecms/area/ui/apos/components/AposBreadcrumbOperations.vue +0 -1
- package/modules/@apostrophecms/area/ui/apos/components/AposWidgetControls.vue +0 -1
- package/modules/@apostrophecms/attachment/index.js +4 -1
- package/modules/@apostrophecms/db/index.js +68 -27
- package/modules/@apostrophecms/doc-type/ui/apos/logic/AposDocContextMenu.js +5 -3
- package/modules/@apostrophecms/express/index.js +2 -0
- package/modules/@apostrophecms/file/index.js +9 -8
- package/modules/@apostrophecms/http/index.js +1 -1
- package/modules/@apostrophecms/i18n/i18n/en.json +3 -0
- package/modules/@apostrophecms/image/ui/apos/components/AposMediaManagerEditor.vue +2 -2
- package/modules/@apostrophecms/image/ui/apos/components/AposMediaUploader.vue +3 -0
- package/modules/@apostrophecms/job/index.js +9 -7
- package/modules/@apostrophecms/layout-widget/ui/apos/components/AposGridColumn.vue +0 -1
- package/modules/@apostrophecms/layout-widget/ui/apos/components/AposGridManager.vue +0 -1
- package/modules/@apostrophecms/login/ui/apos/components/TheAposLogin.vue +10 -2
- package/modules/@apostrophecms/login/ui/apos/components/TheAposLoginHeader.vue +3 -3
- package/modules/@apostrophecms/modal/ui/apos/components/AposModal.vue +52 -23
- package/modules/@apostrophecms/modal/ui/apos/components/AposModalTabs.vue +6 -1
- package/modules/@apostrophecms/oembed/index.js +2 -1
- package/modules/@apostrophecms/piece-type/index.js +2 -1
- package/modules/@apostrophecms/piece-type/ui/apos/components/AposDocsManagerDisplay.vue +7 -2
- package/modules/@apostrophecms/recently-edited/ui/apos/components/AposCellTitle.vue +1 -0
- package/modules/@apostrophecms/rich-text-widget/ui/apos/components/AposRichTextWidgetEditor.vue +21 -4
- package/modules/@apostrophecms/schema/ui/apos/components/AposArrayEditor.vue +1 -0
- package/modules/@apostrophecms/schema/ui/apos/components/AposInputDateAndTime.vue +7 -2
- package/modules/@apostrophecms/schema/ui/apos/components/AposInputSelect.vue +1 -0
- package/modules/@apostrophecms/schema/ui/apos/components/AposInputWrapper.vue +1 -1
- package/modules/@apostrophecms/schema/ui/apos/components/AposSubform.vue +1 -0
- package/modules/@apostrophecms/schema/ui/apos/logic/AposSubform.js +10 -0
- package/modules/@apostrophecms/styles/ui/apos/components/TheAposStyles.vue +1 -0
- package/modules/@apostrophecms/template/index.js +117 -11
- package/modules/@apostrophecms/template/lib/jsxLoader.js +128 -0
- package/modules/@apostrophecms/template/lib/jsxRender.js +490 -0
- package/modules/@apostrophecms/template/lib/jsxRuntime.js +276 -0
- package/modules/@apostrophecms/template/lib/nunjucksLoader.js +11 -36
- package/modules/@apostrophecms/template/lib/viewWatcher.js +113 -0
- package/modules/@apostrophecms/ui/ui/apos/components/AposButtonGroup.vue +1 -1
- package/modules/@apostrophecms/ui/ui/apos/components/AposCellLastEdited.vue +1 -1
- package/modules/@apostrophecms/ui/ui/apos/components/AposSelect.vue +1 -0
- package/modules/@apostrophecms/ui/ui/apos/components/AposSlat.vue +10 -4
- package/modules/@apostrophecms/ui/ui/apos/components/AposSlatList.vue +6 -1
- package/modules/@apostrophecms/ui/ui/apos/components/AposSubformPreview.vue +1 -1
- package/modules/@apostrophecms/ui/ui/apos/components/AposTreeHeader.vue +1 -1
- package/modules/@apostrophecms/ui/ui/apos/scss/global/_inputs.scss +2 -0
- package/modules/@apostrophecms/ui/ui/apos/scss/global/_theme.scss +1 -0
- package/modules/@apostrophecms/uploadfs/index.js +3 -0
- package/modules/@apostrophecms/util/index.js +20 -3
- package/package.json +14 -10
- package/test/add-missing-schema-fields-project/node_modules/.package-lock.json +131 -0
- package/test/add-missing-schema-fields-project/test.js +22 -3
- package/test/assets.js +110 -67
- package/test/db-tools.js +365 -0
- package/test/db.js +24 -15
- package/test/default-adapter.js +256 -0
- package/test/external-front.js +419 -1
- package/test/files.js +28 -0
- package/test/job.js +1 -1
- package/test/modules/jsx-area-test/index.js +23 -0
- package/test/modules/jsx-area-test/views/bad-area.jsx +7 -0
- package/test/modules/jsx-area-test/views/with-area-ctx.jsx +13 -0
- package/test/modules/jsx-area-test/views/with-area.jsx +7 -0
- package/test/modules/jsx-area-test/views/with-widget-ctx.jsx +12 -0
- package/test/modules/jsx-area-test/views/with-widget.jsx +7 -0
- package/test/modules/jsx-async-widget/index.js +6 -0
- package/test/modules/jsx-async-widget/views/widget.jsx +11 -0
- package/test/modules/jsx-bridge-test/index.js +1 -0
- package/test/modules/jsx-bridge-test/views/cross-module.jsx +7 -0
- package/test/modules/jsx-bridge-test/views/disambig-name-only.jsx +7 -0
- package/test/modules/jsx-bridge-test/views/disambig-target.jsx +8 -0
- package/test/modules/jsx-bridge-test/views/disambig-with-template-name.jsx +7 -0
- package/test/modules/jsx-bridge-test/views/include-html.jsx +7 -0
- package/test/modules/jsx-bridge-test/views/include-target.html +4 -0
- package/test/modules/jsx-bridge-test/views/jsx-extends-via-extend.jsx +9 -0
- package/test/modules/jsx-bridge-test/views/jsx-extends.jsx +9 -0
- package/test/modules/jsx-bridge-test/views/jsx-layout.jsx +14 -0
- package/test/modules/jsx-bridge-test/views/njk-extends.jsx +14 -0
- package/test/modules/jsx-bridge-test/views/njk-layout.html +9 -0
- package/test/modules/jsx-bridge-test/views/short-form.jsx +7 -0
- package/test/modules/jsx-bridge-test/views/short-target.jsx +3 -0
- package/test/modules/jsx-component-test/index.js +15 -0
- package/test/modules/jsx-component-test/views/greet.html +1 -0
- package/test/modules/jsx-component-test/views/uses-component.jsx +8 -0
- package/test/modules/jsx-ctx-widget/index.js +6 -0
- package/test/modules/jsx-ctx-widget/views/widget.jsx +4 -0
- package/test/modules/jsx-mixed-test/index.js +9 -0
- package/test/modules/jsx-mixed-test/views/apos-full.jsx +21 -0
- package/test/modules/jsx-mixed-test/views/async-list.jsx +12 -0
- package/test/modules/jsx-mixed-test/views/lib/format.js +3 -0
- package/test/modules/jsx-mixed-test/views/localized.jsx +3 -0
- package/test/modules/jsx-mixed-test/views/partial.jsx +3 -0
- package/test/modules/jsx-mixed-test/views/safe-helper.jsx +3 -0
- package/test/modules/jsx-mixed-test/views/syntax-error.jsx +3 -0
- package/test/modules/jsx-mixed-test/views/throws.jsx +5 -0
- package/test/modules/jsx-mixed-test/views/uses-import.jsx +5 -0
- package/test/modules/jsx-mixed-test/views/uses-require.jsx +5 -0
- package/test/modules/jsx-watcher-cross-test/index.js +5 -0
- package/test/modules/jsx-watcher-cross-test/views/cross-template.jsx +3 -0
- package/test/modules/jsx-watcher-test/index.js +5 -0
- package/test/modules/jsx-watcher-test/views/watcher-test.jsx +3 -0
- package/test/modules/template-jsx-options-test/index.js +12 -0
- package/test/modules/template-jsx-options-test/views/options-test.jsx +9 -0
- package/test/modules/template-jsx-subclass-test/index.js +3 -0
- package/test/modules/template-jsx-subclass-test/views/override-test.jsx +3 -0
- package/test/modules/template-jsx-test/index.js +9 -0
- package/test/modules/template-jsx-test/views/boolean-attrs.jsx +11 -0
- package/test/modules/template-jsx-test/views/class-and-for.jsx +7 -0
- package/test/modules/template-jsx-test/views/dangerously-set.jsx +3 -0
- package/test/modules/template-jsx-test/views/escape-attr.jsx +3 -0
- package/test/modules/template-jsx-test/views/escape-body.jsx +3 -0
- package/test/modules/template-jsx-test/views/inherit-test.jsx +3 -0
- package/test/modules/template-jsx-test/views/list.jsx +7 -0
- package/test/modules/template-jsx-test/views/override-test.jsx +3 -0
- package/test/modules/template-jsx-test/views/svg-attrs.jsx +27 -0
- package/test/modules/template-jsx-test/views/test.jsx +3 -0
- package/test/modules/template-jsx-test/views/void-elements.jsx +9 -0
- package/test/templates-jsx-watcher.js +135 -0
- package/test/templates-jsx.js +537 -0
- package/test/utils.js +103 -0
- package/test-lib/util.js +50 -14
- 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
|
+
}
|