Kitsu MALonnaised

Shows MyAnimeList.net data on kitsu.app

// ==UserScript==
// @name         Kitsu MALonnaised
// @description  Shows MyAnimeList.net data on kitsu.app
// @version      1.1.0
//
// @author       tophf
// @namespace    https://github.com/tophf
// @inspired-by  https://greasyfork.org/scripts/5890
//
// @match        https://kitsu.app/*
//
// @grant        GM_xmlhttpRequest
// @grant        GM_getValue
// @grant        GM_setValue
// @grant        GM_addStyle
// @grant        GM_openInTab
// @grant        GM_getResourceText
// @grant        unsafeWindow
//
// @resource     LZString https://cdn.jsdelivr.net/gh/openstyles/lz-string-unsafe@22af192175b5e1707f49c57de7ce942d4d4ad480/lz-string-unsafe.min.js
// @run-at       document-start
//
// @connect      myanimelist.net
// @connect      self
// ==/UserScript==

/* global LZStringUnsafe */
'use strict';

const API_URL = 'https://kitsu.app/api/edge/';
const MAL_URL = 'https://myanimelist.net/';
const MAL_CDN_URL = 'https://cdn.myanimelist.net/';
let MAL_IMG_EXT = '.jpg';
// maximum number present in a MAL page initially
const MAL_RECS_LIMIT = 24;
const MAL_CAST_LIMIT = 10;
const MAL_STAFF_LIMIT = 4;
const MAL_CSS_CHAR_IMG = 'a[href*="/character/"] img[data-src]';
const MAL_CSS_VA_IMG = 'a[href*="/people/"] img[data-src]';
const KITSU_RECS_PER_ROW = 4;
const KITSU_RECS_HOVER_DELAY = 250;
const KITSU_RECS_HOVER_DURATION = 500;
const KITSU_GRAY_LINK_CLASS = 'import-title';
// IntersectionObserver margin
const LAZY_MARGIN = 200;
const LAZY_ATTR = 'malsrc';
const $LAZY_ATTR = '$' + LAZY_ATTR;

const DB_NAME = 'MALonnaise';
const DB_STORE_NAME = 'data';
const DB_FIELDS = 'path TID time score users favs chars recs'.split(' ');

const HOUR = 3600e3;
const DAY = 24 * HOUR;
const AIR_DATE_MAX_DIFF = 30 * DAY;
const CACHE_DURATION = DAY;
const GZIP = window.CompressionStream;

const ID = (name => Object.defineProperties({
  SCORE: `${name}-SCORE`,
  USERS: `${name}-USERS`,
  FAVS: `${name}-FAVS`,
  CHARS: `${name}-CHARS`,
  RECS: `${name}-RECS`,
}, {
  me: {
    value: name,
  },
  selectAll: {
    value: (suffix = '') =>
      Object.keys(ID)
        .map(id => `#${ID.me}-${id} ${suffix}`)
        .join(','),
  },
}))(GM_info.script.name.replace(/\W/g, ''));

const EXT_LINK = {
  tag: 'SVG:svg',
  viewBox: '0 0 22 22',
  children: [{
    tag: 'SVG:path',
    d: 'M13,0v2h5.6L6.3,14.3l1.4,1.4L20,3.4V9h2V0H13z M0,4v18h18V9l-2,2v9H2V6h9l2-2H0z',
  }],
};

const agent = (() => {
  const data = new Proxy({}, {
    get: (self, name) =>
      self[name] ||
      (self[name] = new Map()),
  });
  return {
    on(name, fn, thisArg) {
      data[name].set(fn, [thisArg]);
    },
    resolveOn(name, thisArg) {
      return new Promise(resolve =>
        data[name].set(resolve, [thisArg, true]));
    },
    fire(name, ...args) {
      const listeners = data[name];
      for (const [fn, [thisArg, once]] of listeners) {
        fn.apply(thisArg, args);
        if (once)
          listeners.delete(fn);
      }
    },
  };
})();


const API = (() => {
  const API_OPTIONS = {
    headers: {
      Accept: 'application/vnd.api+json',
    },
  };
  const handler = {
    get({path}, endpoint) {
      const fn = () => {};
      fn.path = path + (path ? '/' : '') + endpoint;
      return new Proxy(fn, handler);
    },
    async apply(target, thisArg, [options]) {
      for (const [k, v] of Object.entries(options)) {
        if (typeof v === 'object') {
          delete options[k];
          for (const [kk, vv] of Object.entries(v))
            options[`${k}[${kk}]`] = vv;
        }
      }
      const url = `${API_URL}${target.path}?${new URLSearchParams(options)}`;
      return (await fetch(url, API_OPTIONS)).json();
    },
  };
  return new Proxy({path: ''}, handler);
})();

/**
 * @property {Object} data
 * @property {String} renderedPath
 */
class App {

  static async init() {
    App.data = {};
    agent.on(InterceptXHR.register(), App.processMappings);
    agent.on(InterceptHistory.register(), App.onUrlChange);
    window.addEventListener('popstate', () => App.onUrlChange());

    await Cache.init();
    App.onUrlChange();
    App.initStyles();

    // detect WebP support
    $create({
      tag: 'img',
      src: 'data:image/webp;base64,UklGRh4AAABXRUJQVlA4TBEAAAAvAAAAAAfQ//73v/+BiOh/AAA=',
      onload() {
        MAL_IMG_EXT = '.webp';
      },
    });
  }

