From c6724fd5d634e40474ad9bac641e9c53f3383577 Mon Sep 17 00:00:00 2001 From: Koji Lopez Date: Thu, 7 Feb 2019 12:13:44 -0800 Subject: [PATCH 01/14] Add amp-viewer-assistance extension (#15195). Change-Id: I78dac3ec9f85c3bbd1ceb4ce88a61b3f7d0dc539 --- build-system/tasks/compile.js | 2 + bundles.config.js | 1 + .../0.1/amp-viewer-assistance.js | 171 ++++++++++++++++++ .../0.1/test/test-amp-viewer-assistance.js | 168 +++++++++++++++++ .../test/validator-amp-viewer-assistance.html | 38 ++++ .../test/validator-amp-viewer-assistance.out | 39 ++++ .../amp-viewer-assistance.md | 20 ++ .../validator-viewer-assistance.protoascii | 55 ++++++ .../0.1/amp-viewer-integration.css | 20 ++ .../0.1/amp-viewer-integration.js | 2 + src/url.js | 2 + test/unit/test-url.js | 6 + tools/experiments/experiments.js | 4 + 13 files changed, 528 insertions(+) create mode 100644 extensions/amp-viewer-assistance/0.1/amp-viewer-assistance.js create mode 100644 extensions/amp-viewer-assistance/0.1/test/test-amp-viewer-assistance.js create mode 100644 extensions/amp-viewer-assistance/0.1/test/validator-amp-viewer-assistance.html create mode 100644 extensions/amp-viewer-assistance/0.1/test/validator-amp-viewer-assistance.out create mode 100644 extensions/amp-viewer-assistance/amp-viewer-assistance.md create mode 100644 extensions/amp-viewer-assistance/validator-viewer-assistance.protoascii create mode 100644 extensions/amp-viewer-integration/0.1/amp-viewer-integration.css diff --git a/build-system/tasks/compile.js b/build-system/tasks/compile.js index 6d255a0af3df..f407bdd4e58e 100644 --- a/build-system/tasks/compile.js +++ b/build-system/tasks/compile.js @@ -239,6 +239,8 @@ function compile(entryModuleFilenames, outputDir, outputFilename, options) { 'extensions/amp-consent/**/*.js', // Needed to access AmpGeo type for service locator 'extensions/amp-geo/**/*.js', + // Needed for AmpViewerAssistanceService + 'extensions/amp-viewer-assistance/**/*.js', // Needed for AmpViewerIntegrationVariableService 'extensions/amp-viewer-integration/**/*.js', 'src/*.js', diff --git a/bundles.config.js b/bundles.config.js index e558f0a499bb..0393853ceafe 100644 --- a/bundles.config.js +++ b/bundles.config.js @@ -394,6 +394,7 @@ exports.extensionBundles = [ ], }, {name: 'amp-google-vrview-image', version: '0.1', type: TYPES.MISC}, + {name: 'amp-viewer-assistance', version: '0.1', type: TYPES.MISC}, { name: 'amp-viewer-integration', version: '0.1', diff --git a/extensions/amp-viewer-assistance/0.1/amp-viewer-assistance.js b/extensions/amp-viewer-assistance/0.1/amp-viewer-assistance.js new file mode 100644 index 000000000000..9a8a037cc20b --- /dev/null +++ b/extensions/amp-viewer-assistance/0.1/amp-viewer-assistance.js @@ -0,0 +1,171 @@ +import {ActionTrust} from '../../../src/action-constants'; +import {Services} from '../../../src/services'; +import {dev, user} from '../../../src/log'; +import {dict} from '../../../src/utils/object'; +import {isExperimentOn} from '../../../src/experiments'; +import {tryParseJson} from '../../../src/json'; + + +/** @const {string} */ +const TAG = 'amp-viewer-assistance'; + +/** @const {string} */ +const GSI_TOKEN_PROVIDER = 'actions-on-google-gsi'; + +export class AmpViewerAssistance { + /** + * @param {!../../../src/service/ampdoc-impl.AmpDoc} ampdoc + */ + constructor(ampdoc) { + const assistanceElement = ampdoc.getElementById('amp-viewer-assistance'); + + /** @const @private {boolean} */ + this.enabled_ = !!assistanceElement; + if (!this.enabled_) { + return; + } + + /** @const @private */ + this.ampdoc_ = ampdoc; + + /** @const @private {!Element} */ + this.assistanceElement_ = dev().assertElement(assistanceElement); + + /** @const @private {!JsonObject} */ + this.configJson_ = tryParseJson(this.assistanceElement_.textContent, e => { + throw user().createError( + 'Failed to parse "amp-viewer-assistance" JSON: ' + e); + }); + + /** @private @const {!../../../src/service/viewer-impl.Viewer} */ + this.viewer_ = Services.viewerForDoc(ampdoc); + + /** @private @const {!../../../src/service/action-impl.ActionService} */ + this.action_ = Services.actionServiceForDoc(assistanceElement); + + /** @private @const {!../../../src/service/vsync-impl.Vsync} */ + this.vsync_ = Services.vsyncFor(ampdoc.win); + } + + /** + * @param {!../../../src/service/action-impl.ActionInvocation} invocation + * @return {?Promise} + * @private + */ + actionHandler_(invocation) { + const {method, args} = invocation; + if (method == 'updateActionState' && !!args) { + this.viewer_.sendMessageAwaitResponse(method, args).catch(error => { + user().error(TAG, error.toString()); + }); + } else if (method == 'signIn') { + this.requestSignIn_(); + } + + return null; + } + + /** + * @private + * @restricted + */ + start_() { + if (!this.enabled_) { + user().info( + TAG, 'Invalid AMP Action - no "id=amp-viewer-assistance" element'); + return this; + } + return this.viewer_.isTrustedViewer().then(isTrustedViewer => { + if (!isTrustedViewer && + !isExperimentOn(this.ampdoc_.win, 'amp-viewer-assistance-untrusted')) { + this.enabled_ = false; + user().info(TAG, 'Disabling AMP Action since viewer is not trusted'); + return this; + } + this.action_.installActionHandler( + this.assistanceElement_, this.actionHandler_.bind(this), + ActionTrust.HIGH); + + this.getIdTokenPromise(); + + this.viewer_.sendMessage('viewerAssistanceConfig',dict({ + 'config': this.configJson_, + })); + return this; + }); + } + + /** + * @return {!Promise} + */ + getIdTokenPromise() { + return this.viewer_.sendMessageAwaitResponse('getAccessTokenPassive', dict({ + // For now there's only 1 provider option, so we just hard code it + 'providers': [GSI_TOKEN_PROVIDER], + })) + .then(token => { + this.setIdTokenStatus_(Boolean(token)); + return token; + }).catch(() => { + this.setIdTokenStatus_(/*available=*/false); + }); + } + + /** + * @private + */ + requestSignIn_() { + this.viewer_.sendMessageAwaitResponse('requestSignIn', dict({ + 'providers': [GSI_TOKEN_PROVIDER], + })).then(token => { + user().info(TAG, 'Token: ' + token); + if (token) { + this.setIdTokenStatus_(/*available=*/true); + this.action_.trigger( + this.assistanceElement_, 'signedIn', null, ActionTrust.HIGH); + } + }); + } + + /** + * Toggles the CSS classes related to the status of the identity token. + * @private + * @param {boolean} available + */ + setIdTokenStatus_(available) { + this.toggleTopClass_( + 'amp-viewer-assistance-identity-available', available); + this.toggleTopClass_( + 'amp-viewer-assistance-identity-unavailable', !available); + } + + /** + * Gets the root element of the AMP doc. + * @return {!Element} + * @private + */ + getRootElement_() { + const root = this.ampdoc_.getRootNode(); + return dev().assertElement(root.documentElement || root.body || root); + } + + /** + * Toggles a class on the root element of the AMP doc. + * @param {string} className + * @param {boolean} on + * @private + */ + toggleTopClass_(className, on) { + this.vsync_.mutate(() => { + this.getRootElement_().classList.toggle(className, on); + }); + + } +} + +// Register the extension services. +AMP.extension(TAG, '0.1', function(AMP) { + AMP.registerServiceForDoc('amp-viewer-assistance', function(ampdoc) { + return new AmpViewerAssistance(ampdoc).start_(); + }); +}); diff --git a/extensions/amp-viewer-assistance/0.1/test/test-amp-viewer-assistance.js b/extensions/amp-viewer-assistance/0.1/test/test-amp-viewer-assistance.js new file mode 100644 index 000000000000..a767f3431402 --- /dev/null +++ b/extensions/amp-viewer-assistance/0.1/test/test-amp-viewer-assistance.js @@ -0,0 +1,168 @@ +import {ActionInvocation} from '../../../../src/service/action-impl'; +import {AmpViewerAssistance} from '../amp-viewer-assistance'; +import {mockServiceForDoc} from '../../../../testing/test-helper'; + +describes.fakeWin('AmpViewerAssistance', { + amp: true, + location: 'https://pub.com/doc1', +}, env => { + let document; + let ampdoc; + let element; + let viewerMock; + + beforeEach(() => { + ampdoc = env.ampdoc; + document = env.win.document; + viewerMock = mockServiceForDoc(env.sandbox, env.ampdoc, 'viewer', [ + 'isTrustedViewer', + 'sendMessage', + 'sendMessageAwaitResponse', + ]); + viewerMock.isTrustedViewer.returns(Promise.resolve(true)); + viewerMock.sendMessageAwaitResponse.returns(Promise.resolve('idToken')); + + element = document.createElement('script'); + element.setAttribute('id', 'amp-viewer-assistance'); + element.setAttribute('type', 'application/json'); + document.body.appendChild(element); + }); + + it('should disable service when no config', () => { + document.body.removeChild(element); + const service = new AmpViewerAssistance(ampdoc); + expect(service.enabled_).to.be.false; + expect(service.assistanceElement_).to.be.undefined; + }); + + it('should disable service when the viewer is not trusted', () => { + viewerMock.isTrustedViewer.returns(Promise.resolve(false)); + const config = { + 'providerId': 'foo-bar', + }; + element.textContent = JSON.stringify(config); + const service = new AmpViewerAssistance(ampdoc); + return service.start_().then(() => { + expect(service.enabled_).to.be.false; + }); + }); + + it('should fail if config is malformed', () => { + expect(() => { + new AmpViewerAssistance(ampdoc); + }).to.throw(Error); + }); + + it('should send the config to the viewer', () => { + const config = { + 'providerId': 'foo-bar', + }; + element.textContent = JSON.stringify(config); + const service = new AmpViewerAssistance(ampdoc); + expect(service.enabled_).to.be.true; + expect(service.assistanceElement_).to.equal(element); + const sendMessageStub = service.viewer_.sendMessage; + return service.start_().then(() => { + expect(sendMessageStub).to.be.calledOnce; + expect(sendMessageStub.firstCall.args[0]).to + .equal('viewerAssistanceConfig'); + expect(sendMessageStub.firstCall.args[1]).to.deep.equal({ + 'config': config, + }); + }); + }); + + it('should send updateActionState to the viewer', () => { + const config = { + 'providerId': 'foo-bar', + }; + element.textContent = JSON.stringify(config); + const service = new AmpViewerAssistance(ampdoc); + const sendMessageStub = service.viewer_.sendMessageAwaitResponse; + const invocationArgs = { + 'foo': 'bar', + }; + return service.start_().then(() => { + sendMessageStub.resetHistory(); + const invocation = new ActionInvocation( + element, 'updateActionState', invocationArgs); + service.actionHandler_(invocation); + expect(sendMessageStub).to.be.calledOnce; + expect(sendMessageStub.firstCall.args[0]).to.equal('updateActionState'); + expect(sendMessageStub.firstCall.args[1]).to.deep.equal(invocationArgs); + }); + }); + + it('should fail to send updateActionState if args are missing', () => { + const config = { + 'providerId': 'foo-bar', + }; + element.textContent = JSON.stringify(config); + const service = new AmpViewerAssistance(ampdoc); + const sendMessageStub = service.viewer_.sendMessage; + return service.start_().then(() => { + sendMessageStub.reset(); + const invocation = new ActionInvocation(element, 'updateActionState'); + service.actionHandler_(invocation); + expect(sendMessageStub).to.not.be.called; + }); + }); + + it('should send handle the signIn action', () => { + const config = { + 'providerId': 'foo-bar', + }; + element.textContent = JSON.stringify(config); + const service = new AmpViewerAssistance(ampdoc); + const sendMessageStub = service.viewer_.sendMessageAwaitResponse; + return service.start_().then(() => { + sendMessageStub.resetHistory(); + sendMessageStub.returns(Promise.reject()); + const invocation = new ActionInvocation(element, 'signIn'); + service.actionHandler_(invocation); + expect(sendMessageStub).to.be.calledOnce; + expect(sendMessageStub.firstCall.args[0]).to.equal('requestSignIn'); + expect(sendMessageStub.firstCall.args[1]).to.deep.equal({ + providers: ['actions-on-google-gsi'], + }); + }); + }); + + it('should make IDENTITY_TOKEN available through a promise', () => { + const config = { + 'providerId': 'foo-bar', + }; + element.textContent = JSON.stringify(config); + const service = new AmpViewerAssistance(ampdoc); + return service.start_() + .then(() => service.getIdTokenPromise()) + .then(token => expect(token).to.equal('idToken')); + }); + + it('should set the css classes if IDENTITY_TOKEN is unavailable', () => { + const config = { + 'providerId': 'foo-bar', + }; + element.textContent = JSON.stringify(config); + const service = new AmpViewerAssistance(ampdoc); + service.vsync_ = { + mutate: callback => { + callback(); + }, + }; + const sendMessageStub = service.viewer_.sendMessageAwaitResponse; + sendMessageStub.returns(Promise.reject()); + return service.getIdTokenPromise().then(() => { + expect(sendMessageStub).to.be.calledOnce; + expect(sendMessageStub.firstCall.args[0]).to.equal( + 'getAccessTokenPassive'); + expect(sendMessageStub.firstCall.args[1]).to.deep.equal({ + providers: ['actions-on-google-gsi'], + }); + expect(document.documentElement).not.to.have.class( + 'amp-viewer-assistance-identity-available'); + expect(document.documentElement).to.have.class( + 'amp-viewer-assistance-identity-unavailable'); + }); + }); +}); diff --git a/extensions/amp-viewer-assistance/0.1/test/validator-amp-viewer-assistance.html b/extensions/amp-viewer-assistance/0.1/test/validator-amp-viewer-assistance.html new file mode 100644 index 000000000000..fad36525b6e5 --- /dev/null +++ b/extensions/amp-viewer-assistance/0.1/test/validator-amp-viewer-assistance.html @@ -0,0 +1,38 @@ + + + + + + + + + + + + + + +Hello, world. + + diff --git a/extensions/amp-viewer-assistance/0.1/test/validator-amp-viewer-assistance.out b/extensions/amp-viewer-assistance/0.1/test/validator-amp-viewer-assistance.out new file mode 100644 index 000000000000..bf574ea4ba73 --- /dev/null +++ b/extensions/amp-viewer-assistance/0.1/test/validator-amp-viewer-assistance.out @@ -0,0 +1,39 @@ +PASS +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| Hello, world. +| +| diff --git a/extensions/amp-viewer-assistance/amp-viewer-assistance.md b/extensions/amp-viewer-assistance/amp-viewer-assistance.md new file mode 100644 index 000000000000..338dd49807a4 --- /dev/null +++ b/extensions/amp-viewer-assistance/amp-viewer-assistance.md @@ -0,0 +1,20 @@ +# amp-viewer-assistance + +The amp-viewer-assistance element provides specification of AMP viewer +configuration information and invocation of AMP viewer assisted behavior. + +## Example + +```html + +``` diff --git a/extensions/amp-viewer-assistance/validator-viewer-assistance.protoascii b/extensions/amp-viewer-assistance/validator-viewer-assistance.protoascii new file mode 100644 index 000000000000..fe2414565c10 --- /dev/null +++ b/extensions/amp-viewer-assistance/validator-viewer-assistance.protoascii @@ -0,0 +1,55 @@ +# +# 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. +# +tags: { # amp-viewer-assistance + html_format: AMP + html_format: EXPERIMENTAL + tag_name: "SCRIPT" + extension_spec: { + name: "amp-viewer-assistance" + version: "0.1" + version: "latest" + requires_usage: GRANDFATHERED + deprecated_allow_duplicates: true + } + attr_lists: "common-extension-attrs" +} +tags: { # amp-viewer-assistance (json) + html_format: AMP + html_format: EXPERIMENTAL + tag_name: "SCRIPT" + spec_name: "amp-viewer-assistance extension .json script" + unique: true + mandatory_parent: "HEAD" + requires_extension: "amp-viewer-assistance" + attrs: { + name: "id" + mandatory: true + value: "amp-viewer-assistance" + dispatch_key: NAME_VALUE_DISPATCH + } + attrs: { name: "nonce" } + attrs: { + name: "type" + mandatory: true + value_casei: "application/json" + } + cdata: { + blacklisted_cdata_regex: { + regex: " + + + + + + + + + + + + + +
DescriptionThe amp-viewer-assistance element provides specification of AMP viewer +configuration information and invocation of AMP viewer assisted behavior.
AvailabilityStable
Required Script +
+ <script async custom-element="amp-viewer-assistance" src="https://cdn.ampproject.org/v0/amp-viewer-assistance-0.1.js"></script> +
+
## Example From 2ce0926addc4cd548cd96c16f8b74d512b2bffb9 Mon Sep 17 00:00:00 2001 From: Koji Lopez Date: Fri, 15 Feb 2019 10:58:36 -0800 Subject: [PATCH 08/14] Update amp-viewer-assistance documentation. Change-Id: I9031cc6930c18de3ca691f41274f868670b72c7a --- .../amp-viewer-assistance.md | 135 +++++++++++++++++- 1 file changed, 128 insertions(+), 7 deletions(-) diff --git a/extensions/amp-viewer-assistance/amp-viewer-assistance.md b/extensions/amp-viewer-assistance/amp-viewer-assistance.md index dd8b9557d66f..b2d836c90c9a 100644 --- a/extensions/amp-viewer-assistance/amp-viewer-assistance.md +++ b/extensions/amp-viewer-assistance/amp-viewer-assistance.md @@ -21,8 +21,7 @@ limitations under the License. - + @@ -35,20 +34,142 @@ configuration information and invocation of AMP viewer assisted behavior. + + + +
DescriptionThe amp-viewer-assistance element provides specification of AMP viewer -configuration information and invocation of AMP viewer assisted behavior.Amp-viewer-assistance provides assistive behaviors on AMP pages facilitated by a viewer. Messages are passed between the amp-viewer-assistance extension and the external viewer as outlined below.
Availability
Required Element + <script id="amp-viewer-assistance" type="application/json"></script> +
+## Supported Functions + +The `amp-viewer-assistance` extension currently has two functions that can be invoked via AMP expressions. For example, you may invoke the signIn function on a button tap: + +```html + +``` + + + + + + + + + + + + + + +
Function NameDescription
signInThe amp-viewer-assistance extension sends a message to the viewer to sign in expecting an identity token back.
updateActionStateA function to send a message to the outer viewer representing a state change. Should contain an argument of the resulting state change.
+ +## Messages Sent + +There are several messages that can be sent from the amp-viewer-assistance extension to the external viewer. + + + + + + + + + + + + + + + + + + + + + + +
Message NameDescription
viewerAssistanceConfigA message containing the initial json config within the amp-viewer-assistance element. This message is sent during the extension's initialization.
requestSignInRequests for a sign in from the viewer expecting a string identity token back from the viewer. Passes a json argument with a property providers containing an array of identity providers. Currently only supports actions-on-google-gsi.
getAccessTokenPassiveSimilar to requestSignIn however, only requests for the identity token instead of a fresh sign in. Passes a json argument with a property providers containing an array of identity providers. Currently only supports actions-on-google-gsi.
updateActionStateA message representing a change in state. Can be sent with an argument payload.
+ +## Events Triggered + +In order to act upon a successful sign in from the viewer assistance, a `signedIn` event is emitted from the `amp-viewer-assistance` script element. On the element, an expression can be attached via the `on` attribute. In this example, we are showing a success message after a user has signed in: + +```html + + +``` + + + + + + + + + + + + +
Event NameEmitting ElementDescription
signedInamp-viewer-assistance An action emitted upon a successful signIn by the external viewer.
+ +## Identity Class + +Upon a successful sign in or identity token retrieval, a `amp-viewer-assistance-identity-available` class will be attached to the root element of the AMP document. This can be used to manipulate elements with compound CSS classes. + +In this example, we have a message telling the user they are signed out. If the identity is available through the extension, the message's `display` attribute will be overwritten to `display:none`. + +```css +.signedOutMessage { + display: block; +} + +.amp-viewer-assistance-identity-available .signedOutMessage { + display: none; +} +``` + ## Example +Wrapping up the above, here is an example implementation of a page utilizing sign in, as well as an `updateActionState` after a form submission. + ```html - + + +
+ Signed Out! +
+ +
+
+ ``` From b537af0e10eb542c5202e4bf1f4f83575e5b3488 Mon Sep 17 00:00:00 2001 From: Koji Lopez Date: Tue, 19 Feb 2019 11:28:33 -0800 Subject: [PATCH 09/14] Remove redundant amp-viewer-assistnace validator files. Change-Id: I5c3194a511b64321500025e59375f618ed4660a4 --- .../test/validator-amp-viewer-assistance.html | 38 ------------- .../test/validator-amp-viewer-assistance.out | 39 -------------- .../validator-viewer-assistance.protoascii | 53 ------------------- 3 files changed, 130 deletions(-) delete mode 100644 extensions/amp-viewer-assistance/0.1/test/validator-amp-viewer-assistance.html delete mode 100644 extensions/amp-viewer-assistance/0.1/test/validator-amp-viewer-assistance.out delete mode 100644 extensions/amp-viewer-assistance/validator-viewer-assistance.protoascii diff --git a/extensions/amp-viewer-assistance/0.1/test/validator-amp-viewer-assistance.html b/extensions/amp-viewer-assistance/0.1/test/validator-amp-viewer-assistance.html deleted file mode 100644 index 35ad796091fe..000000000000 --- a/extensions/amp-viewer-assistance/0.1/test/validator-amp-viewer-assistance.html +++ /dev/null @@ -1,38 +0,0 @@ - - - - - - - - - - - - - - -Hello, world. - - diff --git a/extensions/amp-viewer-assistance/0.1/test/validator-amp-viewer-assistance.out b/extensions/amp-viewer-assistance/0.1/test/validator-amp-viewer-assistance.out deleted file mode 100644 index 13683d57f4f5..000000000000 --- a/extensions/amp-viewer-assistance/0.1/test/validator-amp-viewer-assistance.out +++ /dev/null @@ -1,39 +0,0 @@ -PASS -| -| -| -| -| -| -| -| -| -| -| -| -| -| -| Hello, world. -| -| diff --git a/extensions/amp-viewer-assistance/validator-viewer-assistance.protoascii b/extensions/amp-viewer-assistance/validator-viewer-assistance.protoascii deleted file mode 100644 index fdce7bdbdd6e..000000000000 --- a/extensions/amp-viewer-assistance/validator-viewer-assistance.protoascii +++ /dev/null @@ -1,53 +0,0 @@ -# -# Copyright 2019 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. -# -tags: { # amp-viewer-assistance - html_format: AMP - html_format: ACTIONS - tag_name: "SCRIPT" - extension_spec: { - name: "amp-viewer-assistance" - version: "0.1" - version: "latest" - } - attr_lists: "common-extension-attrs" -} -tags: { # amp-viewer-assistance (json) - html_format: AMP - html_format: ACTIONS - tag_name: "SCRIPT" - spec_name: "amp-viewer-assistance extension .json script" - unique: true - mandatory_parent: "HEAD" - requires_extension: "amp-viewer-assistance" - attrs: { - name: "id" - mandatory: true - value: "amp-viewer-assistance" - dispatch_key: NAME_VALUE_DISPATCH - } - attrs: { name: "nonce" } - attrs: { - name: "type" - mandatory: true - value_casei: "application/json" - } - cdata: { - blacklisted_cdata_regex: { - regex: "