MaxMilton/microdoc

View on GitHub
src/router.ts

Summary

Maintainability
C
1 day
Test Coverage
F
0%
/* eslint-disable no-plusplus */

// TODO: Remember scroll position when navigating and restore it when returning
//  ↳ Could be tricky because the markdown takes a little time to render
//    ↳ It might cause a visual jump when navigating -- although that could be
//      reduced if we don't need to fetch the content file e.g., when the search
//      or preload plugins are used
//  ↳ https://github.com/sveltejs/kit/blob/master/packages/kit/src/runtime/client/router.js
//  ↳ https://github.com/sveltejs/sapper/blob/master/runtime/src/app/router/index.ts#L201
//  ↳ https://github.com/vuejs/vue-router/blob/dev/src/util/scroll.js

import { Remarkable } from 'remarkable';
import { create, setupSyntheticEvent } from 'stage1';
import type { InternalRoute, Routes } from './types';
import { FAKE_BASE_URL, makeInPageLink, toName, toSlug } from './utils';

const LOADING_DELAY_MS = 176;

const md = new Remarkable({
  html: true,
});

md.core.ruler.push(
  '',
  (state) => {
    const blockTokens = state.tokens;
    const blockLength = blockTokens.length;

    for (let blockIndex = 0; blockIndex < blockLength; blockIndex++) {
      const blockToken: Remarkable.BlockContentToken = blockTokens[blockIndex];

      if (blockToken.type === 'inline') {
        const inlineTokens = blockToken.children!;
        const inlineLength = inlineTokens.length;

        for (let inlineIndex = 0; inlineIndex < inlineLength; inlineIndex++) {
          const token = inlineTokens[inlineIndex] as Remarkable.LinkOpenToken;

          if (token.type === 'link_open') {
            // FIXME: Relative links; "./" and "../"

            // Modify in-page (start with #) and relative link href in a way
            // that works with our hash based routing
            token.href =
              token.href[0] === '#' && token.href[1] !== '/'
                ? makeInPageLink(token.href.slice(1))
                : new URL(token.href, FAKE_BASE_URL).href.replace(
                    /^http:\/\/x\/(?:#\/)?/,
                    '#/',
                  );
          }
        }
      }
    }

    return false;
  },
  {},
);

interface HeadingCloseToken extends Remarkable.HeadingToken {
  slug: string | false | undefined;
}

// Add id attribute to headings
// TODO: Prevent duplicate ids
md.renderer.rules.heading_open = (tokens, idx) => {
  const level = tokens[idx].hLevel;
  const text = (tokens[idx + 1] as unknown as Remarkable.TextToken).content;
  const slug = level > 1 && text && toSlug(text);
  // eslint-disable-next-line no-param-reassign
  (tokens[idx + 2] as HeadingCloseToken).slug = slug;

  return `<h${level}${slug ? ` id="${slug}"` : ''}>`;
};
md.renderer.rules.heading_close = (tokens, idx) => {
  // eslint-disable-next-line prefer-destructuring
  const slug = (tokens[idx] as HeadingCloseToken).slug;

  return `${
    slug
      ? `<a href="${makeInPageLink(
          slug,
        )}" class=microdoc-hash-link title="Direct link to heading">#</a>`
      : ''
  }</h${tokens[idx].hLevel}>\n`;
};

// Add wrapper div around tables
md.renderer.rules.table_open = () => '<div class=table-wrapper><table>';
md.renderer.rules.table_close = () => '</table></div>';

const $routes = new Map<string, InternalRoute>();

export function routeTo(url: string): void {
  window.location.hash = url;
}

// https://github.com/lukeed/navaid/blob/master/src/index.js#L52
function handleClick(event: MouseEvent): void {
  if (
    event.ctrlKey ||
    event.metaKey ||
    event.altKey ||
    event.shiftKey ||
    event.button ||
    event.defaultPrevented
  ) {
    return;
  }

  const link = (event.target as HTMLElement).closest('a');
  const href = link && link.getAttribute('href');

  if (
    !href ||
    link.target ||
    link.host !== window.location.host ||
    href[0] === '#'
  ) {
    return;
  }

  event.preventDefault();
  routeTo(href);
}

function normaliseRoutes(routes: Routes, parent?: InternalRoute) {
  for (let index = 0; index < routes.length; index++) {
    if (typeof routes[index] === 'string') {
      // eslint-disable-next-line no-param-reassign
      routes[index] = {
        path: routes[index] as string,
      };
    }

    const route = routes[index] as InternalRoute;

    if (!route.path && !route.children) {
      // eslint-disable-next-line no-console
      console.error('Invalid route:', route);

      // Remove broken route
      routes.splice(index--, 1);
      continue;
    }

    if (parent) {
      route.parent = parent;
    }

    if (route.path) {
      route.path = `${parent?.path ? `${parent.path}/` : '#/'}${route.path}`;
    }

    if (!route.name && route.path) {
      route.name = toName(route.path);
    }

    if (route.children) {
      // Parent routes (with children) can be without a path if they're being
      // used just for grouping together content into a logical section
      if (!route.path) {
        route.path = parent?.path || '';
      }

      normaliseRoutes(route.children, route);
    } else {
      $routes.set(route.path!, route);
    }
  }
}

export function setupRouter(): void {
  // Expose internal route map for plugins
  window.microdoc.$routes = $routes;

  normaliseRoutes(window.microdoc.routes);

  document.body.__click = handleClick;
  setupSyntheticEvent('click');
}

const loadingError = (path: string, error: unknown) => `
  <div class=microdoc-alert>
    <strong>Error: </strong>${String(error) || 'Unknown error'}
  </div>

  <p class=break>Unable to load ${path}</p>
