diff --git a/README.md b/README.md index 26a12a3..642118c 100644 --- a/README.md +++ b/README.md @@ -438,3 +438,52 @@ app.get((req, res) => { })); }) ``` + +## Alternative usage + +With parts renderers `generateRenderContent`, `renderHeadContent`, `renderBodyContent` via html streaming: + +```js +import express from 'express'; +import {createRenderFunction} from '@gravity-ui/app-layout'; + +const app = express(); + +const renderLayout = createRenderFunction(); + +app.get('/', async function (req, res) { + res.writeHead(200, { + 'Content-Type': 'text/html', + 'Transfer-Encoding': 'chunked', + }); + + const content = generateRenderContent(plugins, { + title: 'Home page', + }); + + const {htmlAttributes, helpers, bodyContent} = content; + + res.write(` + + + + ${renderHeadContent(content)} + + + ${renderBodyContent(content)} + `); + + const data = await getUserData(); + + res.write(` + ${content.renderHelpers.renderInlineScript(` + window.__DATA__ = ${htmlescape(data)}; + `)} + + + `); + res.end(); +}); + +app.listen(3000); +``` diff --git a/src/index.ts b/src/index.ts index c6f42e3..2d2d1f6 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,4 +1,7 @@ -export {createRenderFunction, generateRenderContent} from './render.js'; +export {generateRenderContent} from './utils/generateRenderContent'; +export {renderHeadContent} from './utils/renderHeadContent'; +export {renderBodyContent} from './utils/renderBodyContent'; +export {createRenderFunction} from './render.js'; export { createGoogleAnalyticsPlugin, createYandexMetrikaPlugin, diff --git a/src/render.ts b/src/render.ts index e6f8e35..3f12f88 100644 --- a/src/render.ts +++ b/src/render.ts @@ -1,155 +1,22 @@ -import htmlescape from 'htmlescape'; - -import type {Icon, Meta, Plugin, RenderContent, RenderParams} from './types.js'; -import {attrs, getRenderHelpers, hasProperty} from './utils.js'; - -function getRootClassName(theme?: string) { - if (!theme) { - return []; - } - const classes = ['g-root']; - if (theme) { - classes.push(`g-root_theme_${theme}`); - } - return classes; -} - -const defaultIcon: Icon = { - type: 'image/png', - sizes: '16x16', - href: '/favicon.png', -}; - -const defaultMeta: Meta[] = [ - { - name: 'viewport', - content: 'width=device-width, initial-scale=1.0', - }, -]; - -/** - * Generates the content to be rendered in an HTML page. - * @param plugins - An array of plugins. - * @param params - An object containing various parameters for rendering the content. - * @returns An object containing the generated content for an HTML page. - */ -export function generateRenderContent( - plugins: Plugins | undefined, - params: RenderParams, -): RenderContent { - const helpers = getRenderHelpers(params); - const htmlAttributes: Record = {}; - const meta = params.meta ?? []; - // in terms of sets: meta = params.meta ∪ (defaultMeta ∖ params.meta) - defaultMeta.forEach((defaultMetaItem) => { - if (!meta.find(({name}) => name === defaultMetaItem.name)) { - meta.push(defaultMetaItem); - } - }); - const styleSheets = params.styleSheets || []; - const scripts = params.scripts || []; - const inlineStyleSheets = params.inlineStyleSheets || []; - const inlineScripts = params.inlineScripts || []; - const links = params.links || []; - - inlineScripts.unshift(`window.__DATA__ = ${htmlescape(params.data || {})};`); - - const content = params.bodyContent ?? {}; - const {theme, className} = content; - const bodyClasses = Array.from( - new Set([...getRootClassName(theme), ...(className ? className.split(' ') : [])]), - ); - const bodyContent = { - className: bodyClasses, - root: content.root, - beforeRoot: content.beforeRoot ? [content.beforeRoot] : [], - afterRoot: content.afterRoot ? [content.afterRoot] : [], - }; - - const {lang, isMobile, title, pluginsOptions = {}} = params; - for (const plugin of plugins ?? []) { - plugin.apply({ - options: hasProperty(pluginsOptions, plugin.name) - ? pluginsOptions[plugin.name] - : undefined, - renderContent: { - htmlAttributes, - meta, - links, - styleSheets, - scripts, - inlineStyleSheets, - inlineScripts, - bodyContent, - }, - commonOptions: {title, lang, isMobile}, - utils: helpers, - }); - } - - if (lang) { - htmlAttributes.lang = lang; - } - - return { - htmlAttributes, - meta, - links, - styleSheets, - scripts, - inlineStyleSheets, - inlineScripts, - bodyContent, - }; -} +import type {Plugin, RenderParams} from './types.js'; +import {generateRenderContent} from './utils/generateRenderContent'; +import {renderBodyContent} from './utils/renderBodyContent'; +import {renderHeadContent} from './utils/renderHeadContent'; export function createRenderFunction(plugins?: Plugins) { return function render(params: RenderParams) { - const { - htmlAttributes, - meta, - styleSheets, - scripts, - inlineStyleSheets, - inlineScripts, - links, - bodyContent, - } = generateRenderContent(plugins, params); - - const helpers = getRenderHelpers(params); + const content = generateRenderContent(plugins, params); - const icon: Icon = { - ...defaultIcon, - ...params.icon, - }; + const {htmlAttributes, helpers, bodyContent} = content; return ` - + - - ${params.title} - - ${[ - ...scripts.map(({src, crossOrigin}) => - helpers.renderLink({href: src, crossOrigin, rel: 'preload', as: 'script'}), - ), - ...links.map((link) => helpers.renderLink(link)), - ...meta.map((metaData) => helpers.renderMeta(metaData)), - ...styleSheets.map((style) => helpers.renderStyle(style)), - ...inlineStyleSheets.map((text) => helpers.renderInlineStyle(text)), - ...scripts.map((script) => helpers.renderScript(script)), - ...inlineScripts.map((text) => helpers.renderInlineScript(text)), - ] - .filter(Boolean) - .join('\n')} + ${renderHeadContent(content)} - - ${bodyContent.beforeRoot.join('\n')} -
- ${bodyContent.root ?? ''} -
- ${bodyContent.afterRoot.join('\n')} + + ${renderBodyContent(content)} `.trim(); diff --git a/src/types.ts b/src/types.ts index 69878bd..61d762a 100644 --- a/src/types.ts +++ b/src/types.ts @@ -36,6 +36,14 @@ export interface CommonOptions { isMobile?: boolean; } +export interface BodyContent { + attributes: Record; + className: string[]; + beforeRoot: string[]; + root?: string; + afterRoot: string[]; +} + export interface RenderContent { htmlAttributes: Record; meta: Meta[]; @@ -44,12 +52,10 @@ export interface RenderContent { styleSheets: Stylesheet[]; inlineScripts: string[]; inlineStyleSheets: string[]; - bodyContent: { - className: string[]; - beforeRoot: string[]; - root?: string; - afterRoot: string[]; - }; + bodyContent: BodyContent; + helpers: RenderHelpers; + icon: Icon; + title: string; } export interface RenderHelpers { @@ -59,6 +65,7 @@ export interface RenderHelpers { renderInlineStyle(content: string): string; renderMeta(meta: Meta): string; renderLink(link: Link): string; + attrs(obj: Attributes): string; } export interface Plugin { name: Name; @@ -71,6 +78,7 @@ export interface Plugin { } export interface RenderParams extends CommonOptions { data?: Data; + skipRenderDataScript?: boolean; icon?: Icon; nonce?: string; // content diff --git a/src/utils.ts b/src/utils.ts index b37d235..6202a4e 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -56,6 +56,7 @@ export function getRenderHelpers(params: {nonce?: string}): RenderHelpers { renderInlineStyle, renderMeta, renderLink, + attrs, }; } diff --git a/src/utils/generateRenderContent.ts b/src/utils/generateRenderContent.ts new file mode 100644 index 0000000..9517501 --- /dev/null +++ b/src/utils/generateRenderContent.ts @@ -0,0 +1,120 @@ +import htmlescape from 'htmlescape'; + +import {BodyContent, Icon, Meta, Plugin, RenderContent, RenderParams} from '../types'; +import {getRenderHelpers, hasProperty} from '../utils'; + +function getRootClassName(theme?: string) { + if (!theme) { + return []; + } + const classes = ['g-root']; + if (theme) { + classes.push(`g-root_theme_${theme}`); + } + return classes; +} + +const defaultIcon: Icon = { + type: 'image/png', + sizes: '16x16', + href: '/favicon.png', +}; + +const defaultMeta: Meta[] = [ + { + name: 'viewport', + content: 'width=device-width, initial-scale=1.0', + }, +]; + +/** + * Generates the content to be rendered in an HTML page. + * @param plugins - An array of plugins. + * @param params - An object containing various parameters for rendering the content. + * @returns An object containing the generated content for an HTML page. + */ +export function generateRenderContent( + plugins: Plugins | undefined, + params: RenderParams, +): RenderContent { + const helpers = getRenderHelpers(params); + const htmlAttributes: Record = {}; + const meta = params.meta ?? []; + // in terms of sets: meta = params.meta ∪ (defaultMeta ∖ params.meta) + defaultMeta.forEach((defaultMetaItem) => { + if (!meta.find(({name}) => name === defaultMetaItem.name)) { + meta.push(defaultMetaItem); + } + }); + const styleSheets = params.styleSheets || []; + const scripts = params.scripts || []; + const inlineStyleSheets = params.inlineStyleSheets || []; + const inlineScripts = params.inlineScripts || []; + const links = params.links || []; + + if (params.skipRenderDataScript) { + inlineScripts.unshift(`window.__DATA__ = ${htmlescape(params.data || {})};`); + } + + const content = params.bodyContent ?? {}; + const {theme, className} = content; + const bodyClasses = Array.from( + new Set([...getRootClassName(theme), ...(className ? className.split(' ') : [])]), + ); + const bodyContent: BodyContent = { + attributes: { + class: bodyClasses.filter(Boolean).join(' '), + }, + className: bodyClasses, + root: content.root, + beforeRoot: content.beforeRoot ? [content.beforeRoot] : [], + afterRoot: content.afterRoot ? [content.afterRoot] : [], + }; + + const icon: Icon = { + ...defaultIcon, + ...params.icon, + }; + + const {lang, isMobile, title, pluginsOptions = {}} = params; + for (const plugin of plugins ?? []) { + plugin.apply({ + options: hasProperty(pluginsOptions, plugin.name) + ? pluginsOptions[plugin.name] + : undefined, + renderContent: { + htmlAttributes, + meta, + links, + styleSheets, + scripts, + inlineStyleSheets, + inlineScripts, + bodyContent, + helpers, + icon, + title, + }, + commonOptions: {title, lang, isMobile}, + utils: helpers, + }); + } + + if (lang) { + htmlAttributes.lang = lang; + } + + return { + htmlAttributes, + meta, + links, + styleSheets, + scripts, + inlineStyleSheets, + inlineScripts, + bodyContent, + helpers, + icon, + title, + }; +} diff --git a/src/utils/renderBodyContent.ts b/src/utils/renderBodyContent.ts new file mode 100644 index 0000000..36fa0da --- /dev/null +++ b/src/utils/renderBodyContent.ts @@ -0,0 +1,13 @@ +import {RenderContent} from '../types'; + +export function renderBodyContent(content: RenderContent): string { + const {bodyContent} = content; + + return ` + ${bodyContent.beforeRoot.join('\n')} +
+ ${bodyContent.root ?? ''} +
+ ${bodyContent.afterRoot.join('\n')} + `.trim(); +} diff --git a/src/utils/renderHeadContent.ts b/src/utils/renderHeadContent.ts new file mode 100644 index 0000000..3b1a5b7 --- /dev/null +++ b/src/utils/renderHeadContent.ts @@ -0,0 +1,34 @@ +import {RenderContent} from '../types'; + +export function renderHeadContent(content: RenderContent): string { + const { + icon, + scripts, + helpers, + links, + meta, + styleSheets, + inlineStyleSheets, + inlineScripts, + title, + } = content; + + return ` + + ${title} + + ${[ + ...scripts.map(({src, crossOrigin}) => + helpers.renderLink({href: src, crossOrigin, rel: 'preload', as: 'script'}), + ), + ...links.map((link) => helpers.renderLink(link)), + ...meta.map((metaData) => helpers.renderMeta(metaData)), + ...styleSheets.map((style) => helpers.renderStyle(style)), + ...inlineStyleSheets.map((text) => helpers.renderInlineStyle(text)), + ...scripts.map((script) => helpers.renderScript(script)), + ...inlineScripts.map((text) => helpers.renderInlineScript(text)), + ] + .filter(Boolean) + .join('\n')} + `.trim(); +}