DEV Community

Cover image for Svelte and kentico kontent.ai
Domenik Reitzner
Domenik Reitzner

Posted on • Updated on

Svelte and kentico kontent.ai

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

  1. Get your main site ready
  2. Proxy server with polka
  3. Sveltify your site
  4. Make the preview content togglable
  5. 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}`);
    });
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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(/\/\?$/, '');
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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>

Enter fullscreen mode Exit fullscreen mode

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;
});
Enter fullscreen mode Exit fullscreen mode

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();
}
Enter fullscreen mode Exit fullscreen mode

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}`
};
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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)
};
Enter fullscreen mode Exit fullscreen mode

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))
        : {};
}

Enter fullscreen mode Exit fullscreen mode

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;
    });
};
Enter fullscreen mode Exit fullscreen mode

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}
Enter fullscreen mode Exit fullscreen mode

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

Discussion (0)