CMS links

Provides links from your site to Contentful.

2022-06-23 기준 버전입니다. 최신 버전을 확인하세요.

이 스크립트를 설치하려면 Tampermonkey, Greasemonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

You will need to install an extension such as Tampermonkey to install this script.

이 스크립트를 설치하려면 Tampermonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Userscripts와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 유저 스크립트 관리자 확장 프로그램이 필요합니다.

(이미 유저 스크립트 관리자가 설치되어 있습니다. 설치를 진행합니다!)

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

(이미 유저 스타일 관리자가 설치되어 있습니다. 설치를 진행합니다!)

// ==UserScript==
// @name        CMS links
// @namespace   urn://com.typeform.cms-links
// @include     *
// @exclude     none
// @version     1.0.2
// @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 = [];
let siteExcluded = false;
let siteForceIncluded = false;

// 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: 2px;
    font-size: .8rem;
  }
}

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

@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.4rem;
  font-weight: bold;
  font-size: .8rem;
  color: #1e1e1e;
  text-decoration: none;
  padding: 2px;
  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}>div {
  border-radius: 0.32rem;
  padding: .4rem .8rem;
  background: white;
  transition: .2s padding, .2s border-radius;
  animation: .8s ${CLASSNAME_NAMESPACE}-link-inner-appear;
}

.${CONTENTFUL_LINK_CLASSNAME}>div:hover {
  background: rgba(255,255,255,0.8);
}

.${CONTENTFUL_LINK_CLASSNAME}>div:active {
  background: rgba(255,255,255,0.2);
}
  `);
};

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);
  const documentWidth = document.documentElement.clientWidth;
  linkElement.style.left = `${left}px`;
  linkElement.style.top = `${top}px`;
  linkElement.style.display = left >= documentWidth ? "none" : "initial";
};

const addLink = (entry, element) => {
  linkIndex += 1;
  const newElement = document.createElement("a");
  // TODO: Select correct URL schema to use if multiple available
  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) {
    matchingElements.push({
      entry: { id: mainEntryId },
      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) {
      return value;
    }

    if (
      value.startsWith("//images.ctfassets.net/") ||
      value.startsWith("https://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
      );
  // TODO: How should we actually decide what is entry vs asset?
  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 = () => {
  const rawData = document.getElementById("__NEXT_DATA__")?.innerText;
  if (!rawData) {
    return {};
  }
  return JSON.parse(rawData);
};

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

const setContentfulCms = (contentfulSpaceId) => {
  cmsName = "Contentful";
  entryUrlSchema = CONTENTFUL_ENTRY_URL_FORMAT.replace(
    "{{space}}",
    contentfulSpaceId
  );
  assetUrlSchema = CONTENTFUL_ASSET_URL_FORMAT.replace(
    "{{space}}",
    contentfulSpaceId
  );
};

const getUrlSchemaFromProps = () => {
  const data = getPropData();
  const contentfulSpaceId = findCtflSpaceIdInData(data);
  if (contentfulSpaceId) {
    setContentfulCms(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 fetchAndShowLinks = async () => {
  getEntryDataFromProps();
  findElementsMatchingData();
  makeLinks();
  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 parseCommaSeparatedStrings = (value) =>
  (value || "")
    .split(" ")
    .join("")
    .split(",")
    .filter((x) => !!x);

const readExtensionExcludeOptions = async () => {
  if (typeof chrome === "undefined") {
    return;
  }

  let options = await new Promise((res) => {
    chrome.storage.sync.get(["excludeSites"], (data) => res(data));
  });

  const excludeSitesList = parseCommaSeparatedStrings(options.excludeSites);

  if (
    excludeSitesList.some((domain) => document.location.origin.endsWith(domain))
  ) {
    siteExcluded = true;
  }
};
const readExtensionForceIncludeOptions = async () => {
  if (typeof chrome === "undefined") {
    return;
  }

  let options = await new Promise((res) => {
    chrome.storage.sync.get(
      ["includeSites", "includeSitesContentfulSpaceID"],
      (data) => res(data)
    );
  });

  const includeSitesList = parseCommaSeparatedStrings(options.includeSites);
  const contentfulSpaceID = (
    options.includeSitesContentfulSpaceID || ""
  ).trim();

  if (
    contentfulSpaceID &&
    includeSitesList.some((domain) => document.location.origin.endsWith(domain))
  ) {
    setContentfulCms(contentfulSpaceID);
    siteForceIncluded = true;
  }
};

const work = async () => {
  try {
    await readExtensionExcludeOptions();
    if (siteExcluded) {
      console.log(
        `This site has been excluded in CMS links options. CMS links will get some rest for now 💤`
      );
      return;
    }
    getUrlSchemaFromProps();
    if (cmsName) {
      injectStyles();
      addButton();
      console.log(
        `Found a ${cmsName} asset. CMS links is now configured to take you to ${cmsName} 🎉`
      );
    } else {
      await readExtensionForceIncludeOptions();
      if (siteForceIncluded) {
        console.log(
          `This site has been included in CMS links options. CMS links is now configured to take you to ${cmsName} 🎉`
        );
        injectStyles();
        addButton();
        return;
      }
      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();