AEM Cloudservice - React SPA with Sling Vanity URLs

Goal

Adobe Experience Manager 2021.9.5899.20210929T093525Z-210800 (Sep 29, 2021)

Create a AEM React SPA supporting Vanity Urls. Otb Vanity Urls are added in Page Properties, saved as jcr:content/sling:vanityPath

For URL Shortening check this post

Demo | Package Install | Github


Configure Vanity URL


SPA Navigation with Vanity URL



Solution

1) Add the function getVanityUrls() to get the list of vanity urls using querybuilder before loading the app. VanityURLModelClient extending ModelClient was added as a workaround to not request model.json using the vanity url (eg. this is invalid request http://localhost:4502/eaem-home.model.json). Code in eaem-spa-vanity-urls\ui.frontend\src\index.js facilitates loading of vanity urls into the app...

import 'react-app-polyfill/stable';
import 'react-app-polyfill/ie9';
import 'custom-event-polyfill';

import { Constants, ModelManager } from '@adobe/aem-spa-page-model-manager';
import { createBrowserHistory } from 'history';
import React from 'react';
import { render } from 'react-dom';
import { Router } from 'react-router-dom';
import {ModelClient} from '@adobe/aem-spa-page-model-manager';
import App from './App';
import LocalDevModelClient from './LocalDevModelClient';
import './components/import-components';
import './index.css';

const getVanityUrls = async () => {
    const QUERY = "/bin/querybuilder.json?path=/content/eaem-spa-vanity-urls&property=jcr:content/sling:vanityPath&property.operation=exists" +
        "&p.hits=selective&p.properties=jcr:content/sling:vanityPath%20jcr:path&type=cq:Page";

    const response = process.env.REACT_APP_PROXY_ENABLED ? await fetch(QUERY, {
        credentials: 'same-origin',
        headers: {
            'Authorization': process.env.REACT_APP_AEM_AUTHORIZATION_HEADER
        }
    }): await fetch(QUERY);

    const data = (await response.json()).hits.reduce((current, next) => {
        return { ...current, ...{ [next["jcr:path"]]: next["jcr:content"]?.["sling:vanityPath"] } }
    }, {});

    return data;
};

class VanityURLModelClient extends ModelClient{
    fetch(modelPath) {
        //if the path does not start with /content (page editing) or /conf (template editing) return empty model
        if (modelPath && !/^\/content|^\/conf/.test(modelPath)) {
            return Promise.resolve({});
        }else{
            return super.fetch(modelPath);
        }
    }
}

const modelManagerOptions = {};

if(process.env.REACT_APP_PROXY_ENABLED) {
    modelManagerOptions.modelClient = new LocalDevModelClient(process.env.REACT_APP_API_HOST);
}else{
    modelManagerOptions.modelClient = new VanityURLModelClient(process.env.REACT_APP_API_HOST);
}

const renderApp = (vanityUrls) => {
    ModelManager.initialize(modelManagerOptions).then(pageModel => {
        const history = createBrowserHistory();
        render(
            <Router history={history}>
                <App
                    vanityUrls={vanityUrls}
                    history={history}
                    cqChildren={pageModel[Constants.CHILDREN_PROP]}
                    cqItems={pageModel[Constants.ITEMS_PROP]}
                    cqItemsOrder={pageModel[Constants.ITEMS_ORDER_PROP]}
                    cqPath={pageModel[Constants.PATH_PROP]}
                    locationPathname={window.location.pathname}
                />
            </Router>,
            document.getElementById('spa-root')
        );
    });
};

document.addEventListener('DOMContentLoaded', () => {
    getVanityUrls().then((vanityUrls) => {
        renderApp(vanityUrls);
    }, (err) => {
        console.log("Error getting vanity urls", err);
        renderApp({});
    });
});


2) Pass the page full url to vanity url mapping object loaded in step above to child pages in eaem-spa-vanity-urls\ui.frontend\src\App.js

import { Page, withModel } from '@adobe/aem-react-editable-components';
import React from 'react';

// This component is the application entry point
class App extends Page {
    render() {
        const vanityUrls = this.props.vanityUrls;

        return (
            <div>
                {this.childComponents}
                {this.childPages.map((childPage) => {
                    return <React.Fragment>
                        { React.cloneElement(childPage, {
                            vanityUrls: vanityUrls
                        })}
                    </React.Fragment>
                })}
            </div>
        );
    }
}

