CMS links

Provides links from your site to Contentful.

Per 20-06-2022. Zie de nieuwste versie.

Voor het installeren van scripts heb je een extensie nodig, zoals Tampermonkey, Greasemonkey of Violentmonkey.

Voor het installeren van scripts heb je een extensie nodig, zoals {tampermonkey_link:Tampermonkey}.

Voor het installeren van scripts heb je een extensie nodig, zoals Tampermonkey of Violentmonkey.

Voor het installeren van scripts heb je een extensie nodig, zoals Tampermonkey of Userscripts.

Voor het installeren van scripts heb je een extensie nodig, zoals {tampermonkey_link:Tampermonkey}.

Voor het installeren van scripts heb je een gebruikersscriptbeheerder nodig.

(Ik heb al een user script manager, laat me het downloaden!)

Voor het installeren van gebruikersstijlen heb je een extensie nodig, zoals {stylus_link:Stylus}.

Voor het installeren van gebruikersstijlen heb je een extensie nodig, zoals {stylus_link:Stylus}.

Voor het installeren van gebruikersstijlen heb je een extensie nodig, zoals {stylus_link:Stylus}.

Voor het installeren van gebruikersstijlen heb je een gebruikersstijlbeheerder nodig.

Voor het installeren van gebruikersstijlen heb je een gebruikersstijlbeheerder nodig.

Voor het installeren van gebruikersstijlen heb je een gebruikersstijlbeheerder nodig.

(Ik heb al een beheerder - laat me doorgaan met de installatie!)

// ==UserScript==
// @name        CMS links
// @namespace   urn://com.typeform.cms-links
// @include     *
// @exclude     none
// @version     1.0.1
// @description:en	Provides links from your site to Contentful.
// @grant    		none
// @description Provides links from your site to Contentful.
// @license MIT
// ==/UserScript==

const CLASSNAME_NAMESPACE = "cms-links";
const CONTENTFUL_LINK_CLASSNAME = `${CLASSNAME_NAMESPACE}__contentful-link`;
const CONTENTFUL_BUTTON_CLASSNAME = `${CLASSNAME_NAMESPACE}__activation-button`;
const MIN_POSITION = { left: 0, top: 85 };
const CONTENT_TAG_NAMES = [
  "h1",
  "h2",
  "h3",
  "h4",
  "h5",
  "h6",
  "p",
  "span",
  "img",
];
const PROPERTIES_OF_INTEREST = [
  "title",
  "name",
  "description",
  "headline",
  "quote",
  "message",
  "alt",
];
let CONTENTFUL_ENTRY_URL_FORMAT =
  "https://app.contentful.com/spaces/{{space}}/entries/{{id}}";
let CONTENTFUL_ASSET_URL_FORMAT =
  "https://app.contentful.com/spaces/{{space}}/assets/{{id}}";
let cmsName = null;
let entryUrlSchema = null;
let assetUrlSchema = null;
let entries = [];
let mainEntryId = null;
let matchingElements = [];

// const pause = (duration) =>
//   new Promise((res) => setTimeout(() => res(), duration));

// Leaving this here for now as it's a really useful util:
// const waitFor = async (getterFunction, options = {}, numberOfTries = 0) => {
//   const { wait = 200, maxRetries = 150 } = options;
//   const { conditionMet, output } = getterFunction();
//   if (conditionMet) {
//     return output;
//   }
//   if (numberOfTries > maxRetries) {
//     return null;
//   }
//   await pause(wait);
//   return await waitFor(getterFunction, options, numberOfTries + 1);
// };

const objectTraverseModify = (obj, objModifier, valueModifier) => {
  const objClone = JSON.parse(JSON.stringify(obj));

  if (Array.isArray(objClone)) {
    return objClone.map((item) =>
      objectTraverseModify(item, objModifier, valueModifier)
    );
  }

  if (objClone instanceof Object) {
    const newValue = objModifier ? objModifier(objClone) : objClone;
    Object.keys(newValue).forEach((key) => {
      newValue[key] = objectTraverseModify(
        newValue[key],
        objModifier,
        valueModifier
      );
    });
    return newValue;
  }

  // is a simple value
  return valueModifier ? valueModifier(objClone) : objClone;
};

