Provides links from your site to Contentful.
当前为
// ==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();