site/node_modules/preact-render-to-string/src/index.js

675 lines
16 KiB
JavaScript
Raw Normal View History

2024-10-14 08:09:33 +02:00
import {
encodeEntities,
styleObjToCss,
UNSAFE_NAME,
NAMESPACE_REPLACE_REGEX,
HTML_LOWER_CASE,
SVG_CAMEL_CASE
} from './util.js';
import { options, h, Fragment } from 'preact';
import {
CHILDREN,
COMMIT,
COMPONENT,
DIFF,
DIFFED,
DIRTY,
NEXT_STATE,
PARENT,
RENDER,
SKIP_EFFECTS,
VNODE
} from './constants.js';
/** @typedef {import('preact').VNode} VNode */
const EMPTY_ARR = [];
const isArray = Array.isArray;
const assign = Object.assign;
// Global state for the current render pass
let beforeDiff, afterDiff, renderHook, ummountHook;
/**
* Render Preact JSX + Components to an HTML string.
* @param {VNode} vnode JSX Element / VNode to render
* @param {Object} [context={}] Initial root context object
* @returns {string} serialized HTML
*/
export function renderToString(vnode, context) {
// Performance optimization: `renderToString` is synchronous and we
// therefore don't execute any effects. To do that we pass an empty
// array to `options._commit` (`__c`). But we can go one step further
// and avoid a lot of dirty checks and allocations by setting
// `options._skipEffects` (`__s`) too.
const previousSkipEffects = options[SKIP_EFFECTS];
options[SKIP_EFFECTS] = true;
// store options hooks once before each synchronous render call
beforeDiff = options[DIFF];
afterDiff = options[DIFFED];
renderHook = options[RENDER];
ummountHook = options.unmount;
const parent = h(Fragment, null);
parent[CHILDREN] = [vnode];
try {
return _renderToString(
vnode,
context || EMPTY_OBJ,
false,
undefined,
parent,
false
);
} catch (e) {
if (e.then) {
throw new Error('Use "renderToStringAsync" for suspenseful rendering.');
}
throw e;
} finally {
// options._commit, we don't schedule any effects in this library right now,
// so we can pass an empty queue to this hook.
if (options[COMMIT]) options[COMMIT](vnode, EMPTY_ARR);
options[SKIP_EFFECTS] = previousSkipEffects;
EMPTY_ARR.length = 0;
}
}
/**
* Render Preact JSX + Components to an HTML string.
* @param {VNode} vnode JSX Element / VNode to render
* @param {Object} [context={}] Initial root context object
* @returns {string} serialized HTML
*/
export async function renderToStringAsync(vnode, context) {
// Performance optimization: `renderToString` is synchronous and we
// therefore don't execute any effects. To do that we pass an empty
// array to `options._commit` (`__c`). But we can go one step further
// and avoid a lot of dirty checks and allocations by setting
// `options._skipEffects` (`__s`) too.
const previousSkipEffects = options[SKIP_EFFECTS];
options[SKIP_EFFECTS] = true;
// store options hooks once before each synchronous render call
beforeDiff = options[DIFF];
afterDiff = options[DIFFED];
renderHook = options[RENDER];
ummountHook = options.unmount;
const parent = h(Fragment, null);
parent[CHILDREN] = [vnode];
try {
const rendered = _renderToString(
vnode,
context || EMPTY_OBJ,
false,
undefined,
parent,
true
);
if (Array.isArray(rendered)) {
let count = 0;
let resolved = rendered;
// Resolving nested Promises with a maximum depth of 25
while (
resolved.some((element) => typeof element.then === 'function') &&
count++ < 25
) {
resolved = (await Promise.all(resolved)).flat();
}
return resolved.join('');
}
return rendered;
} finally {
// options._commit, we don't schedule any effects in this library right now,
// so we can pass an empty queue to this hook.
if (options[COMMIT]) options[COMMIT](vnode, EMPTY_ARR);
options[SKIP_EFFECTS] = previousSkipEffects;
EMPTY_ARR.length = 0;
}
}
// Installed as setState/forceUpdate for function components
function markAsDirty() {
this.__d = true;
}
const EMPTY_OBJ = {};
/**
* @param {VNode} vnode
* @param {Record<string, unknown>} context
*/
function renderClassComponent(vnode, context) {
let type = /** @type {import("preact").ComponentClass<typeof vnode.props>} */ (vnode.type);
let isMounting = true;
let c;
if (vnode[COMPONENT]) {
isMounting = false;
c = vnode[COMPONENT];
c.state = c[NEXT_STATE];
} else {
c = new type(vnode.props, context);
}
vnode[COMPONENT] = c;
c[VNODE] = vnode;
c.props = vnode.props;
c.context = context;
// turn off stateful re-rendering:
c[DIRTY] = true;
if (c.state == null) c.state = EMPTY_OBJ;
if (c[NEXT_STATE] == null) {
c[NEXT_STATE] = c.state;
}
if (type.getDerivedStateFromProps) {
c.state = assign(
{},
c.state,
type.getDerivedStateFromProps(c.props, c.state)
);
} else if (isMounting && c.componentWillMount) {
c.componentWillMount();
// If the user called setState in cWM we need to flush pending,
// state updates. This is the same behaviour in React.
c.state = c[NEXT_STATE] !== c.state ? c[NEXT_STATE] : c.state;
} else if (!isMounting && c.componentWillUpdate) {
c.componentWillUpdate();
}
if (renderHook) renderHook(vnode);
return c.render(c.props, c.state, context);
}
/**
* Recursively render VNodes to HTML.
* @param {VNode|any} vnode
* @param {any} context
* @param {boolean} isSvgMode
* @param {any} selectValue
* @param {VNode} parent
* @param {boolean} asyncMode
* @returns {string | Promise<string> | (string | Promise<string>)[]}
*/
function _renderToString(
vnode,
context,
isSvgMode,
selectValue,
parent,
asyncMode
) {
// Ignore non-rendered VNodes/values
if (vnode == null || vnode === true || vnode === false || vnode === '') {
return '';
}
// Text VNodes: escape as HTML
if (typeof vnode !== 'object') {
if (typeof vnode === 'function') return '';
return encodeEntities(vnode + '');
}
// Recurse into children / Arrays
if (isArray(vnode)) {
let rendered = '',
renderArray;
parent[CHILDREN] = vnode;
for (let i = 0; i < vnode.length; i++) {
let child = vnode[i];
if (child == null || typeof child === 'boolean') continue;
const childRender = _renderToString(
child,
context,
isSvgMode,
selectValue,
parent,
asyncMode
);
if (typeof childRender === 'string') {
rendered += childRender;
} else {
renderArray = renderArray || [];
if (rendered) renderArray.push(rendered);
rendered = '';
if (Array.isArray(childRender)) {
renderArray.push(...childRender);
} else {
renderArray.push(childRender);
}
}
}
if (renderArray) {
if (rendered) renderArray.push(rendered);
return renderArray;
}
return rendered;
}
// VNodes have {constructor:undefined} to prevent JSON injection:
if (vnode.constructor !== undefined) return '';
vnode[PARENT] = parent;
if (beforeDiff) beforeDiff(vnode);
let type = vnode.type,
props = vnode.props,
cctx = context,
contextType,
rendered,
component;
// Invoke rendering on Components
if (typeof type === 'function') {
if (type === Fragment) {
// Serialized precompiled JSX.
if (props.tpl) {
let out = '';
for (let i = 0; i < props.tpl.length; i++) {
out += props.tpl[i];
if (props.exprs && i < props.exprs.length) {
const value = props.exprs[i];
if (value == null) continue;
// Check if we're dealing with a vnode or an array of nodes
if (
typeof value === 'object' &&
(value.constructor === undefined || isArray(value))
) {
out += _renderToString(
value,
context,
isSvgMode,
selectValue,
vnode,
asyncMode
);
} else {
// Values are pre-escaped by the JSX transform
out += value;
}
}
}
return out;
} else if (props.UNSTABLE_comment) {
// Fragments are the least used components of core that's why
// branching here for comments has the least effect on perf.
return '<!--' + encodeEntities(props.UNSTABLE_comment || '') + '-->';
}
rendered = props.children;
} else {
contextType = type.contextType;
if (contextType != null) {
let provider = context[contextType.__c];
cctx = provider ? provider.props.value : contextType.__;
}
if (type.prototype && typeof type.prototype.render === 'function') {
rendered = /**#__NOINLINE__**/ renderClassComponent(vnode, cctx);
component = vnode[COMPONENT];
} else {
component = {
__v: vnode,
props,
context: cctx,
// silently drop state updates
setState: markAsDirty,
forceUpdate: markAsDirty,
__d: true,
// hooks
__h: []
};
vnode[COMPONENT] = component;
// If a hook invokes setState() to invalidate the component during rendering,
// re-render it up to 25 times to allow "settling" of memoized states.
// Note:
// This will need to be updated for Preact 11 to use internal.flags rather than component._dirty:
// https://github.com/preactjs/preact/blob/d4ca6fdb19bc715e49fd144e69f7296b2f4daa40/src/diff/component.js#L35-L44
let count = 0;
while (component[DIRTY] && count++ < 25) {
component[DIRTY] = false;
if (renderHook) renderHook(vnode);
rendered = type.call(component, props, cctx);
}
component[DIRTY] = true;
}
if (component.getChildContext != null) {
context = assign({}, context, component.getChildContext());
}
if (
(type.getDerivedStateFromError || component.componentDidCatch) &&
options.errorBoundaries
) {
let str = '';
// When a component returns a Fragment node we flatten it in core, so we
// need to mirror that logic here too
let isTopLevelFragment =
rendered != null &&
rendered.type === Fragment &&
rendered.key == null;
rendered = isTopLevelFragment ? rendered.props.children : rendered;
try {
str = _renderToString(
rendered,
context,
isSvgMode,
selectValue,
vnode,
asyncMode
);
return str;
} catch (err) {
if (type.getDerivedStateFromError) {
component[NEXT_STATE] = type.getDerivedStateFromError(err);
}
if (component.componentDidCatch) {
component.componentDidCatch(err, {});
}
if (component[DIRTY]) {
rendered = renderClassComponent(vnode, context);
component = vnode[COMPONENT];
if (component.getChildContext != null) {
context = assign({}, context, component.getChildContext());
}
let isTopLevelFragment =
rendered != null &&
rendered.type === Fragment &&
rendered.key == null;
rendered = isTopLevelFragment ? rendered.props.children : rendered;
str = _renderToString(
rendered,
context,
isSvgMode,
selectValue,
vnode,
asyncMode
);
}
return str;
} finally {
if (afterDiff) afterDiff(vnode);
vnode[PARENT] = null;
if (ummountHook) ummountHook(vnode);
}
}
}
// When a component returns a Fragment node we flatten it in core, so we
// need to mirror that logic here too
let isTopLevelFragment =
rendered != null &&
rendered.type === Fragment &&
rendered.key == null &&
rendered.props.tpl == null;
rendered = isTopLevelFragment ? rendered.props.children : rendered;
try {
// Recurse into children before invoking the after-diff hook
const str = _renderToString(
rendered,
context,
isSvgMode,
selectValue,
vnode,
asyncMode
);
if (afterDiff) afterDiff(vnode);
vnode[PARENT] = null;
if (ummountHook) ummountHook(vnode);
return str;
} catch (error) {
if (!asyncMode) throw error;
if (!error || typeof error.then !== 'function') throw error;
const renderNestedChildren = () => {
try {
return _renderToString(
rendered,
context,
isSvgMode,
selectValue,
vnode,
asyncMode
);
} catch (e) {
if (!e || typeof e.then !== 'function') throw e;
return e.then(
() =>
_renderToString(
rendered,
context,
isSvgMode,
selectValue,
vnode,
asyncMode
),
() => renderNestedChildren()
);
}
};
return error.then(() => renderNestedChildren());
}
}
// Serialize Element VNodes to HTML
let s = '<' + type,
html = '',
children;
for (let name in props) {
let v = props[name];
switch (name) {
case 'children':
children = v;
continue;
// VDOM-specific props
case 'key':
case 'ref':
case '__self':
case '__source':
continue;
// prefer for/class over htmlFor/className
case 'htmlFor':
if ('for' in props) continue;
name = 'for';
break;
case 'className':
if ('class' in props) continue;
name = 'class';
break;
// Form element reflected properties
case 'defaultChecked':
name = 'checked';
break;
case 'defaultSelected':
name = 'selected';
break;
// Special value attribute handling
case 'defaultValue':
case 'value':
name = 'value';
switch (type) {
// <textarea value="a&b"> --> <textarea>a&amp;b</textarea>
case 'textarea':
children = v;
continue;
// <select value> is serialized as a selected attribute on the matching option child
case 'select':
selectValue = v;
continue;
// Add a selected attribute to <option> if its value matches the parent <select> value
case 'option':
if (selectValue == v && !('selected' in props)) {
s = s + ' selected';
}
break;
}
break;
case 'dangerouslySetInnerHTML':
html = v && v.__html;
continue;
// serialize object styles to a CSS string
case 'style':
if (typeof v === 'object') {
v = styleObjToCss(v);
}
break;
case 'acceptCharset':
name = 'accept-charset';
break;
case 'httpEquiv':
name = 'http-equiv';
break;
default: {
if (NAMESPACE_REPLACE_REGEX.test(name)) {
name = name.replace(NAMESPACE_REPLACE_REGEX, '$1:$2').toLowerCase();
} else if (UNSAFE_NAME.test(name)) {
continue;
} else if ((name[4] === '-' || name === 'draggable') && v != null) {
// serialize boolean aria-xyz or draggable attribute values as strings
// `draggable` is an enumerated attribute and not Boolean. A value of `true` or `false` is mandatory
// https://developer.mozilla.org/en-US/docs/Web/HTML/Global_attributes/draggable
v += '';
} else if (isSvgMode) {
if (SVG_CAMEL_CASE.test(name)) {
name =
name === 'panose1'
? 'panose-1'
: name.replace(/([A-Z])/g, '-$1').toLowerCase();
}
} else if (HTML_LOWER_CASE.test(name)) {
name = name.toLowerCase();
}
}
}
// write this attribute to the buffer
if (v != null && v !== false && typeof v !== 'function') {
if (v === true || v === '') {
s = s + ' ' + name;
} else {
s = s + ' ' + name + '="' + encodeEntities(v + '') + '"';
}
}
}
if (UNSAFE_NAME.test(type)) {
// this seems to performs a lot better than throwing
// return '<!-- -->';
throw new Error(`${type} is not a valid HTML tag name in ${s}>`);
}
if (html) {
// dangerouslySetInnerHTML defined this node's contents
} else if (typeof children === 'string') {
// single text child
html = encodeEntities(children);
} else if (children != null && children !== false && children !== true) {
// recurse into this element VNode's children
let childSvgMode =
type === 'svg' || (type !== 'foreignObject' && isSvgMode);
html = _renderToString(
children,
context,
childSvgMode,
selectValue,
vnode,
asyncMode
);
}
if (afterDiff) afterDiff(vnode);
vnode[PARENT] = null;
if (ummountHook) ummountHook(vnode);
// Emit self-closing tag for empty void elements:
if (!html && SELF_CLOSING.has(type)) {
return s + '/>';
}
const endTag = '</' + type + '>';
const startTag = s + '>';
if (Array.isArray(html)) return [startTag, ...html, endTag];
else if (typeof html !== 'string') return [startTag, html, endTag];
return startTag + html + endTag;
}
const SELF_CLOSING = new Set([
'area',
'base',
'br',
'col',
'command',
'embed',
'hr',
'img',
'input',
'keygen',
'link',
'meta',
'param',
'source',
'track',
'wbr'
]);
export default renderToString;
export const render = renderToString;
export const renderToStaticMarkup = renderToString;