const insertCSS = (text) => {
  let styleElement = document.getElementById("typeform-contentful-styles");
  if (styleElement) {
    styleElement.innerText += `\n${text}`;
    return;
  }

  styleElement = document.createElement("style");
  styleElement.id = "typeform-contentful-styles";
  styleElement.type = "text/css";
  styleElement.innerText = text;
  document.head.appendChild(styleElement);
};

const injectStyles = () => {
  insertCSS(`
@keyframes ${CLASSNAME_NAMESPACE}-link-appear {
  from {
    padding: 0px;
    font-size: 0rem;
  }
  to {
    padding: 1px;
    font-size: .2rem;
  }
}

@keyframes ${CLASSNAME_NAMESPACE}-link-inner-appear {
  from {
    padding: 0rem 0rem;
  }
  to {
    padding: .2rem .4rem;
  }
}

@keyframes ${CLASSNAME_NAMESPACE}-button-appear {
  from { top: -2rem; }
  to { top: 0rem; }
}

@keyframes ${CLASSNAME_NAMESPACE}-button-disappear {
  from { top: 0rem; }
  to { top: -2rem; }
}

.${CONTENTFUL_BUTTON_CLASSNAME} {
  position: fixed;
  border: none;
  left: 1rem;
  top: 0rem;
  z-index: 100000;
  border-radius: 0 0 0.4rem 0.4rem;
  padding: 1rem 2rem;
  font-weight: bold;
  color: #1e1e1e;
  background-color: white;
  overflow: hidden;
  transform: scale(.5) translate(0, -50%);
  transition: .2s transform, .2s border-radius;
  animation: .8s link-appear;
  cursor: pointer;
}
@supports (backdrop-filter: blur(1rem)) or (-webkit-backdrop-filter: blur(1rem)) {
  .${CONTENTFUL_BUTTON_CLASSNAME}-blur {
    background-color: rgba(255,255,255,0.2);
    backdrop-filter: saturate(180%) blur(20px);
    -webkit-backdrop-filter: saturate(180%) blur(20px);
  }
}
.${CONTENTFUL_BUTTON_CLASSNAME}:hover {
  transform: scale(1) translate(0, 0) !important;
  border-radius: 0 0 0.2rem 0.2rem;
}

.${CONTENTFUL_LINK_CLASSNAME}--hidden {
  display: none;
}

.${CONTENTFUL_LINK_CLASSNAME} {
  position: absolute;
  border-radius: 0.2rem;
  font-weight: bold;
  font-size: .2rem;
  color: #1e1e1e;
  text-decoration: none;
  padding: 1px;
  background: linear-gradient(0.4turn, #4FACD6, #ECE616, #E24A4E);
  overflow: hidden;
  transition: .2s padding, .2s font-size, .2s border-radius, .2s top, .2s left;
  animation: .8s ${CLASSNAME_NAMESPACE}-link-appear;
}


.${CONTENTFUL_LINK_CLASSNAME}:hover {
  border-radius: 0.4rem;
  padding: 2px;
  font-size: .8rem;
}

.${CONTENTFUL_LINK_CLASSNAME}>div {
  border-radius: 0.13rem;
  padding: .2rem .4rem;
  background: white;
  transition: .2s padding, .2s border-radius;
  animation: .8s ${CLASSNAME_NAMESPACE}-link-inner-appear;
}

.${CONTENTFUL_LINK_CLASSNAME}:hover>div {
  border-radius: 0.32rem;
  padding: .4rem .8rem;
}
  `);
};

let linkIndex = -1;

const getRelativeBoundingRect = (element) => {
  const elementRect = element.getBoundingClientRect();
  const bodyRect = document.body.getBoundingClientRect();
  return {
    ...elementRect,
    top: elementRect.top - bodyRect.top,
    left: elementRect.left - bodyRect.left,
  };
};

