UNPKG

8.8 kBJavaScriptView Raw
1'use strict';
2
3Object.defineProperty(exports, '__esModule', { value: true });
4
5var preact = require('preact');
6var CACHE = require('gcache');
7
8class Router {}
9
10const META_TYPES = ['name', 'httpEquiv', 'charSet', 'itemProp'];
11
12// returns a function for filtering head child elements
13// which shouldn't be duplicated, like <title/>.
14// TODO: less fancy
15function unique() {
16 const tags = [];
17 const metaTypes = [];
18 const metaCategories = {};
19 return h => {
20 switch (h.nodeName) {
21 case 'title':
22 case 'base':
23 if (~tags.indexOf(h.nodeName)) return false
24 tags.push(h.nodeName);
25 break
26 case 'meta':
27 for (let i = 0, len = META_TYPES.length; i < len; i++) {
28 const metatype = META_TYPES[i];
29 if (!h.attributes.hasOwnProperty(metatype)) continue
30 if (metatype === 'charSet') {
31 if (~metaTypes.indexOf(metatype)) return false
32 metaTypes.push(metatype);
33 } else {
34 const category = h.attributes[metatype];
35 const categories = metaCategories[metatype] || [];
36 if (~categories.indexOf(category)) return false
37 categories.push(category);
38 metaCategories[metatype] = categories;
39 }
40 }
41 break
42 }
43 return true
44 }
45}
46
47class Head extends preact.Component {
48 render() {
49 const docProps = this.context._documentProps;
50 let children = this.props.children || [];
51
52 // Head was rendered outside of a Document.
53 // This shouldn't happen but don't break if it's not
54 if (!docProps) {
55 return preact.h('head', {}, children)
56 }
57
58 // we have <Head> children from down in the tree,
59 // add them as children to our head now
60 if (docProps.heads) {
61 children = []
62 .concat(docProps.heads)
63 .concat(children)
64 .filter(unique());
65 }
66
67 // add in the rewound style vnodes
68 if (docProps.styles) {
69 children = [].concat(children).concat(docProps.styles);
70 }
71
72 // return an empty <head> if we don't have any children
73 if (children.length == 0) {
74 return preact.h('head')
75 }
76
77 return preact.h('head', {}, children)
78 }
79}
80
81class Script extends preact.Component {
82 render() {
83 const docProps = this.context._documentProps;
84
85 // Page was rendered outside of a Document
86 // or there's no pageHTML prop
87 if (!docProps || !docProps.scripts) {
88 return null
89 }
90
91 return preact.h('div', { id: 'elmo-scripts' }, [
92 docProps.scripts.map(script => preact.h('script', { src: script }))
93 ])
94 }
95}
96
97class Page extends preact.Component {
98 render() {
99 const docProps = this.context._documentProps;
100
101 // Page was rendered outside of a Document
102 // or there's no pageHTML prop
103 if (!docProps || typeof docProps.pageHTML === 'undefined') {
104 return null
105 }
106
107 return preact.h('div', {
108 id: 'elmo',
109 dangerouslySetInnerHTML: { __html: docProps.pageHTML }
110 })
111 }
112}
113
114class Document extends preact.Component {
115 getChildContext() {
116 return { _documentProps: this.props }
117 }
118
119 // Default render implementation, you can override
120 // this by extending the Document
121 render() {
122 return preact.h('html', {}, [
123 preact.h(Head, {}),
124 preact.h('body', {}, [preact.h(Page, {}), preact.h(Script, {})])
125 ])
126 }
127}
128
129// Statics
130Document.Head = Head;
131Document.Script = Script;
132Document.Page = Page;
133
134const VALID_TYPES = {
135 title: true,
136 meta: true,
137 base: true,
138 link: true,
139 style: true,
140 script: true
141};
142const IS_BROWSER = typeof window !== 'undefined';
143const MARKER = 'elmo-head';
144const ATTR_MAP = {
145 acceptCharset: 'accept-charset',
146 className: 'class',
147 htmlFor: 'for',
148 httpEquiv: 'http-equiv'
149};
150
151// our mounted head components
152// reset on each rewind
153CACHE.set('heads', []);
154
155// only update on client-side
156function update() {
157 if (!IS_BROWSER) return
158 updateClient(CACHE.get('heads'));
159}
160
161// client updates
162function updateClient(headComponents) {
163 const vnodes = flatten(headComponents);
164 const buckets = {};
165
166 // buckets the vnodes
167 for (let i = 0; i < vnodes.length; i++) {
168 const vnode = vnodes[i];
169 const nodeName = vnode.nodeName;
170 if (typeof nodeName !== 'string') continue
171 if (!VALID_TYPES[nodeName]) continue
172 const bucket = buckets[nodeName] || [];
173 bucket.push(vnode);
174 buckets[nodeName] = bucket;
175 }
176
177 // only write the title once
178 if (buckets.title) {
179 syncTitle(buckets.title[0]);
180 }
181
182 // sync the vnodes to the DOM
183 for (let type in VALID_TYPES) {
184 if (type === 'title') continue
185 syncElements(type, buckets[type] || []);
186 }
187}
188
189// Map an array of Head components into VDOM nodes
190function flatten(headComponents) {
191 let children = [];
192
193 for (let i = 0; i < headComponents.length; i++) {
194 const head = headComponents[i];
195 if (!head.props || !head.props.children) continue
196 children = children.concat(head.props.children || []);
197 }
198
199 // TODO: look back at next.js to see why we
200 // need to do this double reversal
201 // TODO: less fancy
202 children = children
203 .reverse()
204 .filter(unique())
205 .reverse();
206
207 const results = [];
208 for (let i = 0; i < children.length; i++) {
209 const child = children[i];
210 // strings are handled natively & pass functions through
211 if (typeof child === 'string' || !('nodeName' in child)) {
212 results.push(child);
213 continue
214 }
215 // ignore invalid head tags
216 if (!VALID_TYPES[child.nodeName]) {
217 continue
218 }
219
220 // mark the classname
221 const attrs = child.attributes || {};
222 const className = attrs.className ? `${attrs.className} ${MARKER}` : MARKER;
223 results.push(preact.cloneElement(child, { className }));
224 }
225
226 return results
227}
228
229// write the title to the DOM
230function syncTitle(vnode) {
231 const title = [].concat(vnode.children).join('');
232 if (title !== document.title) document.title = title;
233}
234
235// sync elements with the DOM
236function syncElements(type, vnodes) {
237 const headElement = document.getElementsByTagName('head')[0];
238 const oldNodes = Array.prototype.slice.call(
239 headElement.querySelectorAll(type + '.' + MARKER)
240 );
241
242 const newNodes = [];
243 for (let i = 0; i < vnodes.length; i++) {
244 newNodes.push(vnodeToDOMNode(vnodes[i]));
245 }
246
247 // loop over old nodes looking for old nodes to delete
248 const dels = [];
249 for (let i = 0; i < oldNodes.length; i++) {
250 const oldNode = oldNodes[i];
251 let found = false;
252 for (let j = 0; j < newNodes.length; j++) {
253 if (oldNode.isEqualNode(newNodes[j])) {
254 found = true;
255 break
256 }
257 }
258 if (!found) {
259 dels.push(oldNode);
260 }
261 }
262
263 // loop over new nodes looking for new nodes to add
264 const adds = [];
265 for (let i = 0; i < newNodes.length; i++) {
266 const newNode = newNodes[i];
267 let found = false;
268 for (let j = 0; j < oldNodes.length; j++) {
269 if (newNode.isEqualNode(oldNodes[j])) {
270 found = true;
271 break
272 }
273 }
274 if (!found) {
275 adds.push(newNode);
276 }
277 }
278
279 // remove the old nodes
280 for (let i = 0; i < dels.length; i++) {
281 const node = dels[i];
282 if (!node.parentNode) continue
283 node.parentNode.removeChild(node);
284 }
285
286 // add the new nodes
287 for (let i = 0; i < adds.length; i++) {
288 const node = adds[i];
289 headElement.appendChild(node);
290 }
291}
292
293// vnodeToDOMNode converts a virtual node into a DOM node
294function vnodeToDOMNode(vnode) {
295 const el = document.createElement(vnode.nodeName);
296 const attrs = vnode.attributes || {};
297 const children = vnode.children;
298 for (const p in attrs) {
299 if (!attrs.hasOwnProperty(p)) continue
300 if (p === 'dangerouslySetInnerHTML') continue
301 const attr = ATTR_MAP[p] || p.toLowerCase();
302 el.setAttribute(attr, attrs[p]);
303 }
304 if (attrs['dangerouslySetInnerHTML']) {
305 el.innerHTML = attrs['dangerouslySetInnerHTML'].__html || '';
306 } else if (children) {
307 el.textContent = typeof children === 'string' ? children : children.join('');
308 }
309 return el
310}
311
312// All the heads are collected together
313class Head$1 extends preact.Component {
314 // server: this should get called before rewind
315 // client: doesn't matter where it is really
316 componentWillMount() {
317 const heads = CACHE.get('heads');
318 CACHE.set('heads', heads.concat(this));
319 update();
320 }
321
322 static rewind() {
323 const children = flatten(CACHE.get('heads'));
324 CACHE.set('heads', []);
325 return children
326 }
327
328 componentDidUpdate() {
329 update();
330 }
331
332 componentWillUnmount() {
333 const heads = CACHE.get('heads');
334 const i = heads.indexOf(this);
335 const updated = [];
336 heads.forEach((head, j) => {
337 if (j === i) return
338 updated.push(head);
339 });
340 CACHE.set('heads', updated);
341 update();
342 }
343
344 render() {
345 return null
346 }
347}
348
349exports.Document = Document;
350exports.Head = Head$1;
351exports.Router = Router;