  static async onUrlChange(path = location.pathname) {
    const [, type, slug] = path.match(/\/(anime|manga)\/([^/?#]+)(?:[?#].*)?$|$/);
    App.hide();
    if (!slug)
      App.data = {path};
    if (App.data.path === path)
      return;
    let data = await Cache.read(type, slug) || {};
    App.data = data;
    if (!data.path) {
      App.findMalEquivalent(type, slug);
      return;
    }
    if (data.expired)
      App.plant(data);
    if (data.expired || !data.score)
      data = await App.processMal({type, slug, TID: data.TID});
    App.plant(data);
  }

  static async findMalEquivalent(type, slug) {
    const kitsuData = await API[type]({
      filter: {slug},
      include: 'mappings',
      fields: {
        mappings: 'externalSite,externalId',
        [type]: 'id,slug,status,subtype,startDate',
      },
    });
    if (await App.processMappings(kitsuData))
      return;
    const {categories: malData} = await Util.fetchJson(`${MAL_URL}search/prefix.json?${
      new URLSearchParams({type, keyword: encodeURIComponent(slug), v: 1})
    }`);
    try {
      const gist = Util.str2gist(slug);
      const ka = kitsuData.data[0].attributes;
      const kDate = +Date.parse(ka.startDate + ' GMT');
      const kSubType = ka.subtype.toLowerCase();
      for (const c of malData) {
        if (type !== c.type.toLowerCase())
          continue;
        for (const {url, name, payload: p} of c.items) {
          const mDate = Date.parse(p.aired.split(' to ')[0] + ' GMT');
          const dateDiff = kDate ? Math.abs(kDate - mDate) : Date.now() - mDate;
          if (dateDiff < AIR_DATE_MAX_DIFF && (
            dateDiff <= DAY && kSubType !== p.media_type.toLowerCase() ||
            Util.str2gist(name) === gist
          )) {
            const TID = MalTypeId.fromUrl(url);
            App.plant({
              TID,
              expired: true,
              score: [Number(p.score) || 0],
              path: type + '/' + slug,
            });
            App.plant(await App.processMal({type, slug, url, TID}));
            return;
          }
        }
      }
    } catch (e) {}
    console.warn('No match on MAL for %s/%s', type, slug, malData, kitsuData);
  }

  static async processMappings(payload) {
    const url = Mal.findUrl(payload);
    if (!url)
      return;
    const {type, attributes: {slug}} = payload.data[0];
    let data = await Cache.read(type, slug);
    if (!data || data.expired || !data.score)
      data = await App.processMal({type, slug, url});
    App.plant(data);
    return true;
  }

  static async processMal({type, slug, url, TID}) {
    App.shouldFadeOut = true;
    App.hide();
    const data = await Mal.scavenge(url || MalTypeId.toUrl(TID));
    data.TID = TID || MalTypeId.urlToTID(url);
    data.path = type + '/' + slug;
    if (App.data.recs)
      data.recs.push(...MalRecs.subtract(App.data.recs, data.recs));
    setTimeout(Cache.write, 0, type, slug, data);
    return data;
  }

  static async plant(data) {
    if (!data || data.path === App.renderedPath)
      return;
    App.data = data;
    const [type, slug] = data.path.split('/');
    Object.defineProperties(data, {
      type: {value: type, configurable: true},
      slug: {value: slug, configurable: true},
      url: {value: MalTypeId.toUrl(data.TID), configurable: true},
    });

    await Mutant.gotPath(data);

    Render.all(data);

    App.renderedPath = data.expired ? '' : data.path;
    App.shouldFadeOut = !data.score;
  }

  static async hide() {
    App.renderedPath = '';
    await Util.nextTick();
    if (!App.shouldFadeOut)
      return;
    for (const el of $$(ID.selectAll()))
      el.style.opacity = 0;
  }

  static initStyles() {
    Mutant.gotTheme().then(() => {
      if (!document.body)
        return;
      const bgColor = getComputedStyle(document.body).backgroundColor;
      document.head.append(
        $create({
          tag: 'style',
          textContent: `
            #${ID.RECS} {
              --${ID.me}-bg-color: ${bgColor};
            }`,
        }));
    });

    const MAIN_TRANSITION = 'opacity .25s';

    const RECS_MIN_HEIGHT = 220;
    const RECS_MAX_HEIGHT = 20e3;
    const RECS_IMG_MARGIN = '.5rem';
    const RECS_TRANSITION_TIMING = `${KITSU_RECS_HOVER_DURATION}ms ${KITSU_RECS_HOVER_DELAY}ms`;

    const EXT_LINK_SIZE_EM = .8;

    // language=CSS
    GM_addStyle(`
      a[mal] svg {
        fill: currentColor;
        margin-left: ${EXT_LINK_SIZE_EM / 2}em;
        width: ${EXT_LINK_SIZE_EM}em;
        height: ${EXT_LINK_SIZE_EM}em;
        display: inline-block;
        opacity: .5;
        -webkit-user-select: none;
        -moz-user-select: none;
        -ms-user-select: none;
        user-select: none;
      }
      a[mal="title"] svg {
        vertical-align: middle;
      }
      a[mal]:hover svg {
        opacity: 1;
      }
      .media--sidebar .is-sticky {
        position: static !important;
      }
      #SCORE:hover,
      ${ID.selectAll('a:hover')} {
        text-decoration: underline;
      }
      ${ID.selectAll()} {
        transition: ${MAIN_TRANSITION};
      }
      ${ID.selectAll('ins')} {
        display: block;
        width: 100%;
      }
      #SCORE:not(:first-child),
      #USERS,
      #FAVS {
        margin-left: 1em;
      }
      #USERS::before {
        content: '\\1F464';
        margin-right: .25em;
      }
      #FAVS::before {
        content: '\\2764';
        margin-right: .25em;
      }
      /*******************************************************/
      #CHARS h5 {
        display: inline-block;
      }
      #CHARS h5 a {
        font: inherit;
      }
      #CHARS summary {
        cursor: zoom-in;
      }
      #CHARS details[open] summary {
        cursor: zoom-out;
      }
      #CHARS summary:hover {
        color: #fff;
      }
      #CHARS[mal="anime"] div[mal] {
        width: 50%;
        display: inline-block;
      }
      #CHARS[mal="manga"] li {
        width: calc(50% - 4px);
        display: inline-block;
      }
      #CHARS[mal="manga"] ul[hovered] li:nth-child(odd) {
        margin-right: 8px;
      }
      #CHARS div[mal="people"] {
        opacity: .5;
        will-change: opacity;
        transition: opacity .25s .1s;
      }
      #CHARS div[mal="people"] img {
        opacity: .3;
        will-change: opacity;
        transition: opacity .25s .1s;
      }
      #CHARS div[mal="people"]:only-child {
        width: 100%;
        opacity: 1;
      }
      #CHARS div[mal="people"]:only-child img {
        opacity: .15;
      }
      #CHARS:hover div[mal="people"]:only-child img {
        opacity: .45;
      }
      #CHARS:hover div[mal="people"] img {
        opacity: .6;
      }
      #CHARS div[mal="people"]:only-child:hover img,
      #CHARS div[mal="people"]:hover,
      #CHARS div[mal="people"] img:hover {
        opacity: 1;
      }
      #CHARS div[mal]:first-child a {
        font-weight: bold;
      }
      #CHARS li a svg {
        vertical-align: middle;
        line-height: 1.0;
      }
      #CHARS span {
        display: inline-block;
        white-space: pre-line;
        overflow: hidden;
        text-overflow: ellipsis;
        max-width: calc(100% - 2 * ${EXT_LINK_SIZE_EM}em); /* room for the ext link icon */
        vertical-align: middle;
        line-height: 1.0;
      }
      #CHARS a div {
        overflow: hidden;
        width: 100%;
      }
      #CHARS div[mal="people"]:only-child {
        width: 100%;
      }
      #CHARS img {
        width: calc(100% + 2px);
        max-width: none;
        margin: -1px;
      }
      #CHARS img[${LAZY_ATTR}]:not([src]) {
        padding: 0 100% ${Util.num2pct(350 / 225)} 0;
      }
      #CHARS div[mal]:not(:only-child) a > :first-child:not(div) {
        margin-top: 60%;
      }
      #CHARS small {
        display: block;
        margin: 0 0 8px 0;
        line-height: 1.0;
      }
      /* replace the site's chars */
      #CHARS ul:not([hovered]) {
        display: flex;
        flex-wrap: wrap;
      }
      #CHARS ul[mal~="one-row"]:not([hovered]) li:nth-child(n + 5),
      #CHARS ul:not([hovered]) li:nth-child(n + 9) {
        display: none;
      }
      #CHARS ul:not([hovered]) li {
        width: calc(25% - 6px);
        margin: 0 3px 6px;
        position: relative;
      }
      #CHARS ul:not([hovered]) div[mal] {
        width: 100%;
      }
      #CHARS ul:not([hovered]) a div {
        border-radius: 3px;
        margin-bottom: .5em;
      }
      #CHARS[mal="anime"] ul:not([hovered]) div[mal="people"],
      #CHARS ul:not([hovered]) small,
      #CHARS ul:not([hovered]) li a[mal] svg{
        display:none;
      }
      #CHARS ul:not([hovered]) span {
        max-width: 100%;
        vertical-align: top;
      }
      /*******************************************************/
      #RECS {
        margin-bottom: 1em;
      }
      #RECS ul {
        display: flex;
        flex-wrap: wrap;
        margin: 0 -${RECS_IMG_MARGIN} 0 0;
        padding: 0;
        max-height: ${RECS_MIN_HEIGHT}px;
        overflow: hidden;
        position: relative;
        contain: layout;
        transition: max-height ${RECS_TRANSITION_TIMING};
      }
      #RECS ul:hover {
        max-height: ${RECS_MAX_HEIGHT}px;
      }
      #RECS ul:not(.hovered) {
        -webkit-mask-image: linear-gradient(#000, transparent);
      }
      #RECS li {
        list-style: none;
        position: relative;
        margin: 0 .5rem .5rem 0;
        width: calc(${Util.num2pct(1 / KITSU_RECS_PER_ROW)} - ${RECS_IMG_MARGIN});
        line-height: 1;
        display: flex;
        flex-direction: column;
      }
      #RECS li[mal="auto-rec"] {
        opacity: .25;
      }
      #RECS li[mal="auto-rec"]:hover {
        opacity: 1;
      }
      #RECS li[mal="more"] {
        width: 100%;
        text-align: center;
        padding: 0;
      }
      #RECS li[mal="more"] a {
        padding: 1em;
      }
      #RECS a[mal="title"] {
        margin: 0 0 ${Util.num2pct(315 / 225)};
        font-size: .8rem;
        font-weight: bolder;
      }
      #RECS div {
        overflow: hidden;
        position: absolute;
        top: 2rem;
        left: 0;
        right: 0;
        bottom: 0;
        background-size: calc(100% + 2px);
        background-position: -1px -1px;
        background-repeat: no-repeat;
        transition: opacity .5s, filter .5s;
        cursor: pointer;
      }
      #RECS li[mal="auto-rec"] div {
        filter: grayscale(1);
      }
      #RECS li[mal="auto-rec"]:hover div {
        filter: none;
      }
      #RECS a[mal="title"] div::after {
        content: "MAL only";
        position: absolute;
        top: 0;
        left: 0;
        right: 0;
        bottom: 0;
        box-sizing: content-box;
        width: 2rem;
        height: 2rem;
        margin: auto;
        padding: .75rem .6rem .5rem;
        text-align: center;
        line-height: .9;
        font-weight: bold;
        font-size: 1rem;
        letter-spacing: -.05em;
        border: 3px solid #fff;
        border-radius: 4rem;
        background: #2E51A2;
        color: #fff;
        box-shadow: 2px 3px 10px 2px #000a;
        transition: opacity .5s .1s;
        opacity: 0;
      }
      #RECS a[mal="title"] div:hover::after {
        opacity: 1;
      }
      #RECS span {
        white-space: nowrap;
        overflow: hidden;
        text-overflow: ellipsis;
        padding: 0;
        margin: 0;
        display: inline-block;
        vertical-align: sub;
        max-width: calc(100% - 1.5 * ${EXT_LINK_SIZE_EM}em);
      }
      #RECS small {
        font-size: .75rem;
        opacity: .75;
        margin-bottom: .25em;
      }
    `
    // language=none
    .replace(
      new RegExp(`#(?=${Object.keys(ID).join('|')})\\b`, 'g'),
      `#${ID.me}-`
    ));
  }
}


/**
 * @property {IDB} db
 */
class Cache { // eslint-disable-line no-redeclare

  static async init() {
    Cache.idb = new IDB(DB_NAME, DB_STORE_NAME);
    await Cache.idb.open({
      onupgradeneeded(e) {
        if (!e.oldVersion) {
          const store = e.target.result.createObjectStore(DB_STORE_NAME, {keyPath: 'path'});
          store.createIndex('TID', 'TID', {unique: true});
          store.createIndex('time', 'time', {unique: false});
        }
      },
    });
    if (GZIP) return;
    Cache.initLZ();
    Cache.zip = Cache.LZ;
    Cache.unzip = Cache.unLZ;
  }

  static initLZ() {
    const url = URL.createObjectURL(new Blob([
      GM_getResourceText('LZString'),
      `;(${() => {
        self.onmessage = ({data: {id, action, value}}) =>
          self.postMessage({
            id,
            value: LZStringUnsafe[action](value),
          });
      }})()`,
    ]));
    const q = [];
    const w = new Worker(url);
    const invokeWorker = (action, value) => new Promise(resolve => {
      const id = performance.now();
      const payload = {id, action, value};
      q.push({resolve, payload});
      if (q.length === 1)
        w.postMessage(payload);
    });
    w.onmessage = ({data: {id, value}}) => {
      const i = q.findIndex(_ => _.payload.id === id);
      q[i].resolve(value);
      q.splice(i, 1);
      if (q.length)
        w.postMessage(q[0].payload);
    };
    URL.revokeObjectURL(url);
    Cache.LZ = invokeWorker.bind(null, 'compressToUTF16');
    Cache.unLZ = invokeWorker.bind(null, 'decompressFromUTF16');
  }

  static async read(type, slug) {
    const path = type + '/' + slug;
    const data = await Cache.idb.get(path);
    if (!data)
      return;
    if (Date.now() - data.time > CACHE_DURATION)
      data.expired = true;
    if (data.lz) {
      for (const [k, v] of Object.entries(data.lz))
        data[k] = Util.parseJson(await Cache.unzip(v));
      data.lz = undefined;
    }
    return data;
  }

  static async write(type, slug, data) {
    data.path = type + '/' + slug;
    data.time = Date.now();
    const toWrite = {};
    let lz, lzKeys;
    for (const k of DB_FIELDS) {
      const v = data[k];
      if (v === undefined)
        continue;
      if (v && typeof v === 'object') {
        const str = JSON.stringify(v);
        if (str.length > 100) {
          (lzKeys || (lzKeys = [])).push(k);
          (lz || (lz = [])).push(Cache.zip(str));
          continue;
        }
      }
      toWrite[k] = v;
    }
    if (lz)
      toWrite.lz = (await Promise.all(lz)).reduce((res, v, i) => ((res[lzKeys[i]] = v), res), {});
    try {
      await Cache.idb.put(toWrite);
    } catch (e) {
      if (e instanceof DOMException &&
          e.code === DOMException.QUOTA_EXCEEDED_ERR) {
        await Cache.cleanup();
        await Cache.idb.put(toWrite);
      } else {
        console.error(e);
      }
    }
  }

  static cleanup() {
    return new Promise(resolve => {
      this.idb.exec({index: 'time', write: true, raw: true})
        .openCursor(IDBKeyRange.upperBound(Date.now - CACHE_DURATION))
        .onsuccess = e => {
          const cursor = /** @type IDBCursorWithValue */ e.target.result;
          if (!cursor) {
            resolve();
            return;
          }
          const {value} = cursor;
          if (value.lz) {
            delete value.lz;
            cursor.update(value);
          }
          cursor.continue();
        };
    });
  }

  static zip(str) {
    const zipped = new Response(str).body.pipeThrough(new GZIP('gzip'));
    return new Response(zipped).blob();
  }

  /** @param {Blob|string|undefined} v */
  static unzip(v) {
    if (!v)
      return;
    if (typeof v === 'string')
      return (!Cache.unLZ && Cache.initLZ(), Cache.unLZ(v));
    /* global DecompressionStream */
    return new Response(v.stream().pipeThrough(new DecompressionStream('gzip'))).text();
  }
}


/**
 * @property {IDBDatabase} db
 */
class IDB {

  constructor(name, storeName) {
    this.name = name;
    this.storeName = storeName;
  }

  open(events) {
    return new Promise(resolve => {
      Object.assign(indexedDB.open(this.name), events, {
        onsuccess: e => {
          this.db = e.target.result;
          resolve();
        },
      });
    });
  }

  get(key, index) {
    return this.exec({index}).get(key);
  }

  put(value) {
    return this.exec({write: true}).put(value);
  }

  /**
   * @param _
   * @param {Boolean} [_.write]
   * @param {String} [_.index]
   * @param {Boolean} [_.raw]
   * @return {Promise<IDBObjectStore|IDBIndex>|IDBObjectStore|IDBIndex}
   */
  exec({write, index, raw} = {}) {
    return new Proxy({}, {
      get: (_, method) =>
        (...args) => {
          let op = this.db
            .transaction(this.storeName, write ? 'readwrite' : 'readonly')
            .objectStore(this.storeName);
          if (index)
            op = op.index(index);
          op = op[method](...args);
          return raw ?
            op :
            new Promise((resolve, reject) => {
              op.onsuccess = e => resolve(e.target.result);
              op.onerror = reject;
            });
        },
    });
  }
}


class InterceptHistory {
  static register() {
    const event = Symbol(this.name);
    const pushState = unsafeWindow.History.prototype.pushState;
    unsafeWindow.History.prototype.pushState = function (state, title, url) {
      pushState.apply(this, arguments);
      agent.fire(event, url);
    };
    return event;
  }
}


class InterceptXHR {
  static register() {
    const event = Symbol(this.name);
    const XHR = unsafeWindow.XMLHttpRequest;
    unsafeWindow.XMLHttpRequest = class extends XHR {
      open(method, url, ...args) {
        if (url.startsWith(API_URL)) {
          const newUrl = InterceptXHR.onOpen.call(this, url);
          if (newUrl === false)
            return;
          if (newUrl) {
            url = newUrl;
            this.addEventListener('load', onLoad, {once: true});
          }
        }
        return super.open(method, url, ...args);
      }
    };
    return event;

    function onLoad(e) {
      agent.fire(event, JSON.parse(e.target.responseText));
    }
  }

  static onOpen(url) {
    // https://kitsu.app/api/edge/anime?........&include=categories.......
    if (
      !App.data.TID &&
      url.includes('&include=') && (
        url.includes('/anime?') ||
        url.includes('/manga?'))
    ) {
      const u = new URL(url);
      u.searchParams.set('include', u.searchParams.get('include') + ',mappings');
      u.searchParams.set('fields[mappings]', 'externalSite,externalId');
      return u.href;
    }
    // https://kitsu.app/api/edge/castings?.....&page%5Blimit%5D=4&......
    if (App.data.chars &&
        url.includes('/castings?') &&
        url.includes('page%5Blimit%5D=4')) {
      this.send = InterceptXHR.sendDummy;
      this.setRequestHeader = InterceptXHR.dummy;
      return false;
    }
  }

  static sendDummy() {
    Object.defineProperty(this, 'responseText', {value: '{"data": []}'});
    this.onload({type: 'load', target: this});
  }

  static dummy() {
    // NOP
  }
}


class Mal {

  static findUrl(data) {
    for (const {type, attributes: a} of data.included || []) {
      if (type === 'mappings' &&
          a.externalSite.startsWith('myanimelist')) {
        const malType = a.externalSite.split('/')[1];
        const malId = a.externalId;
        return MAL_URL + malType + '/' + malId;
      }
    }
  }

  static extract(img, stripId) {
    if (!img)
      return;
    const text = Util.decodeHtml(img.alt) || 0;
    // https://myanimelist.net/character/101457/Chika_Kudou
    // https://myanimelist.net/recommendations/anime/31859-35790
    // https://myanimelist.net/anime/19815/No_Game_No_Life?suggestion
    const a = img.closest('a');
    let aId = a && a.href.match(/\/(\d+(?:-\d+)?)|$/)[1] || 0;
    if (stripId && aId && aId.includes('-'))
      aId = aId.replace(stripId, '');
    // https://cdn.myanimelist.net/r/23x32/images/characters/7/331067.webp?s=xxxxxxxxxx
    // https://cdn.myanimelist.net/r/23x32/images/voiceactors/1/47102.jpg?s=xxxxxxxxx
    // https://cdn.myanimelist.net/r/90x140/images/anime/13/77976.webp?s=xxxxxxx
    const {src} = img.dataset;
    const imgId = src && src.match(/\/(\d+\/\d+)\.|$/)[1] || 0;
    return [text, aId >> 0, imgId];
  }

  static extractChars(doc) {
    const processed = new Set();
    const chars = [];
    for (const img of $$(`${MAL_CSS_CHAR_IMG}, ${MAL_CSS_VA_IMG}`, doc)) {
      const parent = img.closest('table');
      if (processed.has(parent))
        continue;
      // we're assuming a character is a table that contains an actor's table
      // and the character's img comes first so we can add the nested actor's table
      // thus skipping it on subsequent matches for 'a[href*="/people/"] img'
      processed.add($('table', parent));
      const char = $(MAL_CSS_CHAR_IMG, parent);
      let actor;
      if (char) {
        for (const el of $$(MAL_CSS_VA_IMG, parent)) {
          const lang = $text('small', el.closest('tr'));
          if (!lang || lang === 'Japanese') {
            actor = el;
            break;
          }
        }
      } else {
        actor = img;
      }
      chars.push([
        $text('small', parent),
        char ? Mal.extract(char) : [],
        ...(actor ? [Mal.extract(actor)] : []),
      ]);
    }
    return chars.length && chars;
  }

  static async scavenge(url) {
    const doc = await Util.fetchDoc(url);
    let el, score, users, favs;

    el = $('[itemprop="ratingValue"],' +
           '[data-id="info1"] > span:not(.dark_text)', doc);
    if (!el)
      return {};
    score = $text(el).trim();
    score = score && Number(score.match(/[\d.]+|$/)[0]) || score;
    const ratingCount = Util.str2num($text('[itemprop="ratingCount"]', doc));

    while (el.parentElement && !el.parentElement.textContent.includes('Members:'))
      el = el.parentElement;
    while ((!users || !favs) && (el = el.nextElementSibling)) {
      const txt = el.textContent;
      users = users || Util.str2num(txt.match(/Members:\s*([\d,]+)|$/)[1]);
      favs = favs || Util.str2num(txt.match(/Favorites:\s*([\d,]+)|$/)[1]);
    }

    const rxStripOwnId = new RegExp('-?\\b' + url.match(/\d+/)[0] + '\\b-?');
    const recs = $$('#anime_recommendation .link,' +
                    '#manga_recommendation .link', doc)
      .map(a => [
        ...Mal.extract($('img', a), rxStripOwnId),
        parseInt($text('.users', a)) || 0,
      ]);

    return {
      users,
      favs,
      score: score ? [score, ratingCount || 0] : undefined,
      chars: Mal.extractChars(doc) || undefined,
      recs: recs.length ? recs : undefined,
    };
  }

  static async scavengeRecs(url) {
    const doc = await Util.fetchDoc(url);
    const data = App.data;
    const oldRecs = data.recs || [];
    const rxType = new RegExp(`^${url.split('/')[3]}: `, 'i');
    data.recs = $$('a[href*="/recommendations/"]', doc)
      .map(a => {
        const entry = a.closest('table');
        const more = $text('a:not([href^="/"]):not([href^="http"])', entry);
        const count = parseInt(more.match(/\s\d+\s|$/)[0]) + 1 || 1;
        const info = Mal.extract($('a img', entry));
        info[0] = info[0].replace(rxType, '');
        info.push(count);
        return info;
      });
    data.recs.sort(MalRecs.sortFn);
    setTimeout(Cache.write, 0, data.type, data.slug, data);
    return MalRecs.subtract(data.recs, oldRecs);
  }
}


const REC_IDX_NAME = 0;
const REC_IDX_ID = 1;
const REC_IDX_COUNT = 3;


class MalRecs {

  static hasId(recs, id) {
    return recs.some(r => r[REC_IDX_ID] === id);
  }

  static subtract(recsA, recsB) {
    return recsA.filter(([, id]) => !MalRecs.hasId(recsB, id));
  }

  static sortFn(a, b) {
    return b[REC_IDX_COUNT] - a[REC_IDX_COUNT] ||
           a[REC_IDX_NAME] < b[REC_IDX_NAME] && -1 ||
           a[REC_IDX_NAME] > b[REC_IDX_NAME] && 1 ||
           0;
  }
}


class MalTypeId {

  static fromUrl(url) {
    return url.match(/((?:anime|manga)\/\d+)|$/)[1] || '';
  }

  static toUrl(typeId) {
    if (!typeId.includes('/'))
      typeId = MalTypeId.fromTID(typeId).join('/');
    return MAL_URL + typeId;
  }

  static fromTID(short) {
    const t = short.slice(0, 1);
    const fullType = t === 'a' && 'anime' ||
                     t === 'm' && 'manga' ||
                     '';
    return [fullType, short.slice(1)];
  }

  static toTID(typeId) {
    return typeId.slice(0, 1) + typeId.split('/')[1];
  }

  static urlToTID(url) {
    return MalTypeId.toTID(MalTypeId.fromUrl(url));
  }
}


class Mutant {

  static async gotPath({path} = {}) {
    const skipCurrent = !path;
    const selector = 'meta[property="og:url"]' +
                     (skipCurrent ? '' : `[content="${location.origin}/${path}"]`);
    if (Mutant.isWaiting(selector, skipCurrent))
      return agent.resolveOn('gotPath');
    const el = await Mutant.waitFor(selector, document.head, {skipCurrent});
    agent.fire('gotPath', path);
    return el;
  }

  static async gotTheme() {
    const head = await Mutant.waitFor('head', document.documentElement);
    const el = await Mutant.waitFor('link[data-theme]', head);
    try {
      el.sheet.cssRules.item(0);
    } catch (e) {
      await new Promise(done => el.addEventListener('load', done, {once: true}));
    }
  }

  static gotMoved(node, timeout = 10e3) {
    return new Promise(resolve => {
      const parent = node.parentNode;
      let timer;
      const ob = new MutationObserver(() => {
        if (node.parentNode !== parent) {
          ob.disconnect();
          clearTimeout(timer);
          resolve(true);
        }
      });
      ob.observe(parent, {childList: true});
      timer = setTimeout(() => {
        ob.disconnect();
        resolve(false);
      }, timeout);
    });
  }

  static async waitFor(selector, base, {skipCurrent} = {}) {
    return !skipCurrent && $(selector, base) ||
      new Promise(resolve => {
        if (!Mutant._waiting)
          Mutant._waiting = new Set();
        Mutant._waiting.add(selector);
        new MutationObserver((mutations, ob) => {
          for (const {addedNodes} of mutations) {
            for (const n of addedNodes) {
              if (n.matches && n.matches(selector)) {
                Mutant._waiting.delete(selector);
                ob.disconnect();
                resolve(n);
              }
            }
          }
        }).observe(base, {childList: true});
      });
  }

  static isWaiting(selector, asPrefix) {
    if (!Mutant._waiting) {
      Mutant._waiting = new Set();
      return false;
    } else if (asPrefix) {
      for (const s of Mutant._waiting) {
        if (s.startsWith(selector))
          return true;
      }
    } else {
      return Mutant._waiting.has(selector);
    }
  }
}


class Render {

  static all(data) {
    if (!Render.scrollObserver)
      Render.scrollObserver = new IntersectionObserver(Render._lazyLoad, {
        rootMargin: LAZY_MARGIN + 'px',
      });
    Render.stats(data);
    Render.characters(data);
    Render.recommendations(data);
    Render.observe();
  }

  static observe(container) {
    for (const el of $$(`[${LAZY_ATTR}]`, container))
      Render.scrollObserver.observe(el);
  }

  static stats({score: [r, count] = ['N/A'], users, favs, url} = {}) {
    const quarter = r > 0 && Math.max(1, Math.min(4, 1 + (r - .001) / 2.5 >> 0));
    $create(Util.externalLink({
      $mal: '',
      id: ID.SCORE,
      parent: $('.media-rating'),
      href: url,
      title: count && `Scored by ${Util.num2str(count)} users` || '',
      textContent: (r > 0 ? Util.num2pct(r / 10) : r) + ' on MAL',
      className: 'media-community-rating' + (quarter ? ' percent-quarter-' + quarter : ''),
      $style: null,
    }));
    $create({
      tag: 'span',
      id: ID.USERS,
      after: $id(ID.SCORE),
      textContent: Util.num2str(users),
      $style: users ? null : 'opacity:0; display:none',
    });
    $create({
      tag: 'span',
      id: ID.FAVS,
      after: $id(ID.USERS),
      textContent: Util.num2str(favs),
      $style: favs ? null : 'opacity:0; display:none',
    });
  }

  static characters({chars = [], url, type, slug, path}) {
    $remove('.media--main-characters');
    if (App.renderedPath !== path) {
      // hide the previous pics of chars and voice actors
      // to prevent them from flashing briefly during fade-in/hover
      const el = $id(ID.CHARS);
      if (el) {
        const hidden = el.style.opacity === '0';
        for (const img of el.getElementsByTagName('img')) {
          if (hidden || img.src.includes('voiceactors'))
            img.removeAttribute('src');
        }
      }
    }
    const numChars = chars.length;
    let numCastPics = 0;
    let numCast = 0;
    for (const [/*type*/, [char, /*charId*/, charImg]] of chars) {
      numCast += char ? 1 : 0;
      numCastPics += charImg ? 1 : 0;
    }
    const moreCharsPossible = numCast === MAL_CAST_LIMIT ||
                              numChars - numCast === MAL_STAFF_LIMIT;
    // prefer chars with pics, except for main chars who stay in place
    chars = chars
      .map((c, i) => [c, i])
      .sort((
        [[typeA, [, , imgA]], i],
        [[typeB, [, , imgB]], j]
      ) =>
        (typeB === 'Main') - (typeA === 'Main') ||
        !!imgB - !!imgA ||
        i - j)
      .map(([c]) => c);

    $create({
      tag: 'section',
      $mal: type,
      id: ID.CHARS,
      after: $('.media--information'),
      className: 'media--related',
      $style: numChars ? null : 'opacity:0; display:none',
      children: numChars && [{
        tag: 'h5',
        children: [
          'Characters ',
          Util.externalLink({
            href: `${url}/${slug}/characters`,
            textContent: 'on MAL',
            $mal: 'chars-all',
          }),
        ],
      }, {
        tag: 'ul',
        $mal: numCastPics <= 6 ? 'one-row' : '',
        onmouseover: Render._charsHovered,
        onmouseout: Render._charsHovered,
        children: chars.map(Render.char),
      }, moreCharsPossible && {
        tag: 'a',
        href: `/${App.data.path}/characters`,
        className: 'more-link',
        textContent: 'View all characters',
      }],
    });
  }

  static char([type, [char, charId, charImg], [va, vaId, vaImg] = []]) {
    return {
      tag: 'li',
      children: [
        char && {
          tag: 'div',
          $mal: 'char',
          children: [
            Util.externalLink({
              $mal: 'char',
              href: `${MAL_URL}character/${charId}`,
              children: [
                charImg && {
                  tag: 'div',
                  children: [{
                    tag: 'img',
                    [$LAZY_ATTR]: `${MAL_CDN_URL}images/characters/${charImg}${MAL_IMG_EXT}`,
                  }],
                }, {
                  tag: 'span',
                  children: Render.malName(char),
                },
              ],
            }),
            type !== 'Supporting' && {
              tag: 'small',
              textContent: type,
            },
          ],
        },

        va && {
          tag: 'div',
          $mal: 'people',
          children: [
            Util.externalLink({
              $mal: 'people',
              href: `${MAL_URL}people/${vaId}`,
              children: [
                vaImg && {
                  tag: 'div',
                  children: [{
                    tag: 'img',
                    [$LAZY_ATTR]: `${MAL_CDN_URL}images/voiceactors/${vaImg}.jpg`,
                  }],
                }, {
                  tag: 'span',
                  children: Render.malName(va),
                },
              ],
            }),
            !char && {
              tag: 'small',
              textContent: type,
            },
          ],
        },
      ],
    };
  }

  static recommendations({recs, url, slug}) {
    $create({
      tag: 'section',
      id: ID.RECS,
      before: $('.media--reactions'),
      $style: recs ? null : 'opacity:0; display:none',
      children: recs && [{
        tag: 'h5',
        children: [
          'Recommendations ',
          Util.externalLink({
            $mal: 'recs-all',
            href: `${url}/${slug}/userrecs`,
            className: KITSU_GRAY_LINK_CLASS,
            textContent: 'on MAL',
          }),
        ],
      }, {
        tag: 'ul',
        onmouseover: Render.recommendationsHidden,
        onmouseenter: Render.onRecsHovered,
        onmouseleave: Render.onRecsHovered,
        children: recs.slice(0, KITSU_RECS_PER_ROW).map(Render.rec, arguments[0]),
      }],
    });
  }

  static recommendationsHidden() {
    this.onmouseover = null;
    const added = $create({tag: 'div'},
      App.data.recs
        .slice(KITSU_RECS_PER_ROW)
        .map(Render.rec, App.data));
    Render.observe(added);
    if (App.data.recs.length === MAL_RECS_LIMIT) {
      $create({
        tag: 'li',
        $mal: 'more',
        parent: added,
        className: 'media-summary',
        children: {
          tag: 'a',
          href: '#',
          onclick: Render.recommendationsMore,
          className: 'more-link',
          textContent: 'Load more recommendations',
        },
      });
    }
    $(`#${ID.RECS} ul`).append(...added.children);
  }

  static onRecsHovered(e) {
    clearTimeout(Render.recsHoveredTimer);
    const on = e.type === 'mouseenter';
    const delay = KITSU_RECS_HOVER_DELAY + (on ? 0 : KITSU_RECS_HOVER_DURATION * .9);
    Render.recsHoveredTimer = setTimeout(() => this.classList.toggle('hovered', on), delay);
  }

  static async recommendationsMore(e) {
    e.preventDefault();
    Object.assign(this, {
      onclick: null,
      textContent: 'Loading...',
      style: 'pointer-events:none; cursor: wait',
    });
    const block = $id(ID.RECS);
    block.style.cursor = 'progress';
    const newRecs = await Mal.scavengeRecs($('a', block).href);
    const added = $create({tag: 'div'}, newRecs.map(Render.rec, App.data));
    Render.observe(added);
    $('ul', block).append(...added.children);
    block.style.cursor = '';
    setTimeout(() =>
      this.parentNode.remove());
  }

  static rec([name, id, img, count]) {
    const {type, TID} = this;
    return {
      tag: 'li',
      $mal: count ? '' : 'auto-rec',
      onclick: Render._kitsuLinkPreclicked,
      onauxclick: Render._kitsuLinkPreclicked,
      onmousedown: Render._kitsuLinkPreclicked,
      onmouseup: Render._kitsuLinkPreclicked,
      onmouseover: Render.kitsuLink,
      children: [{
        tag: 'small',
        children: [
          !count ?
            'auto-rec' :
            Util.externalLink({
              $mal: 'rec',
              href: `${MAL_URL}recommendations/${type}/${id}-${TID.slice(1)}`,
              className: KITSU_GRAY_LINK_CLASS,
              textContent: `${count} rec${count > 1 ? 's' : ''}`,
            }),
        ],
      }, Util.externalLink({
        $mal: 'title',
        title: name,
        href: `${MAL_URL}${type}/${id}`,
        className: KITSU_GRAY_LINK_CLASS,
        children: [{
          tag: 'span',
          textContent: name,
        }],
      }), {
        tag: 'div',
        [$LAZY_ATTR]: `${MAL_CDN_URL}images/${type}/${img}${MAL_IMG_EXT}`,
      }],
    };
  }

  static async kitsuLink() {
    this.onmouseover = null;

    const image = $('div', this);
    const malLink = $('a[mal="title"]', this);
    const typeId = MalTypeId.fromUrl(malLink.href);
    const TID = MalTypeId.toTID(typeId);
    const [type, id] = typeId.split('/');

    const {path = ''} = await Cache.idb.get(TID, 'TID') || {};
    let slug = path.split('/')[1];

    if (!slug) {
      const mappings = await API.mappings({
        filter: {
          externalId: id,
          externalSite: 'myanimelist/' + type,
        },
      });
      const entry = mappings.data[0];
      if (entry) {
        const mappingId = entry.id;
        const mapping = await API.mappings[mappingId].item({
          fields: {
            [type]: 'slug',
          },
        });
        slug = mapping.data.attributes.slug;
        Cache.write(type, slug, {TID});
      }
    }

    if (slug) {
      $create({
        tag: 'a',
        href: `/${type}/${slug}`,
        className: KITSU_GRAY_LINK_CLASS,
        children: image,
        parent: this,
      });
    } else {
      malLink.appendChild(image);
    }

    if (!this.onmousedown && this.onmouseup) {
      await new Promise(resolve => addEventListener('mouseup', resolve, {once: true}));
      await Util.nextTick();
    }
    this.onclick = null;
    this.onauxclick = null;
    this.onmousedown = null;
    this.onmouseup = null;
  }

  static malName(str) {
    const i = str.indexOf(', ');
    // <wbr> wraps even with "white-space:nowrap" so it's better than unicode zero-width space
    if (i < 0) {
      const words = str.split(/\s+/);
      return words.length <= 2
        ? words.join(' ')
        : words[0] + ' ' + words.slice(1).join('\xA0');
    } else {
      return [
        str.slice(i + 2).replace(/\s+/, '\xA0') + ' ',
        {tag: 'wbr'},
        str.slice(0, i).replace(/\s+/, '\xA0'),
      ];
    }
  }

  static _charsHovered() {
    const hovering = this.matches(':hover');
    if (hovering !== this.hasAttribute('hovered')) {
      clearTimeout(this[ID.me]);
      this[ID.me] = setTimeout(Render._charsHoveredTimer, hovering ? 250 : 1000, this);
    }
  }

  static _charsHoveredTimer(el) {
    $attributize(el, 'hovered', el.matches(':hover') ? '' : null);
  }

  static async _kitsuLinkPreclicked(e) {
    if (!e.target.style.backgroundImage)
      return;
    if (e.type === 'mousedown') {
      this.onmousedown = null;
      return;
    }
    if (e.type === 'mouseup') {
      this.onmouseup = null;
      await Util.nextTick();
      if (!this.onclick)
        return;
    }
    this.onclick = null;
    this.onauxclick = null;
    if (e.altKey || e.metaKey || e.button > 1)
      return;

    let link = e.target.closest('a');
    if (!link) {
      const winner = await Promise.race([
        Mutant.gotMoved(e.target),
        Mutant.gotPath(),
      ]);
      if (winner !== true)
        return;
    }

    const {button: btn, ctrlKey: c, shiftKey: s} = e;
    link = e.target.closest('a');
    if (!btn && !c) {
      link.dispatchEvent(new MouseEvent('click', e));
      if (!s)
        App.onUrlChange(link.pathname);
    } else {
      GM_openInTab(link.href, {
        active: btn === 0 && c && s,
        insert: true,
        setParent: true,
      });
    }
  }

  static _lazyLoad(entries) {
    for (const e of entries) {
      if (e.isIntersecting) {
        const el = e.target;
        let url = el.getAttribute(LAZY_ATTR);

        if (el instanceof HTMLImageElement) {
          if (el.src !== url)
            el.src = url;
        } else {
          url = `url(${url})`;
          if (el.style.backgroundImage !== url)
            el.style.backgroundImage = url;
        }

        el.removeAttribute(LAZY_ATTR);
        Render.scrollObserver.unobserve(el);
      }
    }
  }
}


class Util {

  static str2num(str) {
    return str && Number(str.replace(/,/g, '')) || undefined;
  }

  static str2gist(str) {
    return str.replace(/\W+/g, ' ').trim().toLowerCase();
  }

  static num2str(num) {
    return num && num.toLocaleString() || '';
  }

  static num2pct(n, numDecimals = 2) {
    return (n * 100).toFixed(numDecimals).replace(/\.?0+$/, '') + '%';
  }

  static decodeHtml(str) {
    if (str.includes('&#')) {
      str = str.replace(/&#(x?)([\da-f]);/gi, (_, hex, code) =>
        String.fromCharCode(parseInt(code, hex ? 16 : 10)));
    }
    if (!str.includes('&') ||
        !/&\w+;/.test(str))
      return str;
    if (!Mal.parser)
      Mal.parser = new DOMParser();
    const doc = Mal.parser.parseFromString(str, 'text/html');
    return doc.body.firstChild.textContent;
  }

  static parseJson(str) {
    try {
      return JSON.parse(str);
    } catch (e) {}
  }

  static nextTick() {
    return new Promise(setTimeout);
  }

  static fetchDoc(url) {
    return new Promise(resolve => {
      GM_xmlhttpRequest({
        url,
        method: 'GET',
        onload(r) {
          const doc = new DOMParser().parseFromString(r.response, 'text/html');
          resolve(doc);
        },
      });
    });
  }

  static fetchJson(url) {
    return new Promise(resolve => {
      GM_xmlhttpRequest({
        url,
        method: 'GET',
        responseType: 'json',
        onload(r) {
          resolve(r.response);
        },
      });
    });
  }

  static externalLink(
    props,
    children = props.children || props.textContent || []
  ) {
    props.tag = 'a';
    props.target = '_blank';
    props.rel = 'noopener noreferrer';
    props.children = Array.isArray(children) ? children : [children];
    props.children.push(EXT_LINK);
    delete props.textContent;
    return props;
  }
}


/** @return {HTMLElement} */
function $(selector, node = document) {
  return node.querySelector(selector);
}

/** @return {HTMLElement} */
function $id(id, doc = document) {
  return doc.getElementById(id);
}

/** @return {HTMLElement[]} */
function $$(selector, node = document) {
  return [...node.querySelectorAll(selector)];
}

/** @return {String} */
function $text(selector, node = document) {
  const el = typeof selector === 'string' ?
    node.querySelector(selector) :
    selector;
  return el ? el.textContent.trim() : '';
}

/** @return {HTMLElement} */
function $create(props,
                 children = props.children || [],
                 referenceNode = props.id && $id(props.id)) {
  let el;
  let childIndex = -1;
  const hasOwnProperty = Object.hasOwnProperty;
  const toAppend = [];

  if (!Array.isArray(children))
    children = [children];

  for (
    let index = 0, node, info = props, ref = referenceNode;
    index <= children.length;
    info = children[index], ref = el.childNodes[childIndex], index++
  ) {

    if (!info)
      continue;

    childIndex++;

    let ns;
    const isNode = info instanceof Node;

    if (isNode) {
      node = info;
    } else {
      let {tag} = info;
      const i = tag ? tag.indexOf(':') : -1;
      if (i >= 0) {
        ns = tag.slice(0, i);
        tag = tag.slice(i + 1);
      }
      node = ref && ref.localName === (tag && tag.toLowerCase()) && ref || (
        !tag ?
          document.createTextNode(info) :
          /^SVG$/i.test(ns) ?
            document.createElementNS('http://www.w3.org/2000/svg', tag) :
            document.createElement(tag));
    }

    const type = node.nodeType;

    if (index === 0)
      el = node;
    else if (!ref)
      toAppend.push(node);
    else if (isNode || ref.localName !== node.localName)
      ref.parentNode.replaceChild(node, ref);

    if (isNode)
      continue;

    if (type === Node.TEXT_NODE) {
      if (ref && ref.nodeValue !== info)
        ref.nodeValue = info;
      continue;
    }

    if (index > 0 && info.children) {
      $create(info, undefined, node);
      continue;
    }

    for (const k in info) {
      if (!hasOwnProperty.call(info, k) ||
          k === 'tag' ||
          k === 'children' ||
          k === 'parent' ||
          k === 'after' ||
          k === 'before')
        continue;
      const v = info[k];
      const attr = k.startsWith('$') ? k.slice(1) : null;
      if (attr || ns)
        $attributize(node, attr || k, v);
      else if (node[k] !== v)
        node[k] = v;
    }
  }

  if (toAppend.length)
    el.append(...toAppend);
  else {
    const numExpected = childIndex + (props.textContent ? 1 : 0);
    const numZombies = el.childNodes.length - numExpected;
    for (let i = 0; i < numZombies; i++)
      el.lastChild.remove();
  }

  if (props.parent &&
      props.parent !== el.parentNode)
    props.parent.appendChild(el);

  if (props.before &&
      props.before !== el.nextSibling)
    props.before.insertAdjacentElement('beforeBegin', el);

  if (props.after &&
      props.after !== el.previousSibling)
    props.after.insertAdjacentElement('afterEnd', el);

  return el;
}

function $remove(selectorOrNode, base) {
  const el = selectorOrNode instanceof Node ?
    selectorOrNode :
    $(selectorOrNode, base);
  if (el)
    el.remove();
}

function $attributize(node, attr, value) {
  if (value === null)
    node.removeAttribute(attr);
  else if (value !== node.getAttribute(attr))
    node.setAttribute(attr, value);
}

App.init();