Skip to content

Commit

Permalink
Friendly frames embeds (#4916)
Browse files Browse the repository at this point in the history
* Friendly iframe embed system

* getFrameElement refactored to include parent tests
  • Loading branch information
dvoytenko authored Sep 16, 2016
1 parent 08c3f49 commit d50eeae
Show file tree
Hide file tree
Showing 20 changed files with 1,038 additions and 26 deletions.
2 changes: 2 additions & 0 deletions build-system/tasks/presubmit-checks.js
Original file line number Diff line number Diff line change
Expand Up @@ -517,6 +517,7 @@ var forbiddenTermsSrcInclusive = {
message: bannedTermsHelpString,
whitelist: [
'src/element-stub.js',
'src/friendly-iframe-embed.js',
'src/runtime.js',
'src/service/extensions-impl.js',
'src/service/lightbox-manager-discovery.js',
Expand Down Expand Up @@ -546,6 +547,7 @@ var forbiddenTermsSrcInclusive = {
whitelist: [
'src/base-element.js',
'src/event-helper.js',
'src/friendly-iframe-embed.js',
'src/service/performance-impl.js',
'src/service/url-replacements-impl.js',
'extensions/amp-ad/0.1/amp-ad-api-handler.js',
Expand Down
31 changes: 27 additions & 4 deletions src/custom-element.js
Original file line number Diff line number Diff line change
Expand Up @@ -145,10 +145,7 @@ export function stubElements(win) {
// If amp-ad and amp-embed haven't been registered, manually register them
// with ElementStub, in case the script to the element is not included.
if (!knownElements['amp-ad'] && !knownElements['amp-embed']) {
win.ampExtendedElements['amp-ad'] = true;
registerElement(win, 'amp-ad', ElementStub);
win.ampExtendedElements['amp-embed'] = true;
registerElement(win, 'amp-embed', ElementStub);
stubLegacyElements(win);
}
}
const list = win.document.querySelectorAll('[custom-element]');
Expand All @@ -166,6 +163,16 @@ export function stubElements(win) {
}
}

/**
* @param {!Window} win
*/
function stubLegacyElements(win) {
win.ampExtendedElements['amp-ad'] = true;
registerElement(win, 'amp-ad', ElementStub);
win.ampExtendedElements['amp-embed'] = true;
registerElement(win, 'amp-embed', ElementStub);
}

/**
* Stub element if not yet known.
* @param {!Window} win
Expand All @@ -182,6 +189,22 @@ export function stubElementIfNotKnown(win, name) {
registerElement(win, name, ElementStub);
}

/**
* Copies the specified element to child window (friendly iframe). This way
* all implementations of the AMP elements are shared between all friendly
* frames.
* @param {!Window} childWin
* @param {string} name
*/
export function copyElementToChildWindow(childWin, name) {
if (!childWin.ampExtendedElements) {
childWin.ampExtendedElements = {};
stubLegacyElements(childWin);
}
childWin.ampExtendedElements[name] = true;
registerElement(childWin, name, knownElements[name] || ElementStub);
}


/**
* Applies layout to the element. Visible for testing only.
Expand Down
31 changes: 31 additions & 0 deletions src/dom.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,16 @@ import {dev} from './log';
import {cssEscape} from '../third_party/css-escape/css-escape';
import {toArray} from './types';

const HTML_ESCAPE_CHARS = {
'&': '&',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#x27;',
'`': '&#x60;',
};
const HTML_ESCAPE_REGEX = /(&|<|>|"|'|`)/g;


/**
* Waits until the child element is constructed. Once the child is found, the
Expand Down Expand Up @@ -544,3 +554,24 @@ export function escapeCssSelectorIdent(win, ident) {
// Polyfill.
return cssEscape(ident);
}


/**
* Escapes `<`, `>` and other HTML charcaters with their escaped forms.
* @param {string} text
* @return {string}
*/
export function escapeHtml(text) {
if (!text) {
return text;
}
return text.replace(HTML_ESCAPE_REGEX, escapeHtmlChar);
}

/**
* @param {string} c
* @return string
*/
function escapeHtmlChar(c) {
return HTML_ESCAPE_CHARS[c];
}
223 changes: 223 additions & 0 deletions src/friendly-iframe-embed.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,223 @@
/**
* 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 {escapeHtml} from './dom';
import {extensionsFor} from './extensions';
import {getTopWindow} from './service';
import {loadPromise} from './event-helper';
import {resourcesForDoc} from './resources';


/**
* Parameters used to create the new "friendly iframe" embed.
* - html: The complete content of an AMP embed, which is itself an AMP
* document. Can include whatever is normally allowed in an AMP document,
* except for AMP `<script>` declarations. Those should be passed as an
* array of `extensionIds`.
* - extensionsIds: An optional array of AMP extension IDs used in this embed.
* - fonts: An optional array of fonts used in this embed.
*
* @typedef {{
* url: string,
* html: string,
* extensionIds: (?Array<string>|undefined),
* fonts: (?Array<string>|undefined),
* }}
*/
export let FriendlyIframeSpec;


/**
* @type {boolean|undefined}
* @visiblefortesting
*/
let srcdocSupported;

/**
* @param {boolean|undefined} val
* @visiblefortesting
*/
export function setSrcdocSupportedForTesting(val) {
srcdocSupported = val;
}

/**
* Returns `true` if the Friendly Iframes are supported.
* @return {boolean}
*/
function isSrcdocSupported() {
if (srcdocSupported === undefined) {
srcdocSupported = 'srcdoc' in HTMLIFrameElement.prototype;
}
return srcdocSupported;
}


/**
* Creates the requested "friendly iframe" embed. Returns the promise that
* will be resolved as soon as the embed is available. The actual
* initialization of the embed will start as soon as the `iframe` is added
* to the DOM.
* @param {!HTMLIFrameElement} iframe
* @param {!Element} container
* @param {!FriendlyIframeSpec} spec
* @return {!Promise<FriendlyIframeEmbed>}
*/
export function installFriendlyIframeEmbed(iframe, container, spec) {
const win = getTopWindow(iframe.ownerDocument.defaultView);
const extensions = extensionsFor(win);

iframe.style.visibility = 'hidden';
iframe.setAttribute('referrerpolicy', 'unsafe-url');

// Pre-load extensions.
if (spec.extensionIds) {
spec.extensionIds.forEach(
extensionId => extensions.loadExtension(extensionId));
}

const html = mergeHtml(spec);

// Receive the signal when iframe is ready: it's document is formed.
iframe.onload = () => {
// Chrome does not reflect the iframe readystate.
iframe.readyState = 'complete';
};
let readyPromise;
if (isSrcdocSupported()) {
iframe.srcdoc = html;
// TODO(dvoytenko): Look for a way to get a faster call from here.
// Experiments show that the iframe's "load" event is consistently 50-100ms
// later than the contentWindow actually available.
readyPromise = loadPromise(iframe);
container.appendChild(iframe);
} else {
iframe.src = 'about:blank';
container.appendChild(iframe);
const childDoc = iframe.contentWindow.document;
childDoc.open();
childDoc.write(html);
childDoc.close();
// Window is created synchornously in this case.
readyPromise = Promise.resolve();
}
return readyPromise.then(() => {
// Add extensions.
extensions.installExtensionsInChildWindow(
iframe.contentWindow, spec.extensionIds || []);
// Ready to be shown.
iframe.style.visibility = '';
return new FriendlyIframeEmbed(iframe, spec);
});
}


/**
* Merges base and fonts into html document.
* @param {!FriendlyIframeSpec} spec
*/
function mergeHtml(spec) {
const originalHtml = spec.html;
const originalHtmlUp = originalHtml.toUpperCase();

// Find the insertion point.
let ip = originalHtmlUp.indexOf('<HEAD');
if (ip != -1) {
ip = originalHtmlUp.indexOf('>', ip + 1) + 1;
}
if (ip == -1) {
ip = originalHtmlUp.indexOf('<BODY');
}
if (ip == -1) {
ip = originalHtmlUp.indexOf('<HTML');
if (ip != -1) {
ip = originalHtmlUp.indexOf('>', ip + 1) + 1;
}
}

const result = [];

// Preambule.
if (ip > 0) {
result.push(originalHtml.substring(0, ip));
}

// Add <BASE> tag.
result.push(`<base href="${escapeHtml(spec.url)}">`);

// Load fonts.
if (spec.fonts) {
spec.fonts.forEach(font => {
result.push(
`<link href="${escapeHtml(font)}" rel="stylesheet" type="text/css">`);
});
}

// Postambule.
if (ip > 0) {
result.push(originalHtml.substring(ip));
} else {
result.push(originalHtml);
}

return result.join('');
}


/**
* Exposes `mergeHtml` for testing purposes.
* @param {!FriendlyIframeSpec} spec
* @visibleForTesting
*/
export function mergeHtmlForTesting(spec) {
return mergeHtml(spec);
}


/**
* A "friendly iframe" embed. This is the iframe that's fully accessible to
* the AMP runtime. It's similar to Shadow DOM in many respects, but it also
* provides iframe/viewport measurements and enables the use of `vh`, `vw` and
* `@media` CSS.
*
* The friendly iframe is managed by the top-level AMP Runtime. When it's
* destroyed, the `destroy` method must be called to free up the shared
* resources.
*/
export class FriendlyIframeEmbed {

/**
* @param {!HTMLIFrameElement} iframe
* @param {!FriendlyIframeSpec} spec
*/
constructor(iframe, spec) {
/** @const {!HTMLIFrameElement} */
this.iframe = iframe;

/** @const {!Window} */
this.win = iframe.contentWindow;

/** @const {!FriendlyIframeSpec} */
this.spec = spec;
}

/**
* Ensures that all resources from this iframe have been released.
*/
destroy() {
resourcesForDoc(this.iframe).removeForChildWindow(this.win);
}
}
12 changes: 4 additions & 8 deletions src/runtime.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import {
addDocFactoryToExtension,
addElementToExtension,
addShadowRootFactoryToExtension,
installBuiltinElements,
installExtensionsInShadowDoc,
installExtensionsService,
registerExtension,
Expand All @@ -39,8 +40,6 @@ import {installActionServiceForDoc} from './service/action-impl';
import {installGlobalSubmitListener} from './document-submit';
import {extensionsFor} from './extensions';
import {installHistoryService} from './service/history-impl';
import {installImg} from '../builtins/amp-img';
import {installPixel} from '../builtins/amp-pixel';
import {installPlatformService} from './service/platform-impl';
import {installResourcesServiceForDoc} from './service/resources-impl';
import {
Expand All @@ -54,7 +53,6 @@ import {installStyles} from './style-installer';
import {installTimerService} from './service/timer-impl';
import {installTemplatesService} from './service/template-impl';
import {installUrlReplacementsService} from './service/url-replacements-impl';
import {installVideo} from '../builtins/amp-video';
import {installVideoManagerForDoc} from './service/video-manager-impl';
import {installViewerService} from './service/viewer-impl';
import {installViewportService} from './service/viewport-impl';
Expand Down Expand Up @@ -120,9 +118,7 @@ export function installAmpdocServices(ampdoc) {
* @param {!Window} global Global scope to adopt.
*/
export function installBuiltins(global) {
installImg(global);
installPixel(global);
installVideo(global);
installBuiltinElements(global);
}


Expand Down Expand Up @@ -338,7 +334,7 @@ export function adoptShadowMode(global) {
*/
function prepareAndRegisterElement(global, extensions,
name, implementationClass, opt_css) {
addElementToExtension(extensions, name, implementationClass);
addElementToExtension(extensions, name, implementationClass, opt_css);
if (opt_css) {
installStyles(global.document, opt_css, () => {
registerElementClass(global, name, implementationClass, opt_css);
Expand All @@ -359,7 +355,7 @@ function prepareAndRegisterElement(global, extensions,
*/
function prepareAndRegisterElementShadowMode(global, extensions,
name, implementationClass, opt_css) {
addElementToExtension(extensions, name, implementationClass);
addElementToExtension(extensions, name, implementationClass, opt_css);
registerElementClass(global, name, implementationClass, opt_css);
if (opt_css) {
addShadowRootFactoryToExtension(extensions, shadowRoot => {
Expand Down
Loading

0 comments on commit d50eeae

Please sign in to comment.