`;

async function getContent(path: string): Promise<string> {
  let content;

  try {
    const response = await fetch(path);
    content = await response.text();

    if (!response.ok) {
      throw new Error(content || `${response.status}`);
    }
  } catch (error) {
    // eslint-disable-next-line no-console
    console.error(error);

    content = loadingError(path, error);
  }

  return content;
}

function setHTML(node: Element, html: string): void {
  if ('setHTML' in node) {
    // @ts-expect-error - Experimental browser API -- https://developer.mozilla.org/en-US/docs/Web/API/Element/setHTML
    node.setHTML(html);
  } else {
    // eslint-disable-next-line no-param-reassign
    node.innerHTML = html;
  }
}

type RouterComponent = HTMLDivElement;

const view = create('div');
view.className = 'microdoc-page con';

export function Router(): RouterComponent {
  const root = view;

  const loadRoute = (path: string) => {
    if (!path || path === '/') {
      const [[firstRoute]] = $routes;
      routeTo(firstRoute);
      return;
    }

    // Delay loading state to prevent flash even when loading from cache etc.
    const timer = setTimeout(() => {
      root.innerHTML = `
        <div class=spinner-wrapper>
          <div class=spinner></div>
        </div>
      `;
    }, LOADING_DELAY_MS);

    const { hash, pathname } = new URL(path, FAKE_BASE_URL);
    const route = $routes.get(`#${pathname}`);

    if (!route) {
      clearTimeout(timer);
      setHTML(root, loadingError(pathname, new Error('Invalid route')));
      document.title = `Error | ${window.microdoc.title}`;
      return;
    }

    // eslint-disable-next-line no-void
    void getContent(window.microdoc.root + pathname).then((code) => {
      const html = md.render(code);

      clearTimeout(timer);
      setHTML(root, html);
      document.title = `${route.name} | ${window.microdoc.title}`;

      window.microdoc.afterRouteLoad?.(route);

      // scroll to an in-page link
      if (hash) {
        try {
          const id = hash.slice(1);
          const el = document.getElementById(id)!;
          el.scrollIntoView();
          return;
        } catch (error) {
          /* No op */
        }
      }

      // scroll to top
      window.scrollTo(0, 0);
    });

    $routes.forEach((route2) => {
      route2.ref!.classList.remove('active');
    });
    route.ref!.classList.add('active');

    let parent: InternalRoute | undefined = route;

    // eslint-disable-next-line no-cond-assign
    while ((parent = parent.parent)) {
      parent.ref!.classList.add('expanded');
    }
  };

  const handleHashChange = () => loadRoute(window.location.hash.slice(1));

  window.addEventListener('hashchange', handleHashChange);
  // load initial route
  handleHashChange();

  return root;
}