The requirement seemed simple enough: We had this cool search page on our website (the "widget"), and we wanted to provide other webmasters with a way to embed this search onto their own site. They would simply insert a small block of JavaScript which in turn populates some div with our content, just like Google Maps.
What promised to be a very straightforward feature turned out to become a pretty Nolan-esque experience, where different layers of JavaScript had to coerce other layers of JavaScript into…
Okay, it's actually not quite as bad.
It was pretty clear we needed to use an iframe for this; other webmasters should not have to bother with our stylesheets and JavaScript libraries. However, using a conventional iframe does not allow the external site and our iframe – living on two different domains – to communicate at all. Not even to resize the iframe to fit its contents. While there are some solutions to the resizing problem, we simply did not want to give up JavaScript communication in general. The conclusion: the iframe may not actually point to our domain.
Browser security disallows absolutely all JavaScript communication between domains. What is allowed however, is to embed a <script> tag that fetches its code from another domain. For us, it look something like:
<div id="widget_canvas"></div>
<-- better move the following to the bottom of the page -->
<script type="text/javascript">
document.write(unescape('%3Cscript src="' +
(("https:" == document.location.protocol) ? "https://our-domain.com" : "http://our-domain.com") +
'/widget?embed=true" type="text/javascript"%3E%3C/script%3E'
));
</script>
This might be familiar, Google Analytics does exactly the same. We simply write a <script> tag to the document which is instantly executed and fetches our actual widget code.
This code will then in turn
- contain our widget's HTML embedded in a JavaScript string
- return some javascript code that will create an iframe (with a blank src attribute) and
- populate the iframe with the widget's HTML
Et voilà , no domain boundaries any more, the iframe is considered to be in the external site's domain. But while the idea is straightforward, the details are tricky.
The bulk of the JavaScript lives in a Rails layout, the regular views look like always. We confine ourselves to vanilla JavaScript in the external page, but expect jQuery within the iframe.
The following is a small excerpt of the final code, you can find a (slightly adapted)
Make sure to look there, even if you plan to just copy small parts.
# app/layouts/widget.js.erb:
<% if params[:embed] %>
Widget = (function() {
var iFrame; var initialContent; var iFrameDoc;
function htmlLayout() {
return '<%= escape_javascript(<<EOHTML)
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN"
"http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="#{I18n.locale}" lang="#{I18n.locale}">
<head>
<meta http-equiv="Content-type" content="text/html; charset=utf-8" />
#{stylesheet_link_tag 'YOUR STYLESHEETS HERE', :cache => 'widget'}
#{javascript_include_tag 'jquery', 'YOUR JAVASCRIPT LIBRARIES HERE', 'widget_support', :cache => 'widget'}
</head>
<body id="top" class="partner_search" data-lang="#{I18n.locale}">
<div id="iframe_content"></div>
</body>
</html>
EOHTML
%>';
}
function createIFrame() {
var canvas = document.getElementById('widget_canvas');
iFrame = document.createElement('iframe');
canvas.appendChild(iFrame);
iFrame.onload = insertInitialContent;
iFrameDoc = iFrame.contentDocument || iFrame.contentWindow.document;
}
function populateIFrame() {
iFrameDoc.write(htmlLayout());
iFrameDoc.close();
initialContent = '<%= escape_javascript(yield) %>';
}
function replaceContent(content) {
iFrame.contentWindow.replaceIFrameContent(content);
resizeIFrame();
}
function insertInitialContent() {
replaceContent(initialContent);
}
function resizeIFrame() {
iFrame.height = iFrameDoc.body.scrollHeight;
}
function replaceContentWithScript(scriptHref) {
var scriptTag = document.createElement('script');
scriptTag.type = 'text/javascript';
scriptTag.src = scriptHref;
document.body.appendChild(scriptTag);
}
function init() {
createIFrame();
populateIFrame();
}
init();
return { replaceContent: replaceContent, replaceContentWithScript: replaceContentWithScript };
})();
<% else %>
Widget.replaceContent('<%= escape_javascript(yield) %>');
<% end %>
-
# public/javascripts/widget_support.js:
function replaceIFrameContent(content) {
$('#iframe_content').html(content);
}
-
# somewhere in app/controllers/widget_controller:
def show
# ...
render :layout => 'widget.js.erb'
end
Phew, lots of code. I hope it is somewhat self-explanatory, but I'll point out some of the more interesting problems we had to solve.
JavaScript execution order
IE7 is very creative about the order it executes JavaScripts. Inline scripts are executed when encountered, external scripts when they come in. This is fatal for libraries included in the <head>. This is why we fill in the actual content in the iframe's load event, where all libraries should be parsed.
Make sure you have only one JavaScript library you actually include (that is, turn caching on). Moreover, if the JavaScript is already cached, the document-ready event seems to be fired before your JavaScript is even parsed. So all $(function() { ... }) blocks in your code are executed instantly where they are defined.
Inline script tags
When assigning HTML via element.innerHTML = ..., included script tags are not executed. This is why we need the "widget_support.js"; we call the jQuery of the inner frame to actually replace our HTML. JQuery will actually "grep" out the script tags and execute them manually.
Links in the iframe
Left to do is to handle links within the iframe. If they are supposed to bring up new content inside the widget, they obviously have to use the same mechanisms to do so.
Included above is the replaceContentWithScript method which takes a url (that needs to point to another action rendered with the "widget.js.erb" layout), which again fetches JavaScript injecting the new content. So we can wire our links from javascript inside our iframe with:
$('a').click(function(event) {
event.preventDefault();
parent.Widget.replaceContentWithScript($(this).attr('href'));
});
The whole solution seems to work on all major browser starting with IE7. We haven't dared to try IE6. This is also very fresh; we'll let you know if it turns out to be less than stable for production use.