Bookmarklets

To save, drag the bookmarklets below to your Favourites Bar.

This is functional code: click to see what they do and "view source" if you're intrigued by how they work.

Toggle FT

Handy for colleagues: switch between local and production versions of FT pages

If nothing happens check the console!

View source
export function toggleFT({ location }: Window): void {
  const { hostname, pathname, search, hash } = location;
  if (hostname.endsWith("ft.com")) {
    const origin =
      hostname === "local.ft.com"
        ? "https://www.ft.com"
        : "https://local.ft.com:5050";
    const newUrl = new URL(pathname + search + hash, origin);
    return location.replace(newUrl.href);
  }

  console.warn(`Can only toggle URLs on ft.com pages: this is ${hostname}`);
}
CSS Debug

A lightweight way to visually debug layout and structure: the darker the red the deeper the element is nested.

Effective up to 16 levels of nesting: if your elements are solid red try to flatten your DOM a bit!

View source
export function cssDebug({ document }: Window): void {
  const id = "css-debug";

  // Remove existing debug stylesheet if present & return early
  const el = document.getElementById(id);
  if (el) return el.remove();

  // No styles present: inject debug stylesheet
  const body = document.querySelector("body");
  const style = document.createElement("style");
  style.id = id;
  style.innerHTML = "body * {background: #f001 !important;}";
  body.appendChild(style);
}
Explodz

A more advanced visualiser that uses 3D CSS transforms to highlight depth.

Note: This is the original work of Chris Price, lightly updated by me to use modern CSS & JS.

View source
interface Face {
  x: number;
  y: number;
  z: number;
  w: number;
  h: number;
  r: number;
  c: string;
}