export default withModel(App);


3) Add the full url and vanity url in Route in ui.frontend\src\components\RouteHelper\RouteHelper.js. Vanity urls are added to Route only if the app is loaded Published mode (?wcmmode=disabled in author)...

import React, { Component } from 'react';
import { Route } from 'react-router-dom';
import { AuthoringUtils } from '@adobe/aem-spa-page-model-manager';

/**
 * Helper that facilitate the use of the {@link Route} component
 */

/**
 * Returns a composite component where a {@link Route} component wraps the provided component
 *
 * @param {React.Component} WrappedComponent    - React component to be wrapped
 * @param {string} [extension=html]             - extension used to identify a route amongst the tree of resource URLs
 * @returns {CompositeRoute}
 */
export const withRoute = (WrappedComponent, extension) => {

    return class CompositeRoute extends Component {
        render() {
            let routePath = this.props.cqPath;

            if (!routePath) {
                return <WrappedComponent {...this.props} />;
            }

            extension = extension || 'html';

            let paths = ['(.*)' + routePath + '(.' + extension + ')?'];

            if(!AuthoringUtils.isInEditor()){
                let vanityUrl = this.props.vanityUrls?.[routePath];

                if(vanityUrl){
                    paths.push((!vanityUrl.startsWith("/") ? "/" : "") + vanityUrl) ;
                }
            }

            return (
                <Route
                    key={routePath}
                    exact
                    path={ paths }
                    render={routeProps => {
                        return <WrappedComponent {...this.props} {...routeProps} />;
                    }}
                />
            );
        }
    };
};


SPA App Local Development

1) For deploying the app with React changes combined with AEM editor changes eg. using if(!AuthoringUtils.isInEditor()) the app needs to be built using npm run build and pushed to AEM using npm run sync



2) However if the changes are only in React scripts (with no AEM specific code), it's much easier to use npm run start and do hot deployment (the app running on localhost:3000 gets updated in browser when you save the script in editor). The model.json required for app is proxied from AEM. Check it in action


3) For the local dev setup, set REACT_APP_PAGE_MODEL_PATH in eaem-spa-vanity-urls\ui.frontend\.env.development

                     PUBLIC_URL=/

                     REACT_APP_PROXY_ENABLED=true

                     REACT_APP_PAGE_MODEL_PATH=/content/eaem-spa-vanity-urls/us/en.model.json

                     REACT_APP_API_HOST=http://localhost:4502

                     REACT_APP_AEM_AUTHORIZATION_HEADER='Basic YWRtaW46YWRtaW4='

                     REACT_APP_ROOT=/content/eaem-spa-vanity-urls/us/en/home.html


4) The following code in eaem-spa-vanity-urls\ui.frontend\src\index.js loads a local dev client for proxying the model json requests to AEM

if(process.env.REACT_APP_PROXY_ENABLED) {
    modelManagerOptions.modelClient = new LocalDevModelClient(process.env.REACT_APP_API_HOST);
}


5) To start the local dev server npm run start

6) Since the model json request is CORS request from local page load to AEM, such requests are not allowed in modern browsers. Either configure CORS in AEM or since this is just for local development, use a browser profile with disabled web security....

                                     a. Create a new profile in chrome...


                                     b. Create a shortcut with target : 

                                               "C:\Program Files (x86)\Google\Chrome\Application\chrome.exe" --profile-directory="Profile 1"  

                                      --user-data-dir="C:/Users/<user>/AppData/Local/Google/Chrome/User Data/Profile 1" --disable-web-security 



                                     c. Run the new shortcut to open a browser instance disabling CORS...


7) Open the disabled web security chrome instance and access url http://localhost:3000/content/eaem-spa-vanity-urls/us/en/home.html to load the SPA app (the app is now loading from react dev server on port 3000)

8) A sample AEM vanity url served from react dev server on port 3000 eg. http://localhost:3000/eaem-spa-cloud





3 comments:

  1. We should not be using QueryBuilder servlet in production code, as noted at https://helpx.adobe.com/au/experience-manager/kb/Unclosed-ResourceResolver-warnng-at-com-day-cq-search-impl-builder-QueryBuilderImpl.html, since it could lead to memory leaks.

    ReplyDelete
  2. This comment has been removed by the author.

    ReplyDelete
  3. i am getting empty object in vanityUrl object. any help?

    ReplyDelete