Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Support for bind expressions in mustache templates #7602

Merged
merged 82 commits into from
Mar 3, 2017
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
82 commits
Select commit Hold shift + click to select a range
8d9f0af
first pass at adding/removing subtrees
kmh287 Feb 8, 2017
1f0079f
tests passing
kmh287 Feb 8, 2017
f7844a7
Add ability to add and remove bindings in the subtree rooted at a spe…
kmh287 Feb 8, 2017
bdafca6
lint fixes
kmh287 Feb 8, 2017
86f9f9f
merge
kmh287 Feb 8, 2017
ed83a6d
merge conflict
kmh287 Feb 8, 2017
dd7027d
ci issues
kmh287 Feb 9, 2017
7b8bf0b
some cleanup
kmh287 Feb 9, 2017
fdc2f70
Merge branch 'master' of github.com:kmh287/amphtml into #7376_mustach…
kmh287 Feb 9, 2017
884c091
pr comments
kmh287 Feb 9, 2017
da8de76
pr comments, new method of removing bindings for node and its children
kmh287 Feb 10, 2017
f775df8
fix some lint warnings
kmh287 Feb 10, 2017
764e789
test and lint fixes
kmh287 Feb 10, 2017
c454f57
mutation observer
kmh287 Feb 13, 2017
1c8ef1d
update comments
kmh287 Feb 13, 2017
fbff37b
Merge branch '#7376_mustache_binding' into #7376_mustache_binding2
kmh287 Feb 13, 2017
737f943
add observer
kmh287 Feb 13, 2017
6b988d7
naive implementation
kmh287 Feb 13, 2017
8b38137
cleanup
kmh287 Feb 13, 2017
486a7cc
Merge branch 'master' into #7376_mustache_binding2
kmh287 Feb 13, 2017
e24d628
Merge branch 'master' into #7376_mustache_binding
kmh287 Feb 13, 2017
43c1e7c
clean
kmh287 Feb 13, 2017
332bcb7
cleanup bind impl
kmh287 Feb 13, 2017
7524b47
very simple example of bind in a template
kmh287 Feb 13, 2017
ea642c7
pr comments
kmh287 Feb 13, 2017
ee698b7
lint errors
kmh287 Feb 13, 2017
3d2bdf6
Merge branch '#7376_mustache_binding' into #7376_mustache_binding2
kmh287 Feb 13, 2017
ac0aeea
pr comments
kmh287 Feb 14, 2017
3223794
merge
kmh287 Feb 14, 2017
c80f2a2
cleanup
kmh287 Feb 14, 2017
e357373
lint errors
kmh287 Feb 14, 2017
433a5ce
merge
kmh287 Feb 14, 2017
85e759e
pr comments
kmh287 Feb 14, 2017
c698197
pr comments and lint warnings
kmh287 Feb 14, 2017
6d74820
Merge branch '#7376_mustache_binding' into #7376_mustache_binding2
kmh287 Feb 14, 2017
35d70f5
typo
kmh287 Feb 14, 2017
840ed5b
Merge branch '#7376_mustache_binding' into #7376_mustache_binding2
kmh287 Feb 14, 2017
eebfb5c
comments
kmh287 Feb 14, 2017
cf114f6
more lint warnings
kmh287 Feb 14, 2017
9f8c8c5
Merge branch '#7376_mustache_binding' into #7376_mustache_binding2
kmh287 Feb 14, 2017
e35a7eb
pr comment
kmh287 Feb 14, 2017
95f42f2
Merge branch '#7376_mustache_binding' into #7376_mustache_binding2
kmh287 Feb 14, 2017
3bdbbc4
change README
kmh287 Feb 14, 2017
42df400
merge
kmh287 Feb 16, 2017
2da454a
cleanup
kmh287 Feb 16, 2017
84ca405
Merge branch 'master' of github.com:kmh287/amphtml into #7376_mustach…
kmh287 Feb 17, 2017
540371f
pr comments
kmh287 Feb 17, 2017
e40cfe2
pr comments 2
kmh287 Feb 21, 2017
8476b7b
lint warnings
kmh287 Feb 21, 2017
3cee65b
don't use foreach on Nodelist
kmh287 Feb 21, 2017
6b4a0d2
casting
kmh287 Feb 21, 2017
55e343d
more ci warnings
kmh287 Feb 21, 2017
155c915
even more ci warnings
kmh287 Feb 21, 2017
8ae3107
even more ci warnings
kmh287 Feb 21, 2017
3747e80
even more ci issues
kmh287 Feb 21, 2017
7beeb8e
even more ci comments
kmh287 Feb 21, 2017
7f4f93a
even more ci issues
kmh287 Feb 21, 2017
fbdafbc
Merge branch 'master' of github.com:kmh287/amphtml into #7376_mustach…
kmh287 Feb 22, 2017
dd524bc
pr comments
kmh287 Feb 22, 2017
55d6ff3
more pr comments
kmh287 Feb 23, 2017
137eb3b
fix refactoring oversight
kmh287 Feb 23, 2017
25b284b
Merge branch 'master' of github.com:kmh287/amphtml into #7376_mustach…
kmh287 Feb 24, 2017
a10d4f9
pr comments
kmh287 Feb 24, 2017
2d186d4
pr comments
kmh287 Feb 24, 2017
8217a84
merge
kmh287 Feb 24, 2017
d525d76
pr comments
kmh287 Feb 24, 2017
a714c2f
more merging
kmh287 Feb 24, 2017
386c29d
ci issues
kmh287 Feb 24, 2017
7f6cb54
pr comments
kmh287 Feb 28, 2017
e617d6e
assertElement
kmh287 Feb 28, 2017
5eaa991
pr comments
kmh287 Feb 28, 2017
cd4d228
typo
kmh287 Feb 28, 2017
6c1b645
updating sanitizer
kmh287 Feb 28, 2017
1bfef5d
fixing up sanitizer test
kmh287 Feb 28, 2017
757bd53
pr comments
kmh287 Mar 1, 2017
0f6de2e
pr comments
kmh287 Mar 1, 2017
1495793
expanded on amp-mustache tests
kmh287 Mar 1, 2017
eca9501
pr comments
kmh287 Mar 1, 2017
2053634
remove class from whitelist
kmh287 Mar 1, 2017
eab7dc2
restore whole sandbox
kmh287 Mar 1, 2017
08f7956
update mustache validator test and output
kmh287 Mar 2, 2017
60b86f3
pr comments
kmh287 Mar 2, 2017
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 47 additions & 0 deletions examples/bind/forms.amp.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
<!doctype html>
<html ⚡>
<head>
<meta charset="utf-8">
<title>Forms Examples in AMP</title>
<link rel="canonical" href="amps.html" >
<meta name="viewport" content="width=device-width,minimum-scale=1,initial-scale=1">
<style amp-boilerplate>body{-webkit-animation:-amp-start 8s steps(1,end) 0s 1 normal both;-moz-animation:-amp-start 8s steps(1,end) 0s 1 normal both;-ms-animation:-amp-start 8s steps(1,end) 0s 1 normal both;animation:-amp-start 8s steps(1,end) 0s 1 normal both}@-webkit-keyframes -amp-start{from{visibility:hidden}to{visibility:visible}}@-moz-keyframes -amp-start{from{visibility:hidden}to{visibility:visible}}@-ms-keyframes -amp-start{from{visibility:hidden}to{visibility:visible}}@-o-keyframes -amp-start{from{visibility:hidden}to{visibility:visible}}@keyframes -amp-start{from{visibility:hidden}to{visibility:visible}}</style><noscript><style amp-boilerplate>body{-webkit-animation:none;-moz-animation:none;-ms-animation:none;animation:none}</style></noscript>
<script async src="https://cdn.ampproject.org/v0.js"></script>
<script async custom-element="amp-form" src="https://cdn.ampproject.org/v0/amp-form-0.1.js"></script>
<script async custom-template="amp-mustache" src="https://cdn.ampproject.org/v0/amp-mustache-0.1.js"></script>
<script async custom-element="amp-bind" src="https://cdn.ampproject.org/v0/amp-bind-0.1.js"></script>
</head>
<body>

<p>Submit the form and then press a button to change the text in the below template</p>
<button on="tap:AMP.setState(selected=1)">1</button>
<button on="tap:AMP.setState(selected=2)">2</button>
<button on="tap:AMP.setState(selected=3)">3</button>
<button on="tap:AMP.setState(selected=4)">4</button>

<h4>Enter your name and email.</h4>
<form method="post"
action-xhr="/form/echo-json/post"
target="_blank">
<fieldset>
<label>
<span>Your name</span>
<input type="text" name="name" id="name1" required>
</label>
<label>
<span>Your email</span>
<input type="email" name="email" id="email1" required>
</label>
<input type="submit" value="Subscribe">
</fieldset>

<div submit-success>
<template type="amp-mustache">
Success! Thanks {{name}} for entering your email: {{email}}

<p>You have selected: <span [text]="selected ? selected : 'No selection'">0</span></p>
</template>
</div>
</form>
</body>
</html>
101 changes: 94 additions & 7 deletions extensions/amp-bind/0.1/bind-impl.js
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import {reportError} from '../../../src/error';
import {resourcesForDoc} from '../../../src/resources';
import {filterSplice} from '../../../src/utils/array';
import {rewriteAttributeValue} from '../../../src/sanitizer';
import {timerFor} from '../../../src/timer';

const TAG = 'amp-bind';

Expand Down Expand Up @@ -100,10 +101,15 @@ export class Bind {
this.resources_ = resourcesForDoc(ampdoc);

/**
* True if a digest is triggered before scan for bindings completes.
* @private {boolean}
* @const @private {!Array<Promise>}
*/
this.digestQueuedAfterScan_ = false;
this.mutationPromises_ = [];

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this array ever cleaned up in production? If it's only used for testing, you can document it as such and add if (getMode().test) guards as I suggested elsewhere.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done.


/**
* @const @private {MutationObserver}
*/
this.mutationObserver_ =
new MutationObserver(this.onMutationsObserved_.bind(this));

/** @const @private {boolean} */
this.workerExperimentEnabled_ = isExperimentOn(this.win_, 'web-worker');
Expand Down Expand Up @@ -280,7 +286,7 @@ export class Bind {
/**
* Scans `node` for attributes that conform to bind syntax and returns
* a tuple containing bound elements and binding data for the evaluator.
* @param {!Element} node
* @param {!Node} node
* @return {
* !Promise<{
* boundElements: !Array<BoundElementDef>,
Expand All @@ -305,11 +311,18 @@ export class Bind {
// Helper function for scanning the tree walker's next node.
// Returns true if the walker has no more nodes.
const scanNextNode_ = () => {
const element = walker.nextNode();
if (!element) {
const node = walker.currentNode;
if (!node) {
return true;
}
// Walker is filtered to only return elements
const element = dev().assertElement(node);
const tagName = element.tagName;
if (tagName === 'TEMPLATE') {
// Listen for changes in amp-mustache templates
this.observeElementForMutations_(element);
}

const boundProperties = this.scanElement_(element);
if (boundProperties.length > 0) {
boundElements.push({element, boundProperties});
Expand All @@ -323,7 +336,7 @@ export class Bind {
}
expressionToElements[expressionString].push(element);
});
return false;
return !walker.nextNode();
};

return new Promise(resolve => {
Expand Down Expand Up @@ -649,6 +662,61 @@ export class Bind {
}
}

/**
* Begin observing mutations to element. Presently, all supported elements
* that can add/remove bindings add new elements to their parent, so parent
* node should be observed for mutations.
* @private
*/
observeElementForMutations_(element) {
// TODO(kmh287): What if parent is the body tag?
// TODO(kmh287): Generify logic for node observation strategy
// when bind supprots more dynamic nodes.
const elementToObserve = element.parentElement;
this.mutationObserver_.observe(elementToObserve, {childList: true});
}

/**
* Respond to observed mutations. Adds all bindings for newly added elements
* removes bindings for removed elements, then immediately applies the current
* scope to the new bindings.
*
* @param mutations {Array<MutationRecord>}
* @private
*/
onMutationsObserved_(mutations) {
mutations.forEach(mutation => {
// Add bindings for new nodes first to ensure that a binding isn't removed
// and then subsequently re-added.
const addPromises = [];
const addedNodes = mutation.addedNodes;
for (let i = 0; i < addedNodes.length; i++) {
const addedNode = addedNodes[i];
if (addedNode.nodeType == Node.ELEMENT_NODE) {
const addedElement = dev().assertElement(addedNode);
addPromises.push(this.addBindingsForNode_(addedElement));
}
}
const mutationPromise = Promise.all(addPromises).then(() => {
const removePromises = [];
const removedNodes = mutation.removedNodes;
for (let i = 0; i < removedNodes.length; i++) {
const removedNode = removedNodes[i];
if (removedNode.nodeType == Node.ELEMENT_NODE) {
const removedElement = dev().assertElement(removedNode);
removePromises.push(this.removeBindingsForNode_(removedElement));
}
}
return Promise.all(removePromises);
}).then(() => {
return this.digest_();
});
if (getMode().test) {
this.mutationPromises_.push(mutationPromise);
}
});
}

/**
* Returns true if both arrays contain the same strings.
* @param {!(IArrayLike<string>|Array<string>)} a
Expand Down Expand Up @@ -719,4 +787,23 @@ export class Bind {

return false;
}

/**
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not really happy about this. I'm open to suggestions on how to improve this method.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm, maybe dispatch an test-only event (or allow setting of a callback function). You can check getMode().test which should excise it from the prod build.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

OOC, did you try the suggestion above before using polling?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry, this was so many comments back that I missed it. Do you want me to try that approach instead?

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Optional.

* Wait for DOM mutation observer callbacks to fire. Returns a promise
* that resolves when mutation callbacks have fired.
*
* @return {Promise}
*
* @visibleForTesting
*/
waitForAllMutationsForTesting() {
return timerFor(this.win_).poll(5, () => {
return this.mutationPromises_.length > 0;
}).then(() => {
return Promise.all(this.mutationPromises_);
}).then(() => {
this.mutationPromises_.length = 0;
});
}

}
64 changes: 64 additions & 0 deletions extensions/amp-bind/0.1/test/test-bind-impl.js
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import {chunkInstanceForTesting} from '../../../../src/chunk';
import {toArray} from '../../../../src/types';
import {toggleExperiment} from '../../../../src/experiments';
import {user} from '../../../../src/log';
import {installTimerService} from '../../../../src/service/timer-impl';

describes.realWin('amp-bind', {
amp: {
Expand All @@ -33,6 +34,7 @@ describes.realWin('amp-bind', {
let canBindStub;

beforeEach(() => {
installTimerService(env.win);
toggleExperiment(env.win, 'amp-bind', true);

// Stub validator methods to return true for ease of testing.
Expand Down Expand Up @@ -144,6 +146,68 @@ describes.realWin('amp-bind', {
});
});

describe('under dynamic tags', () => {
it('should dynamically detect new bindings', () => {
const doc = env.win.document;
const template = doc.createElement('template');
doc.getElementById('parent').appendChild(template);
return onBindReady().then(() => {
expect(bind.boundElements_.length).to.equal(0);
createElementWithBinding('[onePlusOne]="1+1"');
return bind.waitForAllMutationsForTesting();
}).then(() => {
expect(bind.boundElements_.length).to.equal(1);
});
});

//TODO(kmh287): Move to integration test
it('should NOT bind blacklisted attributes', () => {
// Restore real implementations of canBind and isResultValid
sandbox.restore();
const doc = env.win.document;
const template = doc.createElement('template');
let textElement;
doc.getElementById('parent').appendChild(template);
return onBindReady().then(() => {
expect(bind.boundElements_.length).to.equal(0);
const binding = '[onclick]="\'alert(document.cookie)\'" ' +
'[onmouseover]="\'alert()\'" ' +
'[style]="\'background=color:black\'"';
textElement = createElementWithBinding(binding);
return bind.waitForAllMutationsForTesting();
}).then(() => {
expect(bind.boundElements_.length).to.equal(0);
expect(textElement.getAttribute('onclick')).to.be.null;
expect(textElement.getAttribute('onmouseover')).to.be.null;
expect(textElement.getAttribute('style')).to.be.null;
});
});

//TODO(kmh287): Move to integration test
it('should NOT allow unsecure attribute values', () => {
// Restore real implementations of canBind and isResultValid
sandbox.restore();
const doc = env.win.document;
const template = doc.createElement('template');
let aElement;
doc.getElementById('parent').appendChild(template);
return onBindReady().then(() => {
expect(bind.boundElements_.length).to.equal(0);
const binding = '[href]="javascript:alert(1)"';
const div = env.win.document.createElement('div');
div.innerHTML = '<a ' + binding + '></a>';
aElement = div.firstElementChild;
// Templated HTML is added as a sibling to the template,
// not as a child
doc.getElementById('parent').appendChild(aElement);
return bind.waitForAllMutationsForTesting();
}).then(() => {
expect(aElement.getAttribute('href')).to.be.null;
});
});
});


it('should NOT apply expressions on first load', () => {
const element = createElementWithBinding('[onePlusOne]="1+1"');
expect(element.getAttribute('onePlusOne')).to.equal(null);
Expand Down
40 changes: 40 additions & 0 deletions extensions/amp-mustache/0.1/test/test-amp-mustache.js
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,47 @@ describe('amp-mustache template', () => {
expect(result./*OK*/innerHTML).to.equal('value = <a target="_top">abc</a>');
});

it('should sanitize templated tag names', () => {
const templateElement = document.createElement('div');
templateElement./*OK*/innerHTML =
'value = <{{value}} href="javascript:alert(0)">abc</{{value}}>';
const template = new AmpMustache(templateElement);
template.compileCallback();
const result = template.render({
value: 'a',
});
expect(result./*OK*/innerHTML).to.not
.equal('<a href="javascript:alert(0)">abc</a>');
expect(result.firstElementChild).to.be.null;
});

describe('Sanitizing data- attributes', () => {

it('should sanitize templated attribute names', () => {
const templateElement = document.createElement('div');
templateElement./*OK*/innerHTML =
'value = <a {{value}}="javascript:alert(0)">abc</a>';
let template = new AmpMustache(templateElement);
template.compileCallback();
let result = template.render({
value: 'href',
});
expect(result).to.not.equal('<a href="javascript:alert(0)">abc</a>');
expect(result.firstElementChild.getAttribute('href')).to.be.null;

templateElement./*OK*/innerHTML =
'value = <p [{{value}}]="javascript:alert()">ALERT</p>';
template = new AmpMustache(templateElement);
template.compileCallback();
result = template.render({
value: 'onclick',
});
expect(result).to.not
.equal('<p [onclick]="javascript:alert()">ALERT</p>');
expect(result.firstElementChild.getAttribute('[onclick]')).to.be.null;
expect(result.firstElementChild.getAttribute('onclick')).to.be.null;
});

it('should parse data-&style=value output correctly', () => {
const templateElement = document.createElement('div');
templateElement./*OK*/innerHTML = 'value = <a href="{{value}}"' +
Expand Down
2 changes: 2 additions & 0 deletions extensions/amp-mustache/0.1/test/validator-amp-mustache.html
Original file line number Diff line number Diff line change
Expand Up @@ -33,8 +33,10 @@
<p title="{{allowed}}">{{allowed}}</p>
<p {{notallowed}}></p>
<p {{notallowed}}=0></p>
<p [{{notallowed}}]=0></p>
<p data-{notallowed}=0></p>
<p data-{{notallowed}}=0></p>
<p data-[{{notallowed}}]=0></p>
<p data-{{{notallowed}}}=0></p>
<p data-{{&notallowed}}=0></p>
<p data-{{#notallowed}}=0></p>
Expand Down
Loading