UNPKG

@peak-js/ssr

Version:

Server-side rendering for Peak.js framework

700 lines (567 loc) 20.3 kB
import path from 'path' import fs from 'fs' import { initializeDOM } from './dom.js' import { loadComponent } from './loader.js' const { document } = initializeDOM() // global component directories for resolution let componentDirs = ['components', 'views'] export async function renderComponent(filePath, data = {}, options = {}) { const component = await loadComponent(filePath) // create a component instance with the provided data const instance = new component.ComponentClass() // set SSR context flag instance.$ssr = true // merge data into instance before running initialize() for (const [key, value] of Object.entries(data)) { instance[key] = value } // run SSR lifecycle method if it exists if (typeof instance.ssr === 'function') { await instance.ssr() } // run initialize lifecycle method if it exists if (typeof instance.initialize === 'function') { instance.initialize() } // detect if this is a full HTML document (contains <html> tag) const isFullDocument = component.template.includes('<html') // detect if the top-level element is a layout component const layoutMatch = component.template.trim().match(/^\s*<(x-[^>\s]+)/i) const isLayoutWrapper = layoutMatch && layoutMatch[1].includes('layout') if (isLayoutWrapper) { // this view wraps a layout component - render the layout directly const layoutTagName = layoutMatch[1] return await renderLayoutWrapper(component, instance, data, layoutTagName, options) } let rendered, styles if (isFullDocument) { // for full HTML documents, we need to parse the entire document differently // create a document fragment to hold the HTML const tempDiv = document.createElement('div') tempDiv.innerHTML = component.template // find the html element const htmlEl = tempDiv.querySelector('html') if (htmlEl) { // set component directories if provided if (options.componentDirs) { componentDirs = options.componentDirs } // render the HTML element directly rendered = await renderSSR(htmlEl, instance, instance) // for full documents, inject styles into the head if they exist if (component.style) { const head = rendered.querySelector('head') if (head) { const styleEl = document.createElement('style') styleEl.setAttribute('data-peak-component', component.tagName) styleEl.textContent = component.style head.appendChild(styleEl) } } // return the full HTML document as a string (no wrapper div) return { html: `<!DOCTYPE html>\n${rendered.outerHTML}`, styles: '', // styles already injected tagName: component.tagName, isFullDocument: true, instance } } } // for regular components, extract content from template element const templateWrapper = document.createElement('div') templateWrapper.innerHTML = component.template // get the actual template content (skip the <template> wrapper) const templateEl = templateWrapper.querySelector('template') const template = document.createElement('div') if (templateEl && templateEl.content) { // clone the template content template.appendChild(templateEl.content.cloneNode(true)) } else { // fallback if no template wrapper template.innerHTML = component.template } // set component directories if provided if (options.componentDirs) { componentDirs = options.componentDirs } // render the component with SSR context rendered = await renderSSR(template, instance, instance) // generate scoped styles for regular components styles = component.style ? generateScopedStyles(component.style, component.tagName) : '' return { html: rendered.outerHTML, styles, tagName: component.tagName, instance } } async function renderLayoutWrapper(component, instance, data, layoutTagName, options) { // set component directories if provided if (options.componentDirs) { componentDirs = options.componentDirs } // find the layout component file let layoutPath = null for (const dir of componentDirs) { const possiblePath = path.resolve(dir, `${layoutTagName}.html`) try { fs.accessSync(possiblePath) layoutPath = possiblePath break } catch (e) { // File doesn't exist, continue } } if (!layoutPath) { console.warn(`[peak-ssr] Layout component not found: ${layoutTagName}`) return { html: '', styles: '', tagName: component.tagName } } // parse the view template to extract slot content and layout props const tempDiv = document.createElement('div') tempDiv.innerHTML = component.template const layoutElement = tempDiv.querySelector(layoutTagName) if (!layoutElement) { console.warn(`[peak-ssr] Layout element not found in template`) return { html: '', styles: '', tagName: component.tagName } } // extract attributes as props for the layout const layoutProps = {} for (const attr of [...(layoutElement.attributes || [])]) { if (attr.name.startsWith(':')) { // dynamic attribute const propName = attr.name.slice(1) layoutProps[propName] = evalInSSRContext(instance, attr.value, data) } else { // static attribute if (attr.value !== '[object Object]') { layoutProps[attr.name] = attr.value } } } // capture slot content from inside the layout element and render it const slotContent = layoutElement.innerHTML.trim() // render the slot content with the view's context first const tempSlotDiv = document.createElement('div') tempSlotDiv.innerHTML = slotContent const renderedSlotDiv = await renderSSR(tempSlotDiv, instance, data) const namedSlots = parseNamedSlots(renderedSlotDiv.innerHTML) // render the layout component with the view's data and slot content const layoutData = { ...data, ...layoutProps, _slotContent: namedSlots.default || '', _namedSlots: namedSlots } return await renderComponent(layoutPath, layoutData, options) } function parseNamedSlots(html) { const tempDiv = document.createElement('div') tempDiv.innerHTML = html const slots = { default: '' } const defaultContent = [] // Process all child nodes for (const child of [...tempDiv.childNodes]) { if (child.nodeType === 1 && child.tagName === 'TEMPLATE' && child.hasAttribute('slot')) { // This is a named slot const slotName = child.getAttribute('slot') slots[slotName] = child.innerHTML } else { // This is default slot content defaultContent.push(child.outerHTML || child.textContent) } } slots.default = defaultContent.join('') return slots } async function renderCustomComponent(el, contextData) { const tagName = el.tagName.toLowerCase() // try to find the component file let componentPath = null // first try direct path resolution for (const dir of componentDirs) { const possiblePath = path.resolve(dir, `${tagName}.html`) try { fs.accessSync(possiblePath) componentPath = possiblePath break } catch (e) { // File doesn't exist, continue } } if (!componentPath) { console.warn(`[peak-ssr] Component not found: ${tagName}`) return } // extract attributes as props const props = {} for (const attr of [...(el.attributes || [])]) { if (attr.name.startsWith(':')) { // dynamic attribute const propName = attr.name.slice(1) props[propName] = evalInSSRContext(contextData, attr.value, contextData) } else { // static attributes if (attr.value === '[object Object]') continue props[attr.name] = attr.value } } // capture slot content (innerHTML of the custom element) const slotContent = el.innerHTML.trim() // parse named slots from the slot content const namedSlots = parseNamedSlots(slotContent) // render the component with props and slot content const componentData = { ...contextData, ...props, _slotContent: namedSlots.default || '', _namedSlots: namedSlots, _componentEventHandlers: contextData._componentEventHandlers || [], } const result = await renderComponent(componentPath, componentData) // parse the rendered HTML to get the actual component element const tempDiv = document.createElement('div') tempDiv.innerHTML = result.html // get the first real element inside the wrapper div (skip text nodes) let componentEl = null if (result.isFullDocument) { // for full documents, we can't embed them in other components console.warn(`[peak-ssr] Cannot embed full document component ${tagName} inside another component`) return } for (const child of tempDiv.firstChild?.childNodes || []) { if (child.nodeType === 1) { // Element node componentEl = child break } } if (componentEl) { const hydrationData = serializeComponentState(result.instance, componentData) if (hydrationData) { el.setAttribute('data-peak-ssr', hydrationData) } el.setAttribute('data-peak-component', tagName) el.innerHTML = componentEl.outerHTML } } async function renderSSR(template, ctx, data) { const root = template.cloneNode(true) // track elements with event handlers for this component const componentEventHandlers = [] async function _render(el, state = {}, contextData = data) { if (!el || el.nodeType !== 1) return el // handle x-text directive if (el.hasAttribute?.('x-text') && !el.hasAttribute?.('x-for')) { const expr = el.getAttribute('x-text') const value = evalInSSRContext(ctx, expr, contextData) el.textContent = String(value ?? '') el.removeAttribute('x-text') } // handle x-html directive if (el.hasAttribute?.('x-html') && !el.hasAttribute?.('x-for')) { const expr = el.getAttribute('x-html') const value = evalInSSRContext(ctx, expr, contextData) el.innerHTML = String(value ?? '') el.removeAttribute('x-html') } // handle x-if directive if (el.hasAttribute?.('x-if')) { const expr = el.getAttribute('x-if') const shouldRender = Boolean(evalInSSRContext(ctx, expr, contextData)) state.pass = shouldRender el.removeAttribute('x-if') if (!shouldRender) { el.remove() return null } if (el.tagName === 'TEMPLATE') { const rendered = await renderSSR(el.content, ctx, data) el.replaceWith(rendered) return rendered } } // Handle x-else-if directive if (el.hasAttribute?.('x-else-if')) { if (state.pass === undefined) { console.warn('[peak-ssr] Invalid x-else-if without preceding x-if') } if (state.pass) { el.remove() return null } const expr = el.getAttribute('x-else-if') const shouldRender = Boolean(evalInSSRContext(ctx, expr, contextData)) state.pass = shouldRender el.removeAttribute('x-else-if') if (!shouldRender) { el.remove() return null } if (el.tagName === 'TEMPLATE') { const rendered = await renderSSR(el.content, ctx, data) el.replaceWith(rendered) return rendered } } // Handle x-else directive if (el.hasAttribute?.('x-else')) { if (state.pass === undefined || el.getAttribute('x-else')) { console.warn('[peak-ssr] Invalid x-else') } if (state.pass) { el.remove() return null } if (el.tagName === 'TEMPLATE') { const rendered = await renderSSR(el.content, ctx, data) el.replaceWith(rendered) return rendered } } // Handle x-for directive if (el.hasAttribute?.('x-for')) { const expression = el.getAttribute('x-for') const match = expression.match(/^\s*(\w+)\s+in\s+(.+)$/) if (!match) { console.warn(`[peak-ssr] Invalid x-for syntax: ${expression}`) return el } const [, itemName, itemsExpr] = match const items = evalInSSRContext(ctx, itemsExpr, contextData) const fragment = document.createDocumentFragment() if (Array.isArray(items)) { for (const [index, item] of items.entries()) { const clone = document.createElement('template') if (el.tagName === 'TEMPLATE') { clone.innerHTML = el.innerHTML } else { clone.innerHTML = el.outerHTML clone.content.children[0]?.removeAttribute('x-for') } const itemCtx = Object.create(ctx) itemCtx[itemName] = item itemCtx.index = index const itemData = { ...data, [itemName]: item, index } // render directly instead of recursing const tempElement = clone.content.children[0]?.cloneNode(true) || clone.content.cloneNode(true) // process template directives, then handle custom components with loop context await _render(tempElement, {}, itemData) await processCustomComponents(tempElement, itemData) fragment.appendChild(tempElement) } } el.replaceWith(fragment) return fragment } // handle x-show directive (convert to style) if (el.hasAttribute?.('x-show')) { const expr = el.getAttribute('x-show') const shouldShow = Boolean(evalInSSRContext(ctx, expr, contextData)) if (!shouldShow) { const currentStyle = el.getAttribute('style') || '' el.setAttribute('style', currentStyle + (currentStyle ? '; ' : '') + 'display: none') } el.removeAttribute('x-show') } // Handle attribute bindings const attrs = [...(el.attributes || [])] for (const attr of attrs) { const { name, value } = attr // Handle :attribute bindings if (name.startsWith(':')) { const attrName = name.slice(1) let attrValue = evalInSSRContext(ctx, value, contextData) if (attrName === 'class' && typeof attrValue !== 'string') { attrValue = clsx(attrValue) } if (typeof attrValue === 'boolean') { if (attrValue) { el.setAttribute(attrName, attrName) // Boolean attribute } else { el.removeAttribute(attrName) } } else if (attrValue != null) { if (typeof attrValue === 'object') { // for objects, don't set as attributes since they'll be available in hydration data // just skip setting the attribute to avoid [object Object] strings } else { el.setAttribute(attrName, String(attrValue)) } } el.removeAttribute(name) } if (name.startsWith('@')) { const eventType = name.slice(1) // create a unique identifier for this element const elementId = `peak-event-${Math.random().toString(36).slice(2)}` el.setAttribute('data-peak-event-id', elementId) // track this element's event handler componentEventHandlers.push({ elementId, eventType, handler: value }) } } if (el.tagName === 'SLOT') { const slotName = el.getAttribute('name') || 'default' let slottedContent = '' if (slotName === 'default') { slottedContent = ctx._slotContent || '' } else { slottedContent = ctx._namedSlots?.[slotName] || '' } if (slottedContent) { const tempDiv = document.createElement('div') tempDiv.innerHTML = slottedContent if (el.parentNode && tempDiv.childNodes.length > 0) { for (const child of [...tempDiv.childNodes]) { el.parentNode.insertBefore(child, el) } el.remove() } } } // Recursively render children const children = [...(el.children || [])] const childState = {} for (const child of children) { await _render(child, childState, contextData) } return el } await _render(root, {}) await processCustomComponents(root, data) // store event handlers directly on the context instance if (componentEventHandlers.length > 0) { ctx._componentEventHandlers = componentEventHandlers } return root } async function processCustomComponents(root, contextData) { // Find all custom components in the rendered tree const customElements = [] function findCustomElements(el) { if (el.nodeType === 1 && el.tagName && (el.tagName.startsWith('X-') || el.tagName.includes('-'))) { // Skip elements that were already processed (marked with a flag) if (!el.hasAttribute('data-ssr-processed')) { customElements.push(el) } } for (const child of el.children || []) { findCustomElements(child) } } findCustomElements(root) // Process each custom component for (const el of customElements) { el.setAttribute('data-ssr-processed', 'true') await renderCustomComponent(el, contextData) } } function evalInSSRContext(element, code, data = {}) { if (!code) return undefined try { // safe evaluation context const context = { ...data, Math, Date, String, Number, Object, Array, Boolean, JSON, console, clsx } // Create the function with all context properties as parameters // Filter to only valid JavaScript identifiers and avoid duplicates const validKeys = [] const validValues = [] const usedKeys = new Set() for (const [key, value] of Object.entries(context)) { if (/^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test(key) && !usedKeys.has(key)) { validKeys.push(key) validValues.push(value) usedKeys.add(key) } } const func = new Function(...validKeys, `return ${code}`) return func(...validValues) } catch (error) { console.warn(`[peak-ssr] Evaluation error in "${code}":`, error.message) return undefined } } function generateScopedStyles(styleContent, tagName) { if (!styleContent.trim()) return '' return ` <style data-peak-component="${tagName}"> @layer ${tagName} { ${tagName} { ${styleContent} } ${tagName} [x-scope], ${tagName} [x-scope] * { all: revert-layer; } } </style> ` } function serializeComponentState(instance, componentData) { if (!instance) return null // collect all component state that should be hydrated const state = {} // get all enumerable properties from the instance for (const key in instance) { if (key.startsWith('_') || key.startsWith('$') || typeof instance[key] === 'function') { continue // skip private properties, methods, and framework internals } // skip attributes that are clearly from processing or passed down context if (key === 'data-ssr-processed' || key === 'settings' || key === 'cache' || key === 'index' || key === 'todos' || key === 'title') { continue } try { // only serialize JSON-serializable values const value = instance[key] if (value !== undefined && value !== null) { JSON.stringify(value) // test if serializable state[key] = value } } catch (e) { // skip non-serializable values continue } } // include component event handlers for hydration (from instance) if (instance._componentEventHandlers && instance._componentEventHandlers.length > 0) { state._componentEventHandlers = instance._componentEventHandlers } // only return data if there's something to serialize if (Object.keys(state).length === 0) return null try { return JSON.stringify(state) } catch (e) { console.warn('[peak-ssr] Failed to serialize component state:', e) return null } } // utility function for class name concatenation function clsx(...args) { const classes = [] for (const arg of args) { if (!arg) continue if (typeof arg === 'string' || typeof arg === 'number') { classes.push(String(arg)) } else if (Array.isArray(arg)) { classes.push(clsx(...arg)) } else if (typeof arg === 'object') { for (const [key, value] of Object.entries(arg)) { if (value) classes.push(key) } } } return classes.join(' ') }