CMS links

Provides links from your site to Contentful.

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

您需要先安装一个扩展,例如 篡改猴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.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();