CMS links

Provides links from your site to Contentful.

当前为 2022-06-20 提交的版本,查看 最新版本

您需要先安装一个扩展,例如 篡改猴Greasemonkey暴力猴,之后才能安装此脚本。

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

您需要先安装一个扩展,例如 篡改猴暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴Userscripts ,之后才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 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();