Skip to content

Commit 0ca5210

Browse files
committed
[ Add ] Common View class
[ Add ] 2 utility methods
1 parent eaed402 commit 0ca5210

File tree

11 files changed

+356
-53
lines changed

11 files changed

+356
-53
lines changed

package-lock.json

Lines changed: 4 additions & 4 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "dom-renderer",
3-
"version": "0.2.0",
3+
"version": "0.3.0",
44
"description": "Template engine based on HTML 5 & ECMAScript 6",
55
"keywords": [
66
"template",
@@ -56,7 +56,7 @@
5656
"jsdom": "^13.2.0",
5757
"koapache": "^1.0.5",
5858
"mocha": "^5.2.0",
59-
"prettier": "^1.15.3",
59+
"prettier": "^1.16.4",
6060
"should": "^13.2.3",
6161
"should-sinon": "0.0.6",
6262
"sinon": "^7.2.3"

source/Template.js

Lines changed: 34 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,36 @@ export default class Template extends Array {
5050
);
5151
}
5252

53+
/**
54+
* @private
55+
*
56+
* @param {Number} index
57+
* @param {?Object} context
58+
* @param {Object[]} [scope=[]]
59+
*
60+
* @return {*}
61+
*/
62+
eval(index, context, scope = []) {
63+
try {
64+
let value = this[index].apply(context, scope);
65+
66+
if (value != null) {
67+
if (Object(value) instanceof String)
68+
try {
69+
value = JSON.parse(value);
70+
} catch (error) {
71+
//
72+
}
73+
74+
return value;
75+
}
76+
} catch (error) {
77+
console.warn(error);
78+
}
79+
80+
return '';
81+
}
82+
5383
/**
5484
* @param {?Object} context - `this` in the expression
5585
* @param {...Object} [scope] - Scoped varible objects
@@ -58,12 +88,10 @@ export default class Template extends Array {
5888
*/
5989
evaluate(context, ...scope) {
6090
var value = this[1]
61-
? this[template_raw].replace(/\$\{(\d+)\}/g, (_, index) => {
62-
const value = this[index].apply(context, scope);
63-
64-
return value != null ? value : '';
65-
})
66-
: this[0].apply(context, scope);
91+
? this[template_raw].replace(/\$\{(\d+)\}/g, (_, index) =>
92+
this.eval(index, context, scope)
93+
)
94+
: this.eval(0, context, scope);
6795

6896
if (this[template_value] !== value) {
6997
if (this.onChange) this.onChange(value, this[template_value]);

source/View.js

Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,166 @@
1+
import { parseDOM, scanTemplate, stringifyDOM, attributeMap } from './utility';
2+
3+
import Template from './Template';
4+
5+
const forEach = [].forEach;
6+
7+
const view_template = Symbol('View template'),
8+
view_top = new Map();
9+
10+
export default class View extends Map {
11+
/**
12+
* @param {String} template
13+
*/
14+
constructor(template) {
15+
super()[view_template] = template + '';
16+
17+
forEach.call(parseDOM(template).childNodes, node => {
18+
view_top.set(node, this);
19+
20+
if (node.nodeType === 1) this.parseTree(node);
21+
});
22+
}
23+
24+
/**
25+
* @type {Node[]}
26+
*/
27+
get topNodes() {
28+
const list = [];
29+
30+
view_top.forEach((view, node) => view === this && list.push(node));
31+
32+
return list;
33+
}
34+
35+
/**
36+
* @return {String} HTML/XML source code of this View
37+
*/
38+
toString() {
39+
return stringifyDOM(this.topNodes);
40+
}
41+
42+
/**
43+
* @param {Element} root
44+
*/
45+
static clear(root) {
46+
forEach.call(
47+
root.childNodes,
48+
node => view_top.delete(node) && node.remove()
49+
);
50+
}
51+
52+
/**
53+
* @return {View}
54+
*/
55+
clone() {
56+
return new View(this[view_template]);
57+
}
58+
59+
/**
60+
* @param {HTMLElement} root
61+
*
62+
* @return {String}
63+
*/
64+
static getTemplate(root) {
65+
for (let node of root.childNodes)
66+
if (node.nodeName.toLowerCase() === 'template')
67+
return node.innerHTML;
68+
else if (node.nodeType === 8) return node.nodeValue;
69+
70+
const raw = root.innerHTML;
71+
72+
return (root.innerHTML = '') || raw;
73+
}
74+
75+
/**
76+
* @private
77+
*
78+
* @param {String} type
79+
* @param {Element} element
80+
* @param {Template|View} renderer
81+
*/
82+
addNode(type, element, renderer) {
83+
this.set({ type, element }, renderer);
84+
}
85+
86+
/**
87+
* @private
88+
*
89+
* @param {Element} root
90+
*/
91+
parseTree(root) {
92+
scanTemplate(root, Template.Expression, '[data-view]', {
93+
attribute: ({ ownerElement, name, value }) => {
94+
name = attributeMap[name] || name;
95+
96+
this.addNode(
97+
'Attr',
98+
ownerElement,
99+
new Template(
100+
value,
101+
['view'],
102+
name in ownerElement
103+
? value => (ownerElement[name] = value)
104+
: value => ownerElement.setAttribute(name, value)
105+
)
106+
);
107+
},
108+
text: node => {
109+
const { parentNode } = node;
110+
111+
this.addNode(
112+
'Text',
113+
parentNode,
114+
new Template(
115+
node.nodeValue,
116+
['view'],
117+
parentNode.firstElementChild
118+
? value => (node.nodeValue = value)
119+
: value => (parentNode.innerHTML = value)
120+
)
121+
);
122+
},
123+
view: node =>
124+
this.addNode(
125+
'View',
126+
node,
127+
new View(View.getTemplate(node).trim())
128+
)
129+
});
130+
}
131+
132+
/**
133+
* @param {Object} data
134+
*
135+
* @return {View}
136+
*/
137+
render(data) {
138+
this.forEach((renderer, { type, element }) => {
139+
switch (type) {
140+
case 'Attr':
141+
case 'Text':
142+
return renderer.evaluate(element, data);
143+
}
144+
145+
var _data_ = data[element.dataset.view];
146+
147+
if (!_data_ && _data_ !== null) return;
148+
149+
View.clear(element);
150+
151+
if (!_data_) return;
152+
153+
if (!(_data_ instanceof Array)) _data_ = [_data_];
154+
155+
element.append.apply(
156+
element,
157+
[].concat.apply(
158+
[],
159+
_data_.map(item => renderer.clone().render(item).topNodes)
160+
)
161+
);
162+
});
163+
164+
return this;
165+
}
166+
}

source/index.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
11
export * from './utility';
22

33
export { default as Template } from './Template';
4+
5+
export { default } from './View';

source/utility.js

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,13 @@
1+
/**
2+
* @type {Object}
3+
*/
4+
export const attributeMap = {
5+
class: 'className',
6+
for: 'htmlFor',
7+
readonly: 'readOnly',
8+
value: 'defaultValue'
9+
};
10+
111
const HTML_page = /<!?(DocType|html|head|body|meta|title|base)[\s\S]*?>/,
212
parser = new DOMParser();
313

@@ -14,6 +24,19 @@ export function parseDOM(markup) {
1424
}).content;
1525
}
1626

