/** * DOM-free SSR renderer. * * Operates on the virtual node tree produced by `html-parser.ts` and * evaluates `bq-*` directives without depending on any browser DOM API. * Runs unmodified on Bun, Deno and Node and is the default backend used by * `renderToString()` whenever the global `DOMParser` is not configured to * take precedence. * * @module bquery/ssr * @internal */ import { DANGEROUS_ATTR_PREFIXES, DANGEROUS_PROTOCOLS, DANGEROUS_TAGS, DEFAULT_ALLOWED_ATTRIBUTES, DEFAULT_ALLOWED_TAGS, RESERVED_IDS, } from '../security/constants'; import type { BindingContext } from '../view/types'; import { classifyUnsupportedDirective, collectOnEvents, directiveHead, reportUnsupportedDirective, resolveModelReflection, SSR_ON_MARKER_ATTR, type SSRDirectiveMode, type UnsupportedDirectiveStrategy, } from './directive-support'; import { evaluateExpression } from './expression'; import { cheapHash, collectDirectiveSignatureFromAttrs, HYDRATION_HASH_ATTR } from './hash'; import { cloneNode, escapeText, parseTemplate, serializeTree, type SSRElement, type SSRNode, } from './html-parser'; const isUnsafeUrlAttribute = (name: string): boolean => { const n = name.toLowerCase(); return ( n === 'href' || n === 'src' || n === 'xlink:href' || n === 'formaction' || n === 'action' || n === 'poster' || n === 'background' || n === 'cite' || n === 'data' ); }; const sanitizeUrlForProtocolCheck = (value: string): string => value .trim() .replace(/[\u0000-\u001F\u007F]+/g, '') .replace(/[\u200B-\u200D\uFEFF\u2028\u2029]+/g, '') .replace(/\\u[\da-fA-F]{4}/g, '') .replace(/\s+/g, '') .toLowerCase(); const isUnsafeUrlValue = (value: string): boolean => { const normalized = sanitizeUrlForProtocolCheck(value); return DANGEROUS_PROTOCOLS.some((protocol) => normalized.startsWith(protocol)); }; const URL_PROTOCOL_PATTERN = /^[a-zA-Z][a-zA-Z0-9+.-]*:/; const REL_SPLIT_PATTERN = /\s+/; const isAllowedHtmlAttribute = (name: string): boolean => { const lowerName = name.toLowerCase(); for (const prefix of DANGEROUS_ATTR_PREFIXES) { if (lowerName.startsWith(prefix)) return false; } if (lowerName.startsWith('data-')) return true; if (lowerName.startsWith('aria-')) return true; return DEFAULT_ALLOWED_ATTRIBUTES.has(lowerName); }; const isSafeHtmlIdOrName = (value: string): boolean => !RESERVED_IDS.has(value.toLowerCase().trim()); const isExternalHtmlUrl = (url: string): boolean => { try { const trimmedUrl = url.trim(); if (trimmedUrl.startsWith('//')) return true; const lowerUrl = trimmedUrl.toLowerCase(); if (!lowerUrl.startsWith('http://') && !lowerUrl.startsWith('https://')) { if (!URL_PROTOCOL_PATTERN.test(trimmedUrl)) { return false; } return true; } if (typeof window === 'undefined' || !window.location) { return true; } const urlObj = new URL(trimmedUrl, window.location.href); return urlObj.origin !== window.location.origin; } catch { return true; } }; interface RenderOpts { prefix: string; stripDirectives: boolean; /** Whether to add `data-bq-h` mismatch hashes to elements with directives. */ annotateHydration: boolean; /** Directive set to evaluate: `'static'` (default) or `'full'`. */ mode: SSRDirectiveMode; /** How to report directives that cannot be server-rendered. */ onUnsupported: UnsupportedDirectiveStrategy; } /** * `cheapHash` and `HYDRATION_HASH_ATTR` are imported from `./hash` so the * server-side annotation and client-side verifier stay in lock-step. */ const setClass = (el: SSRElement, cls: string): void => { if (!cls) return; const existing = el.attributes['class']; const merged = existing ? `${existing} ${cls}` : cls; if (!('class' in el.attributes)) el.attributeOrder.push('class'); el.attributes['class'] = merged; }; const setStyle = (el: SSRElement, declarations: Record): void => { let css = el.attributes['style'] ?? ''; for (const [prop, val] of Object.entries(declarations)) { if (val === undefined || val === null || val === false) continue; const cssProp = prop.replace(/([A-Z])/g, '-$1').toLowerCase(); if (css && !css.endsWith(';')) css += '; '; css += `${cssProp}: ${String(val)};`; } if (!('style' in el.attributes)) el.attributeOrder.push('style'); el.attributes['style'] = css; }; const removeAttr = (el: SSRElement, name: string): void => { if (name in el.attributes) { delete el.attributes[name]; const idx = el.attributeOrder.indexOf(name); if (idx !== -1) el.attributeOrder.splice(idx, 1); } }; const setAttr = (el: SSRElement, name: string, value: string): void => { if (!(name in el.attributes)) el.attributeOrder.push(name); el.attributes[name] = value; }; const collectDirectiveSignature = (el: SSRElement, prefix: string): string => collectDirectiveSignatureFromAttrs(el.attributeOrder, el.attributes, prefix); const parseForExpression = ( expression: string ): { itemName: string; indexName?: string; listExpr: string } | null => { const match = expression.match(/^\(?(\w+)(?:\s*,\s*(\w+))?\)?\s+in\s+(\S.*)$/); if (!match) return null; return { itemName: match[1], indexName: match[2] || undefined, listExpr: match[3].trim(), }; }; const stripDirectiveAttributes = (node: SSRNode, prefix: string): void => { if (node.type !== 'element') { if (node.type === 'fragment') { for (const child of node.children) stripDirectiveAttributes(child, prefix); } return; } for (const name of [...node.attributeOrder]) { if (name.startsWith(`${prefix}-`) || name.startsWith(':')) { removeAttr(node, name); } } for (const child of node.children) stripDirectiveAttributes(child, prefix); }; const setText = (el: SSRElement, value: string): void => { el.children = [{ type: 'text', value }]; }; /** Recursively collects `