const adjustElementPosition = (matchingElement, linkElement) => {
  const boundingRect = getRelativeBoundingRect(matchingElement);
  const left = Math.max(MIN_POSITION.left, boundingRect.left);
  const top = Math.max(MIN_POSITION.top, boundingRect.top);
  linkElement.style.left = `${left}px`;
  linkElement.style.top = `${top}px`;
};

const addLink = (entry, element) => {
  linkIndex += 1;
  const newElement = document.createElement("a");
  newElement.href = entry.urlSchema.replaceAll("{{id}}", entry.id);
  newElement.target = "_blank";
  newElement.rel = "noopener";
  newElement.className = `${CONTENTFUL_LINK_CLASSNAME}--hidden`;
  newElement.setAttribute("data-id", entry.id);
  const newElementInner = document.createElement("div");
  newElementInner.innerText = `View in ${cmsName}`;
  newElement.appendChild(newElementInner);
  adjustElementPosition(element, newElement);
  setTimeout(() => {
    newElement.className = `${CONTENTFUL_LINK_CLASSNAME}`;
  }, Math.min(3000, linkIndex * 200));
  document.body.appendChild(newElement);
};

const updateLink = (entry, element) => {
  const matchingLinkElements = getLinks().filter(
    (link) => link.getAttribute("data-id") === entry.id
  );
  if (matchingLinkElements.length < 1) {
    return;
  }
  const linkElement = matchingLinkElements[0];

  adjustElementPosition(element, linkElement);
};

const getLinks = () => {
  return [...document.getElementsByTagName("A")];
};

const furthestDescendantWithText = (element, text) => {
  const matchingChildElements = [...element.childNodes].filter(
    (e) => e.innerText === text
  );
  if (matchingChildElements.length === 0) {
    return element;
  }
  return furthestDescendantWithText(matchingChildElements[0], text);
};

const findElementsMatchingData = () => {
  if (mainEntryId) {
    // TODO: Should we check that a entry with that ID isn't already stored?
    matchingElements.push({
      entry: { id: mainEntryId, urlSchema: entryUrlSchema },
      element: document.body,
    });
  }

  const allElements = CONTENT_TAG_NAMES.flatMap((tagName) => [
    ...document.body.getElementsByTagName(tagName),
  ]);
  allElements.forEach((element) => {
    const innerText = element.innerText;
    const altText = element.getAttribute("alt");
    entries.forEach((entry) => {
      // Filter out `null` values as these will give a false-positive:
      entry.texts
        .filter((t) => !!t)
        .forEach((text) => {
          // Don't create multiple links for one entry:
          if (matchingElements.some((match) => match.entry.id === entry.id))
            return;
          if ([innerText, altText].includes(text)) {
            matchingElements.push({
              entry,
              element: furthestDescendantWithText(element, text),
            });
          }
        });
    });
  });
};

const makeLinks = () => {
  matchingElements.forEach(({ entry, element }) => {
    if (!getLinks().some((link) => link.getAttribute("data-id") === entry.id)) {
      addLink(entry, element);
    }
  });
};

const updateLinks = () => {
  matchingElements.forEach(({ entry, element }) => {
    updateLink(entry, element);
  });
};

const findCtflSpaceIdInData = (data) => {
  let spaceId = null;
  objectTraverseModify(data, null, (value) => {
    if (
      value &&
      value.startsWith &&
      value.startsWith("//images.ctfassets.net/")
    ) {
      spaceId = value.split("/")[3];
    }
    return value;
  });
  return spaceId;
};

