Svelte 및 kentico kontent.ai

이 블로그 게시물은 kentico kontent.ai의 서버 측 렌더링 CMS 콘텐츠에 미리보기 기능을 추가하는 방법에 관한 것입니다(제 경우에는 렌더링에 Salesforce 상거래 클라우드를 사용했습니다). CMS 콘텐츠에 이미 클라이언트 렌더링을 사용하고 있다면 이것이 필요하지 않은 경우 프로젝트에 미리보기 구성을 추가하기만 하면 됩니다.

색인


  • Get your main site ready
  • Proxy server with polka
  • Sveltify your site
  • Make the preview content togglable
  • Add more CMS items

  • 메인 사이트를 준비하세요

    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.

    폴카가 있는 프록시 서버

    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);
    

    귀하의 사이트를 단순화하십시오

    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}`
    };
    

    미리보기 콘텐츠를 전환 가능하게 만들기

    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.

    더 많은 CMS 항목 추가

    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.

    학점



    표지 이미지: https://unsplash.com/@marvelous

    좋은 웹페이지 즐겨찾기