Skip to content

Commit e30a945

Browse files
committed
Inject nodes
1 parent 00cda14 commit e30a945

6 files changed

Lines changed: 300 additions & 6 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828
* Adds a new way to make `GET` requests with a large query string. It can become a `POST` request containing the key `__aposGetWithQuery` in its body.
2929
A middleware checks for this key and converts the request back to a `GET` request with the right `req.query` property.
3030
* Adds a new batch operation to tag images.
31+
* Adds `prependNodes` and `appendNodes` methods to every module. These methods allow you to inject HTML to every page using a `node` declaration. More information can be found in the [documentation](https://docs.apostrophecms.org/reference/modules/module.html#prepend-and-append-nodes).
3132

3233
### Changes
3334

modules/@apostrophecms/module/index.js

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -333,6 +333,62 @@ module.exports = {
333333
});
334334
},
335335

336+
// Prepepnd/append nodes, rendered to HTML, to a given location. Supports
337+
// the same locations as `apos.template.prepend()` and `apos.template.append()`.
338+
// The rendered markup is automatically escaped and injected into the
339+
// appropriate location in the HTML document.
340+
// The `method` argument is the name of existing method in the current module.
341+
// It should be string, passing a function will throw an error.
342+
// Example:
343+
// ```
344+
// self.prependNodes('head', 'myMethod');
345+
// self.appendNodes('main', 'anotherMethod');
346+
// ```
347+
// In the example above, `myMethod` and `anotherMethod` should be defined
348+
// in the current module, and they should return an array of node objects.
349+
// ```
350+
// methods(self) {
351+
// return {
352+
// myMethod() {
353+
// return [
354+
// {
355+
// name: 'meta',
356+
// attributes: {
357+
// name: 'my-meta',
358+
// content: 'my content'
359+
// }
360+
// }
361+
// ];
362+
// },
363+
// anotherMethod() {
364+
// return [
365+
// {
366+
// tag: 'h4',
367+
// body: [
368+
// {
369+
// text: 'Heading text'
370+
// }
371+
// ]
372+
// }
373+
// ];
374+
// }
375+
// };
376+
// }
377+
// ```
378+
// Node objects SHOULD have either `name` or `text` property.
379+
// A node with `name` property can have `attrs` (array of element attributes)
380+
// and/or `body` (array of child nodes).
381+
// `text` nodes are rendered as text (no HTML tags), they don't support
382+
// any other properties.
383+
prependNodes(location, method) {
384+
return self.apos.template
385+
.prependNodes(location, self.__meta.name, method);
386+
},
387+
appendNodes(location, method) {
388+
return self.apos.template
389+
.appendNodes(location, self.__meta.name, method);
390+
},
391+
336392
// Render a template. Template overrides are respected; the
337393
// project level modules/modulename/views folder wins if
338394
// it has such a template, followed by the npm module,

modules/@apostrophecms/template/index.js

Lines changed: 132 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ const Promise = require('bluebird');
3434
const path = require('path');
3535
const { stripIndent } = require('common-tags');
3636
const { SemanticAttributes } = require('@opentelemetry/semantic-conventions');
37+
const voidElements = require('void-elements');
3738

3839
module.exports = {
3940
options: { alias: 'template' },
@@ -50,9 +51,11 @@ module.exports = {
5051
async inject(req, data) {
5152
const key = `${data.end}-${data.where}`;
5253
const components = self.getInjectedComponents(key, data);
54+
const html = data.when ? '' : self.injectNodes(req, key);
5355

5456
return {
55-
components
57+
components,
58+
html
5659
};
5760
}
5861
};
@@ -67,12 +70,10 @@ module.exports = {
6770
};
6871

6972
self.envs = {};
70-
7173
self.filters = {};
72-
7374
self.nunjucks = self.options.language || require('nunjucks');
74-
7575
self.insertions = {};
76+
self.runtimeNodes = {};
7677

7778
},
7879
handlers(self) {
@@ -1073,6 +1074,127 @@ module.exports = {
10731074
};
10741075
},
10751076

