您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
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()