/* global WP_DEFINE_DEVELOPMENT, WP_DEFINE_ASSETS_JSON_LOCATION, WP_DEFINE_VERSIONING_PATH, WP_DEFINE_IS_NODE */
import convertHrtime from 'convert-hrtime';
import { renderToString, renderToStaticMarkup } from 'react-dom/server';
import { match, RouterContext, createMemoryHistory } from 'react-router';
import { prepareComponents, setInitMode, MODE_INIT_SELF } from 'react-redux-component-init';
import { syncHistoryWithStore } from 'react-router-redux';
import { Provider } from 'react-redux';
import debugLib from 'debug';
import React from 'react';
import promisify from 'es6-promisify';
import fs from 'fs';
import path from 'path';
import intercept from 'intercept-stdout';
import stringify from 'qs/lib/stringify';
import pickBy from 'lodash/pickBy';
import mapValues from 'lodash/mapValues';
import flatten from 'lodash/flatten';
import hotHtml from 'hot-callback-loader?export=default!./components/Html';
import Configuration from '../app/config/Configuration';
import createStoreInstance from './util/createStoreInstance';
import { tempAddDebugEnvNamespace, restoreDebugEnv } from './util/tempPatchDebugEnv';
import serverConfig from './server.configdefinitions';
import processQueryRouting from '../app/util/QueryRouting/processQueryRouting';
import serviceConfig from '../app/config/service.configdefinitions';
import { setInitialUserState, setUserPermissionState } from '../app/actions/authenticationActions';
import secureReduxState from './util/secureReduxState';
import {
WEB_VIEW_REDIRECT_URI,
WEB_VIEW_FREE_2_GO_REDIRECT_URI,
} from './util/AuthenticationHelper/constants';
import setupInjects from '../app/util/setupInjects';
import parseRouteRequirements from '../app/util/route-requirements/parseRouteRequirements';
import processRequirements from '../app/util/route-requirements/processRequirements';
import { parseResponseQuery } from './actions/responseActions';
import {
enablePerformanceMeasurements,
setWebViewCookie,
setFree2GoApp,
} from '../app/actions/configActions';
import QueryRoutingProvider from '../app/util/QueryRouting/QueryRoutingProvider';
import RedirectError from '../app/util/RedirectError';
import getProtocol from '../app/util/getProtocol';
import { getError, hasErrors } from '../app/reducers/errorReducer';
import { userAccountSelector } from '../app/selectors/userAccountSelectors';
import RenderMode from '../app/data/enum/RenderMode';
import UserRole from '../app/data/enum/UserRole';
import { getPropOnDeepestRoute } from '../app/util/routeUtils';
import { handleRuntimeError } from '../app/actions/errorActions';
import { getClient } from '../app/util/raven/raven-client';
import prependWebHost from '../app/util/prependWebHost';
import { getUserPermissionStoredData } from '../app/util/userPermissionStateUtil';
import { INITIAL_USER_STATE_COOKIE } from '../app/data/AuthConstants';
const appInsights = require('applicationinsights');
const debug = debugLib('SlimmingWorld:PageRenderer');
const matchPromisified = promisify(match, { multiArgs: true });
const HTTP_MOVED_TEMPORARILY = 302;
const prepareLogMetric = label => ({
time: process.hrtime(),
label,
});
const logMetric = prepared => {
const timing = convertHrtime(process.hrtime(prepared.time)).milliseconds;
debug(`logMetric :: ${prepared.label} : ${timing}`);
process.env.APPINSIGHTS_METRICS_ENABLED &&
appInsights &&
appInsights.defaultClient &&
appInsights.defaultClient.trackMetric({
name: prepared.label,
value: timing,
});
};
const checkWebView = req => {
const returnUrl = req.query.ReturnUrl;
if (!returnUrl) {
return false;
}
const params = returnUrl.split('?');
const param = new URLSearchParams(params[1]).get('redirect_uri');
return (
(req.cookies && !!req.cookies.isWebView) ||
(req.headers && !!req.headers['x-webview']) ||
param === WEB_VIEW_REDIRECT_URI ||
param === WEB_VIEW_FREE_2_GO_REDIRECT_URI
);
};
const checkFree2GoRedirectUri = req => {
const returnUrl = req.query.ReturnUrl;
if (!returnUrl) {
return false;
}
const params = returnUrl.split('?');
const param = new URLSearchParams(params[1]).get('redirect_uri');
return param === WEB_VIEW_FREE_2_GO_REDIRECT_URI;
};
const checkInitialUserState = req => req.cookies && req.cookies[INITIAL_USER_STATE_COOKIE];
const getComponentForRoute = (nextState, route) => {
if (route.component || route.components) {
return Promise.resolve(route.component);
}
const getComponent = route.getComponent;
if (getComponent) {
return new Promise(resolve => {
const componentReturn = getComponent.call(route, nextState, (err, component) => {
resolve(component);
});
if (componentReturn && componentReturn.then && typeof componentReturn.then === 'function') {
return componentReturn.then((err, component) => resolve(component)).catch(() => resolve());
}
return undefined;
});
}
return Promise.resolve();
};
const getPageComponents = (renderProps, routes) =>
Promise.all(
routes.map(route => route.component || getComponentForRoute(renderProps, route)),
).then(components => components.filter(_ => _));
class AccessDeniedError extends Error {}
/**
* Helper class that pre-renders markup for a given route.
*/
class PageRenderer {
Routes = null;
reduxReducer = null;
environmentConfig = null;
assetsManifest = null;
oidcDiscovery = null;
buildInfo = null;
constructor() {
hotHtml(Html => (this.Html = Html));
}
/**
* Returns the parsed assets manifest that outputs during the web build. On development,
* we will read the assets json file on every call. Otherwise, this object will be cached.
*
* @returns {object}
*/
getAssetsManifest = () => {
if (!this.assetsManifest || WP_DEFINE_DEVELOPMENT) {
try {
const assetsFile = fs.readFileSync(path.join(__dirname, WP_DEFINE_ASSETS_JSON_LOCATION));
this.assetsManifest = JSON.parse(assetsFile);
} catch (error) {
throw error;
}
}
return this.assetsManifest;
};
/**
* A JSON object containing details of the current build.
* @param buildInfo An object providing details of the current build.
*/
setBuildInfo = buildInfo => (this.buildInfo = buildInfo);
/**
* Set the react-router configuration component that should be used to match incoming
* requests.
* @param Routes The react-router configuration component
*/
setReactRoutes = Routes => (this.Routes = processQueryRouting(Routes));
/**
* Sets the main reducer to be used when creating new store instances.
* @param reducer A redux reducer function
*/
setReduxReducer = reducer => (this.reduxReducer = reducer);
/**
* Sets the OidcDiscovery instance used to get the public key for validating tokens
* @param oidcDiscovery An instance of OidcDiscovery util
*/
setOidcDiscovery = oidcDiscovery => (this.oidcDiscovery = oidcDiscovery);
/**
* Sets the gateway
*
* @param environmentConfig
*/
setSetupInjects = environmentConfig =>
setupInjects({
config: JSON.parse(JSON.stringify(environmentConfig)),
});
/**
* Sets the environmentConfig instance to set the configurations settings
* @param environmentConfig
* @returns {*}
*/
setEnvironmentConfig = environmentConfig => (this.environmentConfig = environmentConfig);
/**
* Handles incoming request from the express app. Will use renderPage() to do the actual
* page rendering. Catches any errors that occur and calls express' res.redirect()
* or next() accordingly.
* @param {Object} req The express request object
* @param {Object} res The express response object
* @param {function} next The express next callback
* @param {Object} options Options object
* @param {AuthenticationHelper} [options.authHelper=null] If authentication is enabled for this
* server, an instance of AuthenticationHelper will be passed so authentication can be performed
* on routes that require login
* @param {function} [options.afterStoreHook=null] Optional hook function that is called after
* the store is created, so it can dispatch actions or subscribe to changes from outside this
* function. The store will be passed as the first and only parameter to the hook function.
* @param {boolean} [debugMode=false] If debug mode is enabled, will run respond with a json
* with additional metadata instead of the response content as HTML.
*/
async handleRequest(
req,
res,
next,
{ authHelper = null, afterStoreHook = null } = {},
debugMode = false,
) {
const debugProps = {};
let unhookIntercept = null;
if (debugMode) {
tempAddDebugEnvNamespace();
debugProps.errorBuffer = [];
debugProps.logBuffer = [];
const startTime = process.hrtime();
debugProps.times = [{ time: startTime }];
debugProps.pushTime = label =>
debugProps.times.push({
time: process.hrtime(debugProps.times[0].time),
label,
});
unhookIntercept = intercept(
mssg => {
debugProps.logBuffer.push(mssg);
},
mssg => {
debugProps.errorBuffer.push(mssg);
},
);
// eslint-disable-next-line no-param-reassign
res.redirect = function redirect(a1, a2) {
const redirectPath = typeof a1 === 'number' ? a2 : a1;
res.send({ redirect: redirectPath, success: false });
};
}
let markup = null;
try {
markup = await this.renderPage(
req,
res,
{ authHelper, afterStoreHook },
debugMode && debugProps,
);
} catch (e) {
if (e instanceof RedirectError) {
if (debugMode) {
restoreDebugEnv();
}
debug(`RedirectError thrown. Redirecting request to "${e.path}"`);
res.redirect(e.path);
} else if (e instanceof AccessDeniedError) {
if (debugMode) {
restoreDebugEnv();
}
res.status(403).send({
success: false,
error: {
message: e.message,
},
});
return;
} else {
if (debugMode) {
restoreDebugEnv();
res.send({
success: false,
error: {
message: e.message || (e.error && (e.error.message || e.error.error)) || e,
stack: e.stack || (e.error && e.error.stack) || null,
},
});
return;
}
next(e);
}
}
if (debugMode) {
restoreDebugEnv();
}
if (unhookIntercept) {
unhookIntercept();
}
if (markup) {
if (debugMode) {
debugProps.duration = process.hrtime(debugProps.times[0].time);
res.send({
success: true,
markup,
...debugProps,
});
} else {
res.set({
'Content-Type': 'text/html; charset=utf-8',
'Content-Security-Policy': "frame-ancestors 'self'",
'X-Frame-Options': 'SAMEORIGIN',
});
debug('sending markup');
res.send(markup);
}
}
}
/**
* Handles incoming request from the express app. Will match the requested route using
* react-router and if a match has been found, render the markup for that route and
* respond it back to the client.
* @param {Object} req The express request object
* @param {Object} res The express response object
* @param {Object} options Options object
* @param {AuthenticationHelper} [options.authHelper=null] If authentication is enabled for this
* server, an instance of AuthenticationHelper will be passed so authentication can be performed
* on routes that require login
* @param {function} [options.afterStoreHook=null] Optional hook function that is called after
* the store is created, so it can dispatch actions or subscribe to changes from outside this
* function. The store will be passed as the first and only parameter to the hook function.
* @param {Object} [debugProps=null] If debug mode is enabled, this will be an object where
* additional diagnostics will be pushed onto
*/
async renderPage(req, res, { authHelper = null, afterStoreHook = null }, debugProps = null) {
// wait for OIDC discovery
if (!this.oidcDiscovery) {
throw new Error(
'OidcDiscovery instance needs to be initialized before requests can be handled',
);
}
await this.oidcDiscovery.discoverComplete;
debugProps && debugProps.pushTime('route matching');
const routeMatchingMetric = prepareLogMetric('route matching');
// perform main route matching
const renderProps = await this.matchRoutes(req, res);
if (!renderProps) return null;
// get authentication object with subscriptionType and accountState
let authentication;
if (serviceConfig.useServerAuthentication && authHelper) {
authentication = await authHelper.getAuthentication(req, res);
}
logMetric(routeMatchingMetric);
debugProps && debugProps.pushTime('initialize store');
const initializeStoreMetric = prepareLogMetric('initialize store');
// initialize history and store instance
const memoryHistory = createMemoryHistory(req.url);
const store = await createStoreInstance(
this.reduxReducer,
{
jar: req.cookies,
set: (name, value, options) => {
if (res.headersSent) {
debug('warning: trying to persist state but headers already sent');
} else {
res.cookie(name, value, options);
}
},
clear: (name, options) => {
if (res.headersSent) {
debug('warning: trying to clear state but headers already sent');
} else {
res.clearCookie(name, options);
}
},
},
memoryHistory,
authentication,
);
syncHistoryWithStore(memoryHistory, store);
if (afterStoreHook) {
await afterStoreHook(store, req, this.environmentConfig);
}
if (debugProps) {
store.dispatch(enablePerformanceMeasurements());
}
// check the if the 'isWebView' cookie exists, then update its value in the app state
if (checkWebView(req)) {
store.dispatch(setWebViewCookie(true));
}
// Check the Free2Go redirect uri
// If the uri is from the Free2Go app - set the state to be true
if (checkFree2GoRedirectUri(req)) {
store.dispatch(setFree2GoApp(true));
}
if (checkInitialUserState(req)) {
store.dispatch(setInitialUserState(req.cookies[INITIAL_USER_STATE_COOKIE]));
}
// check the location.query for a payment or logout response
await store.dispatch(
parseResponseQuery(
renderProps.location.query,
res,
store.getState()?.config?.environmentConfig,
),
);
logMetric(initializeStoreMetric);
debugProps && debugProps.pushTime('authentication');
const authenticationMetric = prepareLogMetric('authentication');
// handle authentication
await PageRenderer.handleAuthenticationForRequest(req, res, authentication, store);
if (debugProps) {
const account = userAccountSelector(store.getState());
if (!account || !account.roles.includes(UserRole.DEVELOPER)) {
throw new AccessDeniedError('access denied');
}
}
logMetric(authenticationMetric);
debugProps && debugProps.pushTime('query routing');
const queryRoutingMetric = prepareLogMetric('query routing');
// handle query routing (e.g. ?modal=weighin)
const queryRoutingConfigs = PageRenderer.getQueryRoutingConfigs(renderProps);
const routeQueryParams = pickBy(
renderProps ? renderProps.location.query : {},
(value, param) => queryRoutingConfigs[param],
);
// initialize an object with empty query routing results
// we will pass this object to <QueryRoutingProvider /> later on
const queryRoutingResults = mapValues(queryRoutingConfigs, () => null);
// initialize an array with all routing results. we will add the query routing result later
const allRoutingResults = [{ renderProps, queryParam: null }];
for (const queryParam of Object.keys(routeQueryParams)) {
const queryParamValue = routeQueryParams[queryParam];
const queryRoutingConfig = queryRoutingConfigs[queryParam];
if (queryRoutingConfig.routes && queryParamValue) {
const [queryRedirect, queryRenderProps] = await matchPromisified({
routes: queryRoutingConfig.routes,
location: queryParamValue,
});
// If the query result is a redirect, perform a redirect in express
if (queryRedirect) {
debug(
`react-router matched a redirect on query ${queryParam}=${queryParamValue}. Redirecting query to ${queryRedirect.pathname}`,
);
const newQuery = stringify(
{ ...req.query, [queryParam]: queryRedirect.pathname },
{ addQueryPrefix: true },
);
res.redirect(HTTP_MOVED_TEMPORARILY, `${req.path}${newQuery}`);
return null;
}
// store the query routing result in the queryRoutingResults map
queryRoutingResults[queryParam] = queryRenderProps;
allRoutingResults.push({ renderProps: queryRenderProps, queryParam });
}
}
logMetric(queryRoutingMetric);
debugProps && debugProps.pushTime('route requirements');
const routeRequirementsMetric = prepareLogMetric('route requirements');
const meetsRequirements = await PageRenderer.processRouteRequirements(
res,
req,
allRoutingResults,
store,
authHelper,
this.environmentConfig,
);
if (!meetsRequirements) {
return null;
}
logMetric(routeRequirementsMetric);
// TODO: This only handles 404 status code, do we also want to handle other types?
// TODO: 'public' will use a catch-all route for content, should check for API errors here
if (renderProps.routes && renderProps.routes.some(({ status }) => status === 404)) {
res.status(404);
}
const renderMode = store.getState().config.renderMode;
let markup;
debug(`Rendering on ${renderMode}`);
if (renderMode === RenderMode.CLIENT) {
// set the initMode. We normally do this on the client but we do this early because we're
// skipping the server render
store.dispatch(setInitMode(MODE_INIT_SELF));
// Render markup without content
debugProps && debugProps.pushTime('render shell markup');
markup = await this.renderMarkup(
req,
renderProps,
null,
secureReduxState(store.getState()),
undefined,
);
} else {
const allRoutes = flatten(
allRoutingResults.map(result => (result.renderProps && result.renderProps.routes) || []),
);
debugProps && debugProps.pushTime('prepare components');
const prepareComponentsMetric = prepareLogMetric('prepare components');
const components = await getPageComponents(renderProps, allRoutes);
try {
await store.dispatch(prepareComponents(components, renderProps));
} catch (e) {
if (e instanceof RedirectError) {
throw e;
}
debug('Error during prepareComponents. Triggering error page');
debug(e);
store.dispatch(handleRuntimeError(e));
// set initMode to force client to get its own data
store.dispatch(setInitMode(MODE_INIT_SELF));
}
logMetric(prepareComponentsMetric);
const app = (
<QueryRoutingProvider queryRoutingResults={queryRoutingResults}>
<Provider store={store}>
<RouterContext {...renderProps} />
</Provider>
</QueryRoutingProvider>
);
// get the state that is used to render React markup
const stateBeforeRender = store.getState();
if (debugProps) {
// eslint-disable-next-line no-param-reassign
debugProps.performance = stateBeforeRender.performance;
}
// render everything
debugProps && debugProps.pushTime('render markup');
try {
const renderMarkupMetric = prepareLogMetric('render markup');
const contentMarkup = renderToString(app);
logMetric(renderMarkupMetric);
// output actual HTML with rendered content and current server state
debugProps && debugProps.pushTime('render shell markup');
const renderShellMarkupMetric = prepareLogMetric('render shell markup');
markup = await this.renderMarkup(
req,
renderProps,
contentMarkup,
secureReduxState(stateBeforeRender),
);
logMetric(renderShellMarkupMetric);
} catch (e) {
debug('Error during react render. Triggering error page');
debug(e);
const ravenClient = getClient();
if (ravenClient) {
ravenClient.captureException(e);
}
store.dispatch(handleRuntimeError(e));
// set initMode to force client to get its own data
store.dispatch(setInitMode(MODE_INIT_SELF));
const renderShellMarkupMetric = prepareLogMetric('render shell markup');
markup = await this.renderMarkup(
req,
renderProps,
null,
secureReduxState(store.getState()),
);
logMetric(renderShellMarkupMetric);
}
}
debug('Markup rendered. Sending response...');
// When global errors are in the state, output status code
if (hasErrors(store.getState().error)) {
const error = getError(store.getState().error);
res.status(error.statusCode || 500);
}
// Pick deepest route with a maxAge prop
const lastRouteWithMaxAge =
renderProps && renderProps.routes.filter(route => typeof route.maxAge !== 'undefined').pop();
const maxAge = lastRouteWithMaxAge?.maxAge ?? serverConfig.maxAge;
if (typeof maxAge !== 'undefined') {
if (maxAge === 0) {
res.set({
'Cache-Control': `private, max-age=${maxAge}`,
});
} else {
res.set({
'Cache-Control': `public, max-age=${maxAge}`,
});
}
}
return `<!DOCTYPE html>${markup}`;
}
static async processRouteRequirements(
res,
req,
allRoutingResults,
store,
authHelper,
environmentConfig,
) {
for (const { renderProps, queryParam } of allRoutingResults) {
if (renderProps) {
const routeRequirements = parseRouteRequirements(renderProps.routes);
debug(
`Processing routeRequirements for ${
queryParam ? `queryParam "${queryParam}"` : 'main routing'
}`,
);
const { accountState } = getUserPermissionStoredData({
getState: store.getState,
req,
});
const meetsRequirements = await processRequirements(
routeRequirements,
{
dispatch: store.dispatch,
getState: store.getState,
accountState,
renderProps,
},
{
redirect: (location_, webHost = null) => {
const location = prependWebHost(environmentConfig, location_, webHost);
if (queryParam) {
const newQuery = stringify(
{ ...req.query, [queryParam]: location },
{ addQueryPrefix: true },
);
res.redirect(`${req.path}${newQuery}`);
} else {
res.redirect(location);
}
},
redirectToLogin: () => {
authHelper.redirectToLogin(req, res);
},
},
);
if (!meetsRequirements) {
return false;
}
}
}
return true;
}
/**
* Checks if authentication is enabled. If so, will get the current authentication state and
* store any auth tokens found in redux state by dispatching `setAuthTokens`.
* @private
* @param {Object} req The express request object
* @param {Object} res The express response object
* @param {AuthenticationHelper} [authHelper] An instance of AuthenticationHelper
* @param store The redux store to store authentication tokens in
* @returns {Promise} A Promise that resolves when getting authentication has completed
*/
static async handleAuthenticationForRequest(req, res, authentication, store) {
if (
!(serviceConfig.useClientAuthentication || serviceConfig.useServerAuthentication) ||
!authentication
) {
debug('Authentication not enabled. Skipping authentication logic.');
return;
}
if (authentication) {
debug('User permission state found.');
store.dispatch(
setUserPermissionState({
...authentication,
}),
);
} else {
debug('User not authenticated.');
}
}
/**
* Looks up all <QueryRouting> configs on the currently matched routes.
* @param renderProps {Object} The main route matching result
* @returns {Object} An object of query routing configurations mapped by query parameter name
*/
static getQueryRoutingConfigs(renderProps) {
const queryRoutingConfigs = {};
if (renderProps) {
renderProps.routes.forEach(route => {
if (route.queryRoutes) {
Object.keys(route.queryRoutes).forEach(queryRoutDef => {
queryRoutingConfigs[queryRoutDef] = route.queryRoutes[queryRoutDef];
});
}
});
}
return queryRoutingConfigs;
}
/**
* Performs a react-router match() call against the incoming express request. Will execute
* a redirect if a redirect is returned from the match.
* @param {Object} req The express request object
* @param {Object} res The express response object
* @returns {Promise} A Promise that resolves with the renderProps properties if the route was
* matched. Returns null otherwise.
*/
async matchRoutes(req, res) {
const [redirectLocation, renderProps] = await matchPromisified({
routes: this.Routes,
location: req.url,
});
debug(`Route matching complete for ${req.url}`);
if (redirectLocation) {
debug(
`react-router matched a redirect. Redirecting to ${redirectLocation.pathname}${redirectLocation.search}`,
);
res.redirect(
HTTP_MOVED_TEMPORARILY,
`${redirectLocation.pathname}${redirectLocation.search}`,
);
return null;
}
if (!renderProps) {
debug("react-router didn't match any route. Showing a server 404");
res.sendStatus(404);
return null;
}
return renderProps;
}
/**
* Renders the Html component and returns it as a string of HTML markup.
* @param contentMarkup If provided, will render the given content inside the main container
* of the Html component.
*/
renderMarkup = (req, renderProps, contentMarkup = null, reduxState = null) => {
const assetsManifest = this.getAssetsManifest();
const mainBundleAssets = [
assetsManifest.manifest,
assetsManifest.vendor,
assetsManifest.client,
];
// detect if browser-sync should be enabled
const injectWeinreBrwosersyncHost = req.cookies.swdevicedebugging || false;
// get the host from the config corresponding to the microservice we are running
const host = this.environmentConfig?.web?.[serviceConfig.webHost]?.host?.replace(
/https?:\/\//gi,
'',
);
const protocol = getProtocol(req);
// Please note: trailing slash is stripped from canonical url
const canonical = `${protocol}://${host}${(req.originalUrl || req.url).replace(/\/$/, '')}`;
// Temp fix why we figure out the node env is set to none
const indexSEO =
req.headers.host === 'www.slimmingworld.co.uk' || req.headers.host === 'www.slimmingworld.ie';
// find page title on main routes
const pageTitle = getPropOnDeepestRoute(renderProps.routes, 'title');
if (typeof pageTitle === 'undefined') throw new Error('Page title is missing and required');
let seo = {};
if (pageTitle) {
seo = {
metaTitle: pageTitle.replace('{pageTitle}', Configuration.pageTitle),
};
}
const stateSeo = reduxState && reduxState.seo;
if (stateSeo && stateSeo.metaTitle) {
// append the default title behind the custom page title
stateSeo.metaTitle += ` | ${Configuration.pageTitle}`;
seo = {
...seo,
...stateSeo,
};
}
const serviceScripts = serviceConfig.scripts || [];
return renderToStaticMarkup(
<this.Html
environmentConfig={this.environmentConfig}
markup={contentMarkup}
buildInfo={this.buildInfo}
reduxState={reduxState}
injectWeinreBrwosersyncHost={injectWeinreBrwosersyncHost}
mainBundleAssets={mainBundleAssets}
scripts={[].concat(serviceScripts)}
canonical={canonical}
indexSEO={indexSEO}
seo={seo}
/>,
);
};
}
export default PageRenderer;