CMS links

Provides links from your site to Contentful.

目前為 2022-06-20 提交的版本,檢視 最新版本

您需要先安裝使用者腳本管理器擴展,如 TampermonkeyGreasemonkeyViolentmonkey 之後才能安裝該腳本。

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

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyUserscripts 後才能安裝該腳本。

你需要先安裝一款使用者腳本管理器擴展,比如 Tampermonkey,才能安裝此腳本

您需要先安裝使用者腳本管理器擴充功能後才能安裝該腳本。

(我已經安裝了使用者腳本管理器,讓我安裝!)

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

(我已經安裝了使用者樣式管理器,讓我安裝!)

// ==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();