1077+
prependNodes(location, moduleName, method) {
1078+
self.registerRuntimeNodes('prepend', location, moduleName, method);
1079+
},
1080+
1081+
appendNodes(location, moduleName, method) {
1082+
self.registerRuntimeNodes('append', location, moduleName, method);
1083+
},
1084+
1085+
registerRuntimeNodes(end, location, moduleName, method) {
1086+
if (typeof method !== 'string') {
1087+
throw new Error(
1088+
`Do not pass a function to "apos.template.${end}Nodes()". ` +
1089+
'Pass a string with the name of the method to call on the module, ' +
1090+
'e.g. "myMethod"'
1091+
);
1092+
}
1093+
if (typeof moduleName !== 'string') {
1094+
throw new Error(
1095+
`Invalid "moduleName" detected in "apos.template.${end}Nodes()". ` +
1096+
'Pass a string with the name of the module, e.g. "some-module"'
1097+
);
1098+
}
1099+
1100+
if (typeof self.apos.modules[moduleName] === 'undefined') {
1101+
throw new Error(
1102+
`Invalid module "${moduleName}"detected in "apos.template.${end}Nodes()". ` +
1103+
'Make sure the module is registered in your "app.js" file.'
1104+
);
1105+
}
1106+
1107+
if (!self.apos.modules[moduleName][method]) {
1108+
throw new Error(
1109+
`Invalid method "${method}" detected in "apos.template.${end}Nodes()". ` +
1110+
`Make sure the module "${moduleName}" has a method named "${method}".`
1111+
);
1112+
}
1113+
1114+
const key = end + '-' + location;
1115+
self.runtimeNodes[key] = self.runtimeNodes[key] || [];
1116+
self.runtimeNodes[key].push({
1117+
moduleName,
1118+
method
1119+
});
1120+
},
1121+
1122+
// Accepts array of node objects and returns a string - HTML
1123+
// representation of the nodes. Example nodes:
1124+
// [
1125+
// {
1126+
// name: 'div',
1127+
// attrs: { class: 'my-class' },
1128+
// body: [
1129+
// {
1130+
// text: 'Hello world'
1131+
// }
1132+
// ]
1133+
// },
1134+
// {
1135+
// name: 'link',
1136+
// attrs: { href: '/some/path', rel: 'stylesheet' }
1137+
// }
1138+
// ]
1139+
// Node objects SHOULD have either `name` or `text` property.
1140+
// A node with `name` can have `attrs` (array of element attributes)
1141+
// and `body` (array of child nodes).
1142+
// `text` nodes are rendered as text (no HTML tags).
1143+
renderNodes(nodes) {
1144+
if (!Array.isArray(nodes)) {
1145+
self.logError(
1146+
'render-nodes',
1147+
'Invalid nodes array passed to apos.template.renderNodes()'
1148+
);
1149+
return '';
1150+
}
1151+
return nodes.map(node => {
1152+
if (node.text) {
1153+
return self.apos.util.escapeHtml(node.text);
1154+
}
1155+
if (node.name) {
1156+
const name = self.apos.util.escapeHtml(node.name);
1157+
const attrs = Object.entries(node.attrs || {})
1158+
.map(([ key, value ]) => {
1159+
return ` ${self.apos.util.escapeHtml(key)}="${self.apos.util.escapeHtml(value)}"`;
1160+
})
1161+
.join('')
1162+
.trimEnd();
1163+
1164+
if (!node.body && voidElements[name]) {
1165+
return `<${name}${attrs} />`;
1166+
}
1167+
1168+
const body = (
1169+
node.body ? self.renderNodes(node.body) : ''
1170+
).trim();
1171+
1172+
return `<${name}${attrs}>${body}</${name}>`;
1173+
}
1174+
self.logError(
1175+
'render-nodes',
1176+
'Invalid node object passed to apos.template.renderNodes()',
1177+
{ node }
1178+
);
1179+
return '';
1180+
})
1181+
.join('')
1182+
.trim();
1183+
},
1184+
1185+
injectNodes(req, locationKey) {
1186+
const nodes = self.runtimeNodes[locationKey] || [];
1187+
if (!nodes.length) {
1188+
return '';
1189+
}
1190+
const output = [];
1191+
for (const { moduleName, method } of nodes) {
1192+
const handler = self.apos.modules[moduleName][method];
1193+
output.push(self.renderNodes(handler(req)));
1194+
}
1195+
return output.join('\n');
1196+
},
1197+
10761198
async annotateDataForExternalFront(req, template, data, moduleName) {
10771199
const docs = self.getDocsForExternalFront(req, template, data, moduleName);
10781200
for (const doc of docs) {
@@ -1101,6 +1223,12 @@ module.exports = {
11011223
data.bundleMarkup.js.push(...Array.from(modulePreload));
11021224
}
11031225

1226+
// `node` injections
1227+
data.prependHead = self.injectNodes(req, 'prepend-head');
1228+
data.appendHead = self.injectNodes(req, 'append-head');
1229+
data.prependBody = self.injectNodes(req, 'prepend-body');
1230+
data.appendBody = self.injectNodes(req, 'append-body');
1231+
11041232
return data;
11051233
},
11061234

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
11
{% for c in data.components %}
22
{% component c %}
33
{% endfor %}
4+
{{ (data.html | safe) if data.html }}

package.json

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,7 @@
7575
"express": "^4.16.4",
7676
"express-bearer-token": "^3.0.0",
7777
"express-cache-on-demand": "^1.0.3",
78-
"express-session": "^1.17.1",
78+
"express-session": "^1.18.2",
7979
"form-data": "^4.0.0",
8080
"fs-extra": "^7.0.1",
8181
"glob": "^10.4.5",
@@ -123,6 +123,7 @@
123123
"tough-cookie": "^4.0.0",
124124
"underscore.string": "^3.3.4",
125125
"uploadfs": "^1.24.3",
126+
"void-elements": "^3.1.0",
126127
"vue": "^3.3.8",
127128
"vue-advanced-cropper": "^2.8.8",
128129
"vue-loader": "^17.1.0",
@@ -142,4 +143,4 @@
142143
"browserslist": [
143144
"ie >= 10"
144145
]
145-
}
146+
}