const extractIdAndTextsFromObject = (obj) => {
  const hasSysAndFields = !!obj.sys?.id && !!obj.fields;
  const id = hasSysAndFields ? obj.sys.id : obj.id;
  const entryTexts = hasSysAndFields
    ? PROPERTIES_OF_INTEREST.map((property) => obj.fields[property]).filter(
        (s) => !!s
      )
    : PROPERTIES_OF_INTEREST.map((property) => obj[property]).filter(
        (s) => !!s
      );
  let urlSchema = entryUrlSchema;
  if (obj.fields?.file?.url) {
    urlSchema = assetUrlSchema;
  }
  return { id, texts: entryTexts, urlSchema };
};

const findEntries = (data) => {
  objectTraverseModify(
    data,
    (obj) => {
      if (!obj) return obj;
      const { id, texts, urlSchema } = extractIdAndTextsFromObject(obj);
      if (id && texts.length) {
        entries.push({
          id,
          texts,
          urlSchema,
        });
      }
      return obj;
    },
    null
  );
};

const getPropData = (doc) => {
  const rawData = doc.getElementById("__NEXT_DATA__")?.innerText;
  if (!rawData) {
    return {};
  }
  return JSON.parse(rawData);
};

const getEntryDataFromProps = (doc) => {
  const data = getPropData(doc);
  mainEntryId = data.mainEntryId;

  findEntries(data);
};

const getUrlSchema = () => {
  const data = getPropData(document);
  const contentfulSpaceId = findCtflSpaceIdInData(data);
  if (contentfulSpaceId) {
    cmsName = "Contentful";
    entryUrlSchema = CONTENTFUL_ENTRY_URL_FORMAT.replace(
      "{{space}}",
      contentfulSpaceId
    );
    assetUrlSchema = CONTENTFUL_ASSET_URL_FORMAT.replace(
      "{{space}}",
      contentfulSpaceId
    );
  }
  const pageCategoryUrl = data.props?.pageProps?.category?.url;
  const isZendesk =
    pageCategoryUrl && pageCategoryUrl.includes("zendesk.com/api/");
  if (isZendesk) {
    const pageCategoryUrlParts = pageCategoryUrl.split("/");
    const locale = pageCategoryUrlParts[6];
    cmsName = "Zendesk";
    entryUrlSchema = `${pageCategoryUrlParts
      .slice(0, 3)
      .join("/")}/knowledge/articles/{{id}}/${locale}`;
    assetUrlSchema = entryUrlSchema;
  }
};

const fetchAndShowLinks2 = async () => {
  const newPageUrl = `${document.location.origin}${
    document.location.pathname
  }?${["skip-cdn", "cms-links-data"].join("&")}`;

  const newPageContent = await fetch(newPageUrl).then((response) => {
    return response.text();
  });
  var parser = new DOMParser();
  var newPageDocument = parser.parseFromString(newPageContent, "text/html");
  getEntryDataFromProps(newPageDocument);
  findElementsMatchingData();
  makeLinks();
};

const fetchAndShowLinks = async () => {
  getEntryDataFromProps(document);
  findElementsMatchingData();
  makeLinks();
  fetchAndShowLinks2();
  setInterval(updateLinks, 1000);
};

const addButton = () => {
  const newElement = document.createElement("button");
  newElement.className = `${CONTENTFUL_BUTTON_CLASSNAME} ${CONTENTFUL_BUTTON_CLASSNAME}-blur`;
  newElement.innerText = `Show ${cmsName} links`;
  newElement.id = `cms-button`;
  newElement.onclick = () => {
    newElement.style.animation = `.8s ${CLASSNAME_NAMESPACE}-button-disappear`;
    newElement.style.top = `-2rem`;
    fetchAndShowLinks();
  };

  document.body.appendChild(newElement);
};

const work = async () => {
  try {
    injectStyles();
    getUrlSchema();
    if (cmsName) {
      addButton();
      console.log(
        `Found a ${cmsName} asset. CMS links is now configured to take you to ${cmsName} 🎉`
      );
    } else {
      console.log(
        `No CMS assets identified. CMS links will get some rest for now 💤`
      );
    }
  } catch (e) {
    // eslint-disable-next-line no-console
    console.log(`CMS links error:`, e);
  }
};

work();