CMS links

Provides links from your site to Contentful.

// ==UserScript==
// @name        CMS links
// @namespace   urn://com.typeform.cms-links
// @include     *
// @exclude     none
// @version     1.0.4
// @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',
  'div',
  '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 contentfulEntryUrlSchema = null
let contentfulAssetUrlSchema = null
let zendeskUrlSchema = null
let entries = []
let matchingElements = []
let siteExcluded = false
let siteForceIncluded = false

const cmsDataAvailable = () =>
  contentfulEntryUrlSchema || contentfulAssetUrlSchema || zendeskUrlSchema

const cmsNames = () => {
  const cmsNameList = []
  if (contentfulEntryUrlSchema || contentfulAssetUrlSchema) {
    cmsNameList.push(`Contentful`)
  }
  if (zendeskUrlSchema) {
    cmsNameList.push(`Zendesk`)
  }

  if (cmsNameList.length === 0) {
    return 'no CMS'
  }
  return cmsNameList.join(' and ')
}

// 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;
  box-shadow: rgba(0,0,0, .1) .1rem .1rem 1rem .4rem;
}
@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')
  newElement.href = entry.urlSchema.replaceAll('{{id}}', entry.id)
  newElement.target = '_blank'
  newElement.rel = 'noopener'
  newElement.className = `${CONTENTFUL_LINK_CLASSNAME}--hidden`
  newElement.setAttribute('data-id', getLinkDataId(entry.id))
  const newElementInner = document.createElement('div')
  newElementInner.innerText = `View in ${entry.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') === getLinkDataId(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 getLinkDataId = entryID => `cms-link-${entryID}`

const findElementsMatchingData = () => {
  const allElements = CONTENT_TAG_NAMES.flatMap(tagName => [
    ...document.body.getElementsByTagName(tagName),
  ])
  allElements.forEach(element => {
    const innerText = element.innerText?.trim()
    const altText = element.getAttribute('alt')?.trim()
    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') === getLinkDataId(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
}

findZendeskDomainInData = data => {
  let zendeskDomain = null
  let locale = null

  objectTraverseModify(
    data,
    obj => {
      if (!obj || typeof obj?.url !== 'string') {
        return obj
      }

      if (obj.url.match(/^https:\/\/([a-z]+)\.zendesk\.com\/.*$/gm)) {
        const splitUrl = obj.url.split('/')
        zendeskDomain = splitUrl[2]
        locale = splitUrl[6]
      }

      return obj
    },
    null
  )

  return { zendeskDomain, locale }
}

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 && !!s.trim)
        .map(s => s.trim())
    : PROPERTIES_OF_INTEREST.map(property => obj[property])
        .filter(s => !!s && !!s.trim)
        .map(s => s.trim())

  // Determine whether contentful entry, contentful asset, or zendesk
  let urlSchema = null
  let cmsName = null
  if (contentfulEntryUrlSchema) {
    urlSchema = contentfulEntryUrlSchema
    cmsName = 'Contentful'
  }
  if (contentfulAssetUrlSchema && obj.fields?.file?.url) {
    urlSchema = contentfulAssetUrlSchema
  }
  if (zendeskUrlSchema && obj.url?.includes('zendesk.com/')) {
    urlSchema = zendeskUrlSchema
    cmsName = 'Zendesk'
  }
  return { id, texts: entryTexts, urlSchema, cmsName }
}
const findEntries = data => {
  objectTraverseModify(
    data,
    obj => {
      if (!obj) return obj
      const newEntry = extractIdAndTextsFromObject(obj)
      const { id, texts } = newEntry
      if (id && texts.length) {
        entries.push(newEntry)
      }
      return obj
    },
    null
  )
}

const getPropData = () => {
  const rawData = document.getElementById('__NEXT_DATA__')?.innerText
  if (!rawData) {
    return {}
  }
  return JSON.parse(rawData)
}

const getEntryDataFromProps = () => {
  const data = getPropData()
  findEntries(data)
}

const setContentfulCms = contentfulSpaceId => {
  contentfulEntryUrlSchema = CONTENTFUL_ENTRY_URL_FORMAT.replace(
    '{{space}}',
    contentfulSpaceId
  )
  contentfulAssetUrlSchema = CONTENTFUL_ASSET_URL_FORMAT.replace(
    '{{space}}',
    contentfulSpaceId
  )
}

const getUrlSchemaFromProps = () => {
  const data = getPropData()
  const contentfulSpaceId = findCtflSpaceIdInData(data)
  if (contentfulSpaceId) {
    setContentfulCms(contentfulSpaceId)
  }

  const { zendeskDomain, locale } = findZendeskDomainInData(data)
  if (zendeskDomain) {
    zendeskUrlSchema = `https://${zendeskDomain}/knowledge/articles/{{id}}/${locale}`
  }
}

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 CMS 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 (cmsDataAvailable()) {
      injectStyles()
      addButton()
      console.log(
        `Found CMS assets. CMS links is now configured to take you to ${cmsNames()} 🎉`
      )
    } 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 ${cmsNames()} 🎉`
        )
        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()