test/templates.js

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,62 @@ describe('Templates', function() {
6666
}
6767
};
6868
}
69+
},
70+
'inject-nodes': {
71+
init(self) {
72+
self.prependNodes('head', 'prependHeadTest');
73+
self.appendNodes('head', 'appendHeadTest');
74+
self.prependNodes('main', 'prependMainTest');
75+
self.appendNodes('main', 'appendMainTest');
76+
},
77+
methods(self) {
78+
return {
79+
prependHeadTest(req) {
80+
return [
81+
{
82+
name: 'meta',
83+
attrs: {
84+
name: 'prepend-node-head-test>'
85+
}
86+
}
87+
];
88+
},
89+
appendHeadTest(req) {
90+
return [
91+
{
92+
name: 'meta',
93+
attrs: {
94+
name: 'append-node-head-test>'
95+
}
96+
}
97+
];
98+
},
99+
prependMainTest(req) {
100+
return [
101+
{
102+
name: 'h4',
103+
body: [
104+
{
105+
text: 'prepend-node-main-test<test>'
106+
}
107+
]
108+
}
109+
];
110+
},
111+
appendMainTest(req) {
112+
return [
113+
{
114+
name: 'h4',
115+
body: [
116+
{
117+
text: 'append-node-main-test<test>'
118+
}
119+
]
120+
}
121+
];
122+
}
123+
};
124+
}
69125
}
70126
}
71127
});
@@ -207,6 +263,57 @@ describe('Templates', function() {
207263
assert.ok(appendDevWebpackIndex < appendDevIndex);
208264
});
209265

266+
it('should render pages successfully with nodes prepend and append', async function() {
267+
const req = apos.task.getReq();
268+
const html = (await apos.modules['with-layout-page'].renderPage(req, 'page'))
269+
.split('<body');
270+
const head = html[0];
271+
const body = html[1];
272+
273+
const prependNodeHeadTestIndex = head.indexOf('<meta name="prepend-node-head-test&gt;" />');
274+
const appendNodeHeadTestIndex = head.indexOf('<meta name="append-node-head-test&gt;" />');
275+
const prependNodeMainTestIndex = body.indexOf('<h4>prepend-node-main-test&lt;test&gt;</h4>');
276+
const appendNodeMainTestIndex = body.indexOf('<h4>append-node-main-test&lt;test&gt;</h4>');
277+
278+
// Duplicate checks
279+
const prependNodeHeadLastIndex = head.lastIndexOf('<meta name="prepend-node-head-test&gt;" />');
280+
const appendNodeHeadLastIndex = head.lastIndexOf('<meta name="append-node-head-test&gt;" />');
281+
const prependNodeMainLastIndex = body.lastIndexOf('<h4>prepend-node-main-test&lt;test&gt;</h4>');
282+
const appendNodeMainLastIndex = body.lastIndexOf('<h4>append-node-main-test&lt;test&gt;</h4>');
283+
284+
const actual = {
285+
prependHeadExist: prependNodeHeadTestIndex !== -1,
286+
appendHeadExist: appendNodeHeadTestIndex !== -1,
287+
prependHeadNoDuplicate: prependNodeHeadTestIndex === prependNodeHeadLastIndex,
288+
appendHeadNoDuplicate: appendNodeHeadTestIndex === appendNodeHeadLastIndex,
289+
headOrder: prependNodeHeadTestIndex < appendNodeHeadTestIndex,
290+
prependMainExist: prependNodeMainTestIndex !== -1,
291+
appendMainExist: appendNodeMainTestIndex !== -1,
292+
prependMainNoDuplicate: prependNodeMainTestIndex === prependNodeMainLastIndex,
293+
appendMainNoDuplicate: appendNodeMainTestIndex === appendNodeMainLastIndex,
294+
mainOrder: prependNodeMainTestIndex < appendNodeMainTestIndex
295+
};
296+
297+
const expected = {
298+
prependHeadExist: true,
299+
appendHeadExist: true,
300+
prependHeadNoDuplicate: true,
301+
appendHeadNoDuplicate: true,
302+
headOrder: true,
303+
prependMainExist: true,
304+
appendMainExist: true,
305+
prependMainNoDuplicate: true,
306+
appendMainNoDuplicate: true,
307+
mainOrder: true
308+
};
309+
310+
assert.deepEqual(
311+
actual,
312+
expected,
313+
'There was an issue with the prepend/append node rendering.'
314+
);
315+
});
316+
210317
it('should not escape <br /> generated by the nlbr filter, but should escape tags in its input', async function() {
211318
const req = apos.task.getAnonReq();
212319
const result = await apos.modules['template-test'].render(req, 'testWithNlbrFilter');

0 commit comments

Comments
 (0)