export const HtmlEscapedCallbackPhase = { Stringify: 1, BeforeStream: 2, Stream: 3, } as const type HtmlEscapedCallbackOpts = { buffer?: [string] phase: typeof HtmlEscapedCallbackPhase[keyof typeof HtmlEscapedCallbackPhase] context: object // An object unique to each JSX tree. This object is used as the WeakMap key. } export type HtmlEscapedCallback = (opts: HtmlEscapedCallbackOpts) => Promise | undefined export type HtmlEscaped = { isEscaped: true callbacks?: HtmlEscapedCallback[] } export type HtmlEscapedString = string & HtmlEscaped /** * StringBuffer contains string and Promise alternately * The length of the array will be odd, the odd numbered element will be a string, * and the even numbered element will be a Promise. * When concatenating into a single string, it must be processed from the tail. * @example * [ * 'framework.', * Promise.resolve('ultra fast'), * 'a ', * Promise.resolve('is '), * 'Hono', * ] */ export type StringBuffer = (string | Promise)[] import { raw } from '../helper/html/index.ts' // The `escapeToBuffer` implementation is based on code from the MIT licensed `react-dom` package. // https://github.com/facebook/react/blob/main/packages/react-dom-bindings/src/server/escapeTextForBrowser.js const escapeRe = /[&<>'"]/ export const stringBufferToString = async (buffer: StringBuffer): Promise => { let str = '' const callbacks: HtmlEscapedCallback[] = [] for (let i = buffer.length - 1; ; i--) { str += buffer[i] i-- if (i < 0) { break } let r = await buffer[i] if (typeof r === 'object') { callbacks.push(...((r as HtmlEscapedString).callbacks || [])) } const isEscaped = (r as HtmlEscapedString).isEscaped r = await (typeof r === 'object' ? (r as HtmlEscapedString).toString() : r) if (typeof r === 'object') { callbacks.push(...((r as HtmlEscapedString).callbacks || [])) } if ((r as HtmlEscapedString).isEscaped ?? isEscaped) { str += r } else { const buf = [str] escapeToBuffer(r, buf) str = buf[0] } } return raw(str, callbacks) } export const escapeToBuffer = (str: string, buffer: StringBuffer): void => { const match = str.search(escapeRe) if (match === -1) { buffer[0] += str return } let escape let index let lastIndex = 0 for (index = match; index < str.length; index++) { switch (str.charCodeAt(index)) { case 34: // " escape = '"' break case 39: // ' escape = ''' break case 38: // & escape = '&' break case 60: // < escape = '<' break case 62: // > escape = '>' break default: continue } buffer[0] += str.substring(lastIndex, index) + escape lastIndex = index + 1 } buffer[0] += str.substring(lastIndex, index) } export const resolveCallback = async ( str: string | HtmlEscapedString, phase: typeof HtmlEscapedCallbackPhase[keyof typeof HtmlEscapedCallbackPhase], preserveCallbacks: boolean, context: object, buffer?: [string] ): Promise => { const callbacks = (str as HtmlEscapedString).callbacks as HtmlEscapedCallback[] if (!callbacks?.length) { return Promise.resolve(str) } if (buffer) { buffer[0] += str } else { buffer = [str] } const resStr = Promise.all(callbacks.map((c) => c({ phase, buffer, context }))).then((res) => Promise.all( res // eslint-disable-next-line @typescript-eslint/no-explicit-any .filter(Boolean as any) .map((str) => resolveCallback(str, phase, false, context, buffer)) ).then(() => (buffer as [string])[0]) ) if (preserveCallbacks) { return raw(await resStr, callbacks) } else { return resStr } }