27+
/**
28+
* @param {Node[]} list
29+
*
30+
* @return {String} HTML/XML source code
31+
*/
32+
export function stringifyDOM(list) {
33+
const box = document.createElement('div');
34+
35+
box.append.apply(box, Array.from(list, node => node.cloneNode(true)));
36+
37+
return box.innerHTML;
38+
}
39+
1740
/**
1841
* @param {Node} root
1942
* @param {function(node: Node): Number} [filter] - https://developer.mozilla.org/en-US/docs/Web/API/NodeFilter/acceptNode
@@ -56,7 +79,7 @@ export function scanTemplate(
5679
Array.from(iterator, node => {
5780
switch (node.nodeType) {
5881
case 1:
59-
Array.from(
82+
[].forEach.call(
6083
node.attributes,
6184
attr => expression.test(attr.value) && attribute(attr)
6285
);

test/View.js

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
import { readFileSync, readJSONSync } from 'fs-extra';
2+
3+
import { parseDOM } from '../source/utility';
4+
5+
import View from '../source/View';
6+
7+
var view = parseDOM(readFileSync('test/source/index.html')).firstElementChild
8+
.innerHTML,
9+
data = readJSONSync('test/source/index.json');
10+
11+
describe('DOM View', () => {
12+
/**
13+
* @test {View#parseTree}
14+
*/
15+
it('Parsing', () => {
16+
view = new View(view);
17+
18+
Array.from(view.keys(), ({ type }) => type).should.match([
19+
'Attr',
20+
'Text',
21+
'View',
22+
'View'
23+
]);
24+
});
25+
26+
/**
27+
* @test {View#render}
28+
*/
29+
it('Rendering', () => {
30+
(view.render(data) + '').should.be.equal(`
31+
<h1>TechQuery</h1>
32+
33+
<ul data-view="profile">
34+
<template>
35+
<li>\${view.URL}</li>
36+
<li>\${view.title}</li>
37+
</template>
38+
<li>https://tech-query.me/</li>
39+
<li>Web/JavaScript full-stack engineer</li></ul>
40+
41+
<ol data-view="job">
42+
<template>
43+
<li>\${view.title}</li>
44+
</template>
45+
<li>freeCodeCamp</li><li>MVP</li><li>KaiYuanShe</li></ol>
46+
`);
47+
});
48+
49+
/**
50+
* @test {View#render}
51+
*/
52+
it('Updating', () => {
53+
function getLasts() {
54+
return view.topNodes
55+
.map(node => node.nodeType === 1 && node.lastChild)
56+
.filter(Boolean);
57+
}
58+
59+
const last = getLasts(),
60+
_data_ = Object.assign({}, data);
61+
62+
_data_.name = 'tech-query';
63+
_data_.profile = null;
64+
delete _data_.job;
65+
66+
view.render(_data_);
67+
68+
const now = getLasts();
69+
70+
now[0].should.not.be.equal(last[0]);
71+
now[1].nodeName.should.not.be.equal('LI');
72+
now[2].should.be.equal(last[2]);
73+
});
74+
});

test/mocha.opts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
--require @babel/register
22
--require @babel/polyfill
3-
--require ./source/DOM-polyfill
3+
--require ./source/polyfill
44
--require should
55
--require should-sinon
66
--recursive

0 commit comments

Comments
 (0)