Links when Embedding Notebooks

I am looking for help here, but this may also be a feature request.

Embedding entire notebooks with iFrame (with/without resizing) works beautifully. But some Markdown-heavy notebooks that I am embedding have links within the notebooks themselves in the Markdown form: [linked text](#namedCell). Observable’s iFrame embedding expands all such relative href links and makes them absolute, prefixing https://observablehq.com/@<username>/<notebookname> to #namedCell. Is there some way to retain relative href links? Or could there be (as a feature request)?

The behavior of entire notebook embedding with Runtime for JavaScript is different, I know, but entire notebook IFrame embedding is more elegant and offers a much easier updating workflow.

Hi @shadoof, there’s no easy solution to this right now, but let me offer some background and two workarounds (each with its own caveats). As for the feature request, I’d recommend to file that in Observable’s feedback repository as it might get lost otherwise.

Background

When a notebook gets embedded as an iframe there are three browsing contexts in which users can navigate:

  • _top, the embedding page. Normally this is where any navigation should happen to avoid opening pages within the iframe.
  • _parent: The iframe URL that displays the attribution frame and the inner sandbox iframe. This is where any cell IDs reside, and where in-page navigation for anchors would need to happen.
  • _self: The sandbox itself. Navigating in this context would immediately break the notebook (you may have seen this on occasion when clicking a link in an SVG).

Observable sets the sandbox iframe’s document.baseURI (the <base> element) to open links in the _top context, using the notebook’s non-embed URL. This ensures that any relative links open correctly. This base URI is also the reason that you see a link #namedCell being opened in the top-level browsing context (instead of just scrolling to the offset).

Workaround 1: Scroll in _parent

We can only scroll the parent iframe without breaking the notebook if we precisely match the URL that the iframe was embedded with. However, we cannot simply read the parent iframe’s URL (because we’re in a sandbox!), and Observable strips any cell and cells parameters. We’ll need to pass them in separately in order to reconstruct the parent URL.

Let’s create a helper that allows us to use local links. In order for the helper to work the following conditions need to be met:

  • the iframe URL also contains the query parameter _cell or _cells (after the unaliased parameter), and the aliased parameter matches the original parameter exactly
  • the iframe URL is unpinned (i.e., has no version specifier)

Here’s the helper code:

local = {
  const url = new URL(document.baseURI);
  let target = null;
  let base = url;
  const cell = url.searchParams.get("_cell");
  const cells = url.searchParams.get("_cells");
  if(cell || cells) {
    url.search = new URLSearchParams([
      ...Object.entries({cell, cells}).filter(d => d[1]),
      ...url.searchParams.entries()
    ]);
    url.pathname = url.pathname.replace(/^\/(d\/|)/, "/embed/");
    target = "_parent";
  }
  return (text, href) => htl.html`<a ${{target, href: new URL(href, base)}}>${text}`;
}

It can be used as follows:

For details see ${local("this named cell", "#namedCell")}

This may very well be the most fragile solution, so I’d only recommend it as a last resort.

Workaround 2: Scroll in _self

As mentioned earlier, the IDs for named cells reside in the parent iframe, which means that we can’t access the corresponding elements directly.

We can however insert our own anchors, for example:

<span id=namedCell />Some more text after this invisible target element

We can still not link directly to it, which is where the second part of the solution comes in:

handleLocal = {
  const onClick = e => {
    if(!e.target.matches("a[href^='#']")) return;
    const target = document.querySelector(e.target.getAttribute("href"));
    if(target) e.preventDefault(), target.scrollIntoView();
  };
  document.addEventListener("click", onClick);
  invalidation.then(() => document.removeEventListener("click", onClick));
  // Don't display anything.
  return htl.html`<div>`;
}

This snippet captures any clicks on local links and scrolls the target element into view (if it was found).


We can also combine these solutions to handle IDs both in _self and _parent. Assuming that our notebook already contains the local helper that I shared above, we can modify handleLocal as follows:

handleLocal = {
  const onClick = e => {
    if(!e.target.matches("a[href^='#']")) return;
    e.preventDefault();
    const href = e.target.getAttribute("href");
    const target = document.querySelector(href);
    target ? target.scrollIntoView() : local("", href).click();
  };
  document.addEventListener("click", onClick);
  invalidation.then(() => document.removeEventListener("click", onClick));
  // Don't display anything.
  return htl.html`<div>`;
}

What this does:

  1. check if the clicked link is a local link
  2. if the link target exists in the document, scroll it into view
  3. otherwise attempt to navigate to it in the _parent context
1 Like

Many thanks @mootari . This was very helpful … although I am not sure how to proceed. Rather than attempt a workaround, I have experimented with coding anchors into my Markdown, within cells – <a name="aname"> - but this, as I guess you will anticipate, does not work within Observable, although it does work for downloaded notebook code and for Runtime with JavaScript embedding. Hmmm. Seems to me that this generates behaviors that are undesirable because they are inconsistent across contexts and the non-working of document-internal anchoring conventions is also an issue in itself (although the facility to be able to code [linkedText](#nameCell) with the notebooks on Observable is sweet.

I’ll add something to the feedback repository and will also experiment with coding both types of internal anchors in my Markdown-heavy notebooks for now.

Be aware that there are some important differences between iframe embeds and JS embeds:

  • With iframe embeds the code runs in a sandboxed iframe, using a simplified version of Observable’s UI. It has no access to parent contexts.
  • In JS embeds you run your notebook directly in the “embedding” page, which lends greater control but also greater risk since the notebook code an modify any aspect of the page.

You can remove the document’s <base> element, but this means that any links that contain more than just a fragment identifier will break the notebook when clicked.

The name attribute is reserved for forms, and its use as anchor target should be considered obsolete. I suggest to use id instead, like I showed in my example above.

1 Like

Thanks again @mootari , and sorry about using name rather than id in the suggested anchor.

For my own workaround purposes, and perhaps those of others, I have found that coding <a id="sameNameAsCell"></a> at the beginning of a Markdown cell that you wish to be internally linked as above, will produce the anticipated behavior when the entire notebook is exported or embedded using Runtime with Java.

1 Like

If you only need to solve this problem for JS embeds, then I’d suggest a different approach. Modify the import script as follows:

import {Runtime, Inspector} from "https://cdn.jsdelivr.net/npm/@observablehq/runtime@5/dist/runtime.js";
import define from "https://api.observablehq.com/YOUR-NOTEBOOK.js?v=3";

const parent = document.getElementById("YOUR-PARENT-ID");
new Runtime().module(define, name => {
  const container = document.createElement("div");
  if(name) container.id = name;
  parent.append(container);
  return new Inspector(container);
});

This will automatically add IDs based on the cell names to each cell’s container.

1 Like

Thanks @mootari. This works perfectly and I have preferred it as a solution. I hope, nonetheless, that similar options are made available for iframe embedding.