Skip to content

Commit

Permalink
Bind service and state component (#6449)
Browse files Browse the repository at this point in the history
* initial commit for bind component/service

* make default value error more descriptive, only fire in dev mode

* rename some methods/vars

* don't strict equality check for default verification

* hack support for on='tap:setState'

* remove null result check for expr eval

* use vsync for digest mutation, add class and attr toggle

* first pass on amp-bind-state element

* add class change example

* add amp-img ex, fix dumb sanitize attr bug

* move state to separate element per design doc

* document methods in bind-impl.js

* properly handle boolean attributes

* add framework for reacting to attr changes, support size change, fix comments

* propagate attrs in amp-img

* move bind service to /extensions, skip digest for amp-bind-state

* clean up action-impl

* fix lint errors

* handle amp-bind not installed

* fix closure errors in compiled bind expr

* fix type annotations

* add amp-video support

* add amp-bind validator proto

* PR comments, more types, TODOs

* fix long line

* remove local assets in bind example

* fix typedef

* more type fixes

* fix lint error

* revert amp-video changes

* rename bind-state to state

* PR comments

* add unit tests

* check shallow equality during mutate, more unit tests

* PR comments

* nit fix

* call attr changed callback for bool attrs

* more PR comments

* lint and style fixes

* PR comments, class-ify bind-expr

* move expr eval to separate file

* fix lint and type errors

* PR comments
  • Loading branch information
William Chou committed Dec 14, 2016
1 parent 40c9a1a commit d25c72e
Show file tree
Hide file tree
Showing 19 changed files with 1,408 additions and 303 deletions.
3 changes: 2 additions & 1 deletion build-system/tasks/compile-bind-expr.js
Expand Up @@ -28,8 +28,9 @@ gulp.task('compile-bind-expr', function() {

var license = fs.readFileSync(
'build-system/tasks/js-license.txt', 'utf8');
var suppressCheckTypes = '/** @fileoverview @suppress {checkTypes, suspiciousCode, uselessCode} */';
var jsExports = 'exports.parser = parser;';

var out = license + '\n\n' + jsModule + '\n\n' + jsExports + '\n';
var out = [license, suppressCheckTypes, jsModule, jsExports].join('\n\n') + '\n';
fs.writeFileSync(path + 'bind-expr-impl.js', out);
});
20 changes: 18 additions & 2 deletions builtins/amp-img.js
Expand Up @@ -20,6 +20,12 @@ import {registerElement} from '../src/custom-element';
import {srcsetFromElement} from '../src/srcset';
import {user} from '../src/log';

/**
* Attributes to propagate to internal image when changed externally.
* @type {!Array<string>}
*/
const ATTRIBUTES_TO_PROPAGATE = ['alt', 'referrerpolicy', 'aria-label',
'aria-describedby', 'aria-labelledby'];

export class AmpImg extends BaseElement {

Expand All @@ -40,6 +46,17 @@ export class AmpImg extends BaseElement {
this.srcset_ = null;
}

/** @override */
attributeChangedCallback(name, unusedOldValue, unusedNewValue) {
if (name === 'src') {
this.srcset_ = srcsetFromElement(this.element);
this.updateImageSrc_();
} else if (this.img_ && ATTRIBUTES_TO_PROPAGATE.indexOf(name) >= 0) {
this.propagateAttributes(name, this.img_,
/* opt_removeMissingAttrs */ true);
}
}

/** @override */
buildCallback() {
this.isPrerenderAllowed_ = !this.element.hasAttribute('noprerender');
Expand Down Expand Up @@ -81,8 +98,7 @@ export class AmpImg extends BaseElement {
'be correctly propagated for the underlying <img> element.');
}

this.propagateAttributes(['alt', 'referrerpolicy', 'aria-label',
'aria-describedby', 'aria-labelledby'], this.img_);
this.propagateAttributes(ATTRIBUTES_TO_PROPAGATE, this.img_);
this.applyFillContent(this.img_, true);

this.element.appendChild(this.img_);
Expand Down
44 changes: 44 additions & 0 deletions examples/bind.amp.html
@@ -0,0 +1,44 @@
<!doctype html>
<html >
<head>
<meta charset="utf-8">
<title>amp-bind</title>
<link rel="canonical" href="amps.html">
<meta name="viewport" content="width=device-width,minimum-scale=1,initial-scale=1">
<link href="https://fonts.googleapis.com/css?family=Questrial" rel="stylesheet" type="text/css">
<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-bind" src="https://cdn.ampproject.org/v0/amp-bind-0.1.js"></script>

<style amp-custom>
.redBackground {
background-color: red;
}
</style>
</head>

<body>
<amp-state id="myState">
<script type="application/json">
{
"myStateKey1": "myStateValue1"
}
</script>
</amp-state>

<button onclick="AMP.toggleExperiment('amp-bind');window.location.href=window.location.href;">Toggle Experiment</button>

<p [text]="foo">After clicking the button below, this will read 'foo'<p>
<p id="foo" [text]="foo + 'bar'">And this will read 'foobar'<p>
<p [text]="myState.myStateKey1">This will read 'myStateValue1'<p>
<button [disabled]="isButtonDisabled">This button will be disabled</button>
<p [class]="textClass">This text will have have a red background color</p>
<amp-img src="https://ampbyexample.com/img/Border_Collie.jpg" [src]="imgSrc" width=100 [width]="imgSize" height=100 [height]="imgSize" alt="asdf" [alt]="imgAlt"></amp-img>
<p>The image above will increase in size and change its src</p>
<!--
<amp-video src="https://ampbyexample.com/video/tokyo.mp4" [src]="videoSrc" width=480 height=270 controls></amp-video>
-->
<hr>
<button on="tap:AMP.setState(foo='foo', isButtonDisabled=true, textClass='redBackground', imgSrc='https://ampbyexample.com/img/Shetland_Sheepdog.jpg', imgSize=200, imgAlt='Sheepdog', videoSrc='https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/ForBiggerJoyrides.mp4')">Click me</button>
</body>
</html>
Expand Up @@ -14,13 +14,8 @@
* limitations under the License.
*/

import {parser} from './bind-expr-impl';
import {AmpState} from './amp-state';
import {Bind} from './bind-impl';

export function evaluateBindExpr(expr, data) {
try {
parser.yy = data;
return parser.parse(expr);
} finally {
parser.yy = null;
}
}
AMP.registerServiceForDoc('bind', Bind);
AMP.registerElement('amp-state', AmpState);
91 changes: 91 additions & 0 deletions extensions/amp-bind/0.1/amp-state.js
@@ -0,0 +1,91 @@
/**
* Copyright 2016 The AMP HTML Authors. All Rights Reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS-IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

import {bindForDoc} from '../../../src/bind';
import {isJsonScriptTag} from '../../../src/dom';
import {toggle} from '../../../src/style';
import {tryParseJson} from '../../../src/json';
import {user} from '../../../src/log';

export class AmpState extends AMP.BaseElement {
/** @override */
getPriority() {
// Loads after other content.
return 1;
}

/** @override */
isAlwaysFixed() {
return true;
}

/** @override */
isLayoutSupported(unusedLayout) {
return true;
}

/** @override */
buildCallback() {
const TAG = this.getName_();

toggle(this.element, false);
this.element.setAttribute('aria-hidden', 'true');

const id = user().assert(this.element.id,
'%s element must have an id.', TAG);

let json;
const children = this.element.children;
if (children.length == 1) {
const child = children[0];
if (isJsonScriptTag(child)) {
json = tryParseJson(children[0].textContent, e => {
user().error(TAG, 'Failed to parse state. Is it valid JSON?', e);
});
} else {
user().error(TAG,
'State should be in a <script> tag with type="application/json"');
}
} else if (children.length > 1) {
user().error(TAG, 'Should contain only one <script> child.');
}

if (id && json) {
const state = Object.create(null);
state[id] = json;

bindForDoc(this.getAmpDoc()).then(bind => {
bind.setState(state, true);
});
}
}

/** @override */
renderOutsideViewport() {
// We want the state data to be available wherever it is in the document.
return true;
}

/**
* @return {string} Returns a string to identify this tag. May not be unique
* if the element id is not unique.
* @private
*/
getName_() {
return '<amp-state> ' +
(this.element.getAttribute('id') || '<unknown id>');
}
}
53 changes: 53 additions & 0 deletions extensions/amp-bind/0.1/bind-evaluator.js
@@ -0,0 +1,53 @@
/**
* Copyright 2016 The AMP HTML Authors. All Rights Reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS-IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

import {BindExpression} from './bind-expression';

/**
* Asynchronously evaluates a set of Bind expressions.
*/
export class BindEvaluator {
/**
* @param {!Array<string>} expressionStrings
*/
constructor(expressionStrings) {
/** @const {!Array<!BindExpression>} */
this.expressions_ = [];
for (let i = 0; i < expressionStrings.length; i++) {
this.expressions_[i] = new BindExpression(expressionStrings[i]);
}
}

/**
* Evaluates all expressions with the given `scope` data and resolves
* the returned Promise with the results.
* @param {!Object} scope
* @return {!Promise<!Object<string,*>>} Maps expression strings to results.
*/
evaluate(scope) {
return new Promise(resolve => {
/** @type {!Object<string,*>} */
const cache = {};
this.expressions_.forEach(expression => {
const string = expression.expressionString;
if (cache[string] === undefined) {
cache[string] = expression.evaluate(scope);
}
});
resolve(cache);
});
}
}
2 changes: 1 addition & 1 deletion extensions/amp-bind/0.1/bind-expr-impl.jison
Expand Up @@ -46,7 +46,7 @@ const functionWhitelist = (() => {
return out;
})();
/** @return {bool} Returns false if args contains an invalid type. */
/** @return {boolean} Returns false if args contains an invalid type. */
function typeCheckArgs(args) {
for (let i = 0; i < args.length; i++) {
if (toString.call(args[i]) === '[object Object]') {
Expand Down
8 changes: 5 additions & 3 deletions extensions/amp-bind/0.1/bind-expr-impl.js
Expand Up @@ -15,6 +15,8 @@
*/


/** @fileoverview @suppress {checkTypes, suspiciousCode, uselessCode} */

/* parser generated by jison 0.4.15 */
/*
Returns a Parser object of the following structure:
Expand Down Expand Up @@ -183,7 +185,7 @@ case 26:
}

throw new Error($$[$0-1] + '() is not a supported function.');

break;
case 27: case 40:
this.$ = [];
Expand All @@ -201,7 +203,7 @@ case 29:
if (isCorrectType && hasOwnProperty.call($$[$0-1], $$[$0])) {
this.$ = $$[$0-1][$$[$0]];
}

break;
case 32:
this.$ = hasOwnProperty.call(yy, $$[$0]) ? yy[$$[$0]] : null;
Expand Down Expand Up @@ -433,7 +435,7 @@ const functionWhitelist = (() => {
return out;
})();

/** @return {bool} Returns false if args contains an invalid type. */
/** @return {boolean} Returns false if args contains an invalid type. */
function typeCheckArgs(args) {
for (let i = 0; i < args.length; i++) {
if (toString.call(args[i]) === '[object Object]') {
Expand Down
46 changes: 46 additions & 0 deletions extensions/amp-bind/0.1/bind-expression.js
@@ -0,0 +1,46 @@
/**
* Copyright 2016 The AMP HTML Authors. All Rights Reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS-IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

import {parser} from './bind-expr-impl';

/**
* A single Bind expression.
*/
export class BindExpression {
/**
* @param {string} expressionString
*/
constructor(expressionString) {
/** @const {string} */
this.expressionString = expressionString;
}

/**
* Evaluates the expression given a scope.
* @param {!Object} scope
* @return {*}
*/
evaluate(scope) {
// TODO(choumx): Improve performance by extracting AST construction
// and generating evaluator with Function constructor.
try {
parser.yy = scope;
return parser.parse(this.expressionString);
} finally {
parser.yy = null;
}
}
}

0 comments on commit d25c72e

Please sign in to comment.