Svelte and kentico kontent.ai
Domenik Reitzner
Posted on December 11, 2020
This blog post is about adding preview functionality to server-side-rendered CMS content from kentico kontent.ai (in my case we used Salesforce commerce cloud for the rendering). If you use already client rendering for your CMS content, than you don't need this, just add a preview config to your project.
Index
- Get your main site ready
- Proxy server with polka
- Sveltify your site
- Make the preview content togglable
- Add more CMS items
Get your main site ready
One prerequisite for this whole shenanigan to actually work, is that you have your live site up and running.
Another important step is that you have a way of referencing your ssr content to an kontent.ai id. The way I did it was by using data-system-id
in the ssr site.
Proxy server with polka
The node server (I used polka, but express or any similar should work as well) is a very simple one.
I check if I get a call with a ?previewId={id}
, which will have the kentico id.
const dir = join(__dirname, '../public'); //dir for public
const serve = serveStatic(dir);
polka()
.use('/preview', serve)
.get('*', async (req, res) => {
let url = req.originalUrl;
const isMainRequest = url.match(/(\?|&)previewId=/) !== null;
// some magic 🦄
})
.listen(PORT, (err) => {
if (err) throw err;
console.log(`> Running on localhost:${PORT}`);
});
All requests, that are not our main request we will just proxy.
if (!isMainRequest) {
return request
.get(url)
.auth(usr, pwd, false) // if server needs basic auth
.pipe(res);
}
For our main request it is important, that we remove our custom Url parameter
const toRemove = url.match(/[\?|&](previewId=.*?$|&)/)[1];
url = url
.replace(toRemove, '')
.replace(/\/\?$/, '');
After that we can handle our main request and inject our js/css bundles at the end of our html
// get requested site from live server
const resp = await fetch(url, {headers});
let text = await resp.text();
// add script tag before </body>
if (text.includes('<html')) {
const bundles = `
<script src="/preview/bundle.js" async></script>
<link rel="stylesheet" href="/preview/bundle.css">
`;
if(text.includes('</body>')) {
text = text.replace('</body>', `${bundles}</body>`)
} else {
// cloudflare eg. minifies html
// by truncating last closing tags
text += bundles;
}
}
// return response
return res.end(text);
Sveltify your site
The best choice for the frontend in my opinion (especially for such small an powerful tool) is svelte.
I leaves a small footprint comes with huge capabilities and is ideal if you want to run a tool on top of another site.
The basic svelte setup (with ts) looks something like this:
<!-- App.svelte -->
<script lang="ts">
import { onMount } from 'svelte';
// INIT VARS
let preview = true;
let addMode = false;
let toggleFuncs = new Map();
let arrayOfCmsNodes = [];
let overlays = [];
onMount(() => {
// some init stuff
});
</script>
<main>
</main>
CSS can be totally custom. In my project I put the tools in the bottom right corner, but this is just my preference, so I'll leave them out.
In the onMount function I initialize the app by getting the previewId and setting up all available dom nodes that have cms capability. (in my case I excluded child cms components)
// App.svelte
onMount(() => {
// get param from url
const url = new URL(document.URL);
const id = url.searchParams.get('previewId');
loadPreview(id);
const tempArr = [];
document.querySelectorAll('[data-system-id]')
.forEach((node: HTMLElement) => {
if (node.dataset.systemId === id) return;
// for nested this needs to exclude children data-system-id
if((node.parentNode as HTMLElement).closest('[data-system-id]') !== null) return;
tempArr.push(node);
});
arrayOfCmsNodes = tempArr;
});
As you can see, the next step was to call loadPreview(id)
. This will get the preview data from Kontent.ai
// App.svelte
import { getPreviewContent } from './service/kontent';
import { getToggle } from './service/toggleFunctionGenerator';
const loadPreview = async (id: string) => {
if (!id) return;
const content = await getPreviewContent(id);
if (!content?.items?.length) return;
const toggle = getToggle(id, content);
if (!toggle) return;
toggleFuncs.set(id, toggle);
if(preview) toggle();
}
To get the content you just need to fetch the content by id from https://preview-deliver.kontent.ai/${projectId}/items?system.id=${key}
by setting an authorization header with your preview key.
const headers = {
'authorization': `Bearer ${previewKey}`
};
Make the preview content togglable
As we want the content to not be only replaced, but toggle between live and preview version, we need to generate a toggle function.
For switching between those states I created a simple toggle switch and function.
<!-- App.svelte -->
<script lang="ts">
import Toggle from './components/Toggle.svelte';
const togglePreviews = () => {
preview = !preview
toggleFuncs.forEach(func => func());
}
</script>
<main>
<Toggle
{preview}
{togglePreviews} />
</main>
Setting up the toggle function was a little bit more complex, but in the end it is really easy to add more entries.
// .service/toggleFunctionGenerator.ts
import {
replaceText,
} from './replaceContent';
import {
getToogleDataByType,
} from './toggleConfig';
const getNodeBySystemId = (id: string) => document.querySelector(`[data-system-id='${id}']`);
const handleType = (type: string, id: string, elements: IKElements, modularContent: IKModularContent): { (): void} => {
const node = getNodeBySystemId(id);
if (!node) return null;
const {
textReplace,
} = getToogleDataByType(type, elements);
const children = Object.keys(modularContent).length
? Object.entries(modularContent)
.map(([key, value]) => handleType(value.system.type, value.system.id, value.elements, {}))
.filter((child) => !!child)
: [];
const toggleFunc = () => {
if (textReplace) replaceText(node, textReplace);
};
return toggleFunc;
};
export const getToggle = (id: string, content: IKContent) => {
const item = content.items[0];
return handleType(item.system.type, id, item.elements, content.modular_content)
};
By wrapping everything into a toggle function, we keep the state available inside of it. As kontent.ai will return a lot of data that will not be used, I decided to explicitly save the data that I need. I this inside of getToogleDataByType
.
// .service/toggleConfig.ts
// in my project I have 6 different data generators, so they ended up in a new file
const getGenericElements = (elements: IKElements, keyMapper: IKeyValue): IReplacer[] => {
const tempArr: IReplacer[] = [];
Object.entries(keyMapper).forEach(([key, querySelector]) => {
const data = elements[key]
if (!data) return;
tempArr.push({
querySelector,
value: data.value,
});
});
return tempArr;
};
// Toggle Data Config
const myType = (elements: IKElements): IToggleData => {
const textKeyMapper: IKeyValue = {
my_title: '.js-my_title',
};
return {
textReplace: getGenericElements(elements, textKeyMapper),
}
};
export const getToogleDataByType = (type: string, elements: IKElements): IToggleData => {
const callRegistry = {
myType: myType,
}
const caller = callRegistry[type];
return caller
? Object.assign({}, caller(elements))
: {};
}
Each replacer will give us an array with objects that will match the preview value with the dom selector (or whatever other stuff you can think of).
So how does the data generation actually translate to updating the dom when the toggle function is called?
It is basically just getting and saving the old value and setting the new one.
// .service/replaceContent.ts
const getElementByQuerySelector = (node: Element, querySelector: string): any => querySelector === null
? node
: node.querySelector(querySelector);
export const replaceText = (node: Element, textElements: IReplacer[]) => {
textElements.forEach(({querySelector, value}, i) => {
const element = getElementByQuerySelector(node, querySelector);
if (!element) return;
const old = element.textContent;
element.textContent = value;
textElements[i].value = old;
});
};
So we've got the basics up and running. But to only have one id previewed is a little boring.
Add more CMS items
As we alread have an array of cms nodes, setting this up should be fairly easy. ☺
We just need an overlay and handle the add click with the already existing setup.
<!-- App.svelte -->
<script lang="ts">
import AddButton from './components/AddButton.svelte';
import AddBox from './components/AddBox.svelte';
const handleAddClick = (idToAdd: string) => {
handleAddMode();
loadPreview(idToAdd);
arrayOfCmsNodes = arrayOfCmsNodes.filter((node: HTMLElement) => node.dataset.systemId !== idToAdd);
}
const handleAddMode = () => {
addMode = !addMode;
if (addMode) {
arrayOfCmsNodes.forEach((node: HTMLElement) => {
const {top, height, left, width} = node.getBoundingClientRect();
overlays.push({
id: node.dataset.systemId,
top: top + window.scrollY,
height: height,
left: left,
width: width,
});
})
overlays = overlays;
} else {
overlays = [];
}
}
</script>
<main>
{#if arrayOfCmsNodes.length}
<AddButton
{addMode}
{handleAddMode} />
{/if}
</main>
{#each overlays as {id, top, height, left, width}}
<AddBox
{id}
{top}
{height}
{left}
{width}
{handleAddClick} />
{/each}
I know this part was by far the easiest one, but is adds a lot of value to the functionality, so I wanted to include it here.
Thank you for reading and I hope you can take away something or are inspired for your own project.
Credits
cover image: https://unsplash.com/@marvelous
Posted on December 11, 2020
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.