export function explodz(window: Window): void {
  const STEP = 25;
  const STEP_DELTA = 0.001;
  const PERSPECTIVE = 5000;
  const COLOURS = [
    "#C33",
    "#ea4c88",
    "#663399",
    "#0066cc",
    "#669900",
    "#ffcc33",
    "#ff9900",
    "#996633",
  ];

  function getColour(depth: number): string {
    return COLOURS[depth % (COLOURS.length - 1)];
  }

  function getFaceHTML({ x, y, z, w, h, r, c }: Face): string {
    const visual = `background-color:${c};`;
    const dimensions = `width:${w}px; height:${h}px;`;
    const transform = `transform:
			translate3d(${x}px,${y}px,${z}px)
			rotateX(270deg)
			rotateY(${r}deg);
		`;

    return (
      "<div class='explodz-face' style='" +
      visual +
      dimensions +
      transform +
      "'></div>"
    );
  }

  let facesHTML = "";
  let mode = "FACES";

  function traverse(
    element: HTMLElement,
    depth: number,
    offsetLeft: number,
    offsetTop: number
  ): void {
    const childNodes = element.childNodes as NodeListOf<HTMLElement>;
    const l = childNodes.length;
    let index = 0;
    for (const childNode of childNodes) {
      if (childNode.nodeType === Node.ELEMENT_NODE) {
        const translateZ = (STEP + (l - index) * STEP_DELTA).toFixed(3);
        childNode.classList.add("explodz-element");
        childNode.style.setProperty("--z", translateZ + "px");

        let elementBodyOffsetLeft = offsetLeft;
        let elementBodyOffsetTop = offsetTop;

        if (childNode.offsetParent === element) {
          elementBodyOffsetLeft += element.offsetLeft;
          elementBodyOffsetTop += element.offsetTop;
        }

        traverse(
          childNode,
          depth + 1,
          elementBodyOffsetLeft,
          elementBodyOffsetTop
        );

        const colour = getColour(depth);
        const stepDepth = (depth + 1) * STEP;
        // top
        facesHTML += getFaceHTML({
          x: elementBodyOffsetLeft + childNode.offsetLeft,
          y: elementBodyOffsetTop + childNode.offsetTop,
          z: stepDepth,
          w: childNode.offsetWidth,
          h: STEP,
          r: 0,
          c: colour,
        });
        // right
        facesHTML += getFaceHTML({
          x:
            elementBodyOffsetLeft +
            childNode.offsetLeft +
            childNode.offsetWidth,
          y: elementBodyOffsetTop + childNode.offsetTop,
          z: stepDepth,
          w: childNode.offsetHeight,
          h: STEP,
          r: 270,
          c: colour,
        });
        // bottom
        facesHTML += getFaceHTML({
          x: elementBodyOffsetLeft + childNode.offsetLeft,
          y:
            elementBodyOffsetTop + childNode.offsetTop + childNode.offsetHeight,
          z: stepDepth,
          w: childNode.offsetWidth,
          h: STEP,
          r: 0,
          c: colour,
        });
        // left
        facesHTML += getFaceHTML({
          x: elementBodyOffsetLeft + childNode.offsetLeft,
          y: elementBodyOffsetTop + childNode.offsetTop,
          z: stepDepth,
          w: childNode.offsetHeight,
          h: STEP,
          r: 270,
          c: colour,
        });

        index++;
      }
    }
  }

  const yCenter = (window.innerHeight / 2).toFixed(2);

  const body = document.body;
  body.classList.add("explodz-body");

  traverse(body, 0, 0, 0);

  const styleNode = document.createElement("style");
  styleNode.innerHTML = /*css*/ `
		.explodz-body {
			transition: transform 0.5s;

			overflow: visible;
			perspective: ${PERSPECTIVE}px;
			perspective-origin: center ${yCenter}px;
			transform-style: preserve-3d;
			transform-origin: center ${yCenter}px;
		}

		.explodz-body .explodz-element {
			overflow: visible;
			transform-style: preserve-3d;
			transform: translateZ(var(--z))
		}

		.explodz-faces {
			position: absolute;
			top: 0;
			left: 0;
		}

	  .explodz-face {
			position: absolute;
			transform-origin: 0 0 0;
		}
	`;
  body.appendChild(styleNode);

  const faces = document.createElement("div");
  faces.classList.add("explodz-faces");
  faces.innerHTML = facesHTML;
  body.appendChild(faces);

  function onMouseMove(e: MouseEvent) {
    const xrel = e.clientX / window.innerWidth;
    const yrel = 1 - e.clientY / window.innerHeight;
    const rx = yrel * 120 - 60;
    const ry = xrel * 120 - 60;

    body.style.transform = `rotateX(${rx.toFixed(2)}deg) rotateY(${ry.toFixed(
      2
    )}deg)`;
  }

  function toggleFaces() {
    mode = mode === "FACES" ? "NO_FACES" : "FACES";
    faces.style.display = mode === "FACES" ? "block" : "none";
  }

  function disable(event: KeyboardEvent) {
    if (event.code.toLowerCase() === "escape") {
      faces.innerHTML = "";
      body.classList.remove("explodz-body");
      body.style.transform = "";
      const explodzEls: NodeListOf<HTMLDivElement> =
        document.querySelectorAll(".explodz-element");
      for (const explodzEl of explodzEls) {
        explodzEl.classList.remove("explodz-element");
        explodzEl.style.transform = "";
      }
      styleNode.remove();

      document.removeEventListener("mousemove", onMoveDebounced, true);
      document.removeEventListener("mouseup", toggleFaces, true);
      document.removeEventListener("keydown", disable, true);
    }
  }

  function debounce(func: (...args: never) => never | void, timeout = 300) {
    let timer: NodeJS.Timeout;
    return (...args: never) => {
      clearTimeout(timer);
      timer = setTimeout(() => {
        func.apply(this, args);
      }, timeout);
    };
  }

  const onMoveDebounced = debounce(onMouseMove, 100);
  document.addEventListener("mousemove", onMoveDebounced, true);
  document.addEventListener("mouseup", toggleFaces, true);
  document.addEventListener("keydown", disable, true);
}

How this page works

These bookmarklets are authored as regular ol' TypeScript modules. To turn them into something that can be clicked, dragged and saved they've been imported, stringified, minified, url encoded, wrapped within javascript:(%encoded_fn%(this)) to make them executable and then set as the href value of a link.

Guided by tests

One of the benefits of rendering from real code is that a) the source and output are always in sync and b) the functionality can be properly unit tested. As such, it's worth highlighting that these functions are always called with the window object as an argument: doing so makes mocking (i.e. redefining) read-only properties like window.location.replace simpler than resorting to exotic hacks involving Object.defineProperty.

import { toggleFT } from "./toggleFT";

export function testToggleFT(currentUrl: string, expectedUrl: string): void {
  // Derive an instance of Location, over-writing the properties under test
  const { hostname, pathname } = new URL(currentUrl);
  const mockLocation = { ...window.location, hostname, pathname };

  // JSDOM doesn't let us redefine read-only properties... our custom object does
  mockLocation.replace = jest.fn();

  // Call the method just as a browser would, passing in a fake "window"
  toggleFT({ location: mockLocation } as Window);

  // Verify behaviour
  expect(mockLocation.replace).toHaveBeenCalledWith(expectedUrl);
}

Literate code

The "View source" feature operates similarly: the source files are read in as plaintext, run through Prettier and then parsed with Prism before being saved as JSON.

Keeping things performant

Because all this work can be done at build-time and statically pre-rendered, this page is able to function without loading any JavaScript at all.

Neat!