package cc.unitmesh.xuiper.render import cc.unitmesh.xuiper.ir.NanoIR import kotlinx.serialization.json.jsonPrimitive /** * HTML Renderer for NanoIR * * Renders NanoIR components to static HTML. * Useful for server-side rendering and testing. * * Implements NanoRenderer with component-specific methods. */ class HtmlRenderer( private val context: RenderContext = RenderContext() ) : NanoRenderer { private fun renderFlexStyleAttribute(ir: NanoIR): String { val raw = ir.props["flex"]?.jsonPrimitive?.content ?: return "" val flex = raw.toFloatOrNull() ?: return "" if (flex <= 0f) return "" // min-width:0 prevents long text from overflowing in flex rows. return " style=\"flex: $flex 1 0%; min-width: 0;\"" } private fun escapeHtmlAttr(value: String): String { return buildString(value.length) { for (c in value) { when (c) { '&' -> append("&") '"' -> append(""") '\'' -> append("'") '<' -> append("<") '>' -> append(">") else -> append(c) } } } } override fun render(ir: NanoIR): String { return buildString { append("\n\n\n") append("\n") append("\n") append("\n") append("\n\n") append(renderNode(ir)) append("\n") } } override fun renderNode(ir: NanoIR): String { return when (ir.type) { "Component" -> renderComponent(ir) "VStack" -> renderVStack(ir) "HStack" -> renderHStack(ir) "Card" -> renderCard(ir) "Form" -> renderForm(ir) "Text" -> renderText(ir) "Image" -> renderImage(ir) "Badge" -> renderBadge(ir) "Divider" -> renderDivider(ir) "Button" -> renderButton(ir) "Input" -> renderInput(ir) "Checkbox" -> renderCheckbox(ir) "TextArea" -> renderTextArea(ir) "Select" -> renderSelect(ir) // P0: Core Form Input Components "DatePicker" -> renderDatePicker(ir) "Radio" -> renderRadio(ir) "RadioGroup" -> renderRadioGroup(ir) "Switch" -> renderSwitch(ir) "NumberInput" -> renderNumberInput(ir) // P0: Feedback Components "Modal" -> renderModal(ir) "Alert" -> renderAlert(ir) "Progress" -> renderProgress(ir) "Spinner" -> renderSpinner(ir) // Tier 1-3: GenUI Components "SplitView" -> renderSplitView(ir) "GenCanvas" -> renderGenCanvas(ir) "SmartTextField" -> renderSmartTextField(ir) "Slider" -> renderSlider(ir) "DateRangePicker" -> renderDateRangePicker(ir) "DataChart" -> renderDataChart(ir) "DataTable" -> renderDataTable(ir) "Conditional" -> renderConditional(ir) "ForLoop" -> renderForLoop(ir) else -> renderUnknown(ir) } } // ============================================================================ // Layout Components // ============================================================================ override fun renderVStack(ir: NanoIR): String { val spacing = ir.props["spacing"]?.jsonPrimitive?.content ?: "md" val align = ir.props["align"]?.jsonPrimitive?.content ?: "stretch" return buildString { append("
\n") ir.children?.forEach { append(renderNode(it)) } append("
\n") } } override fun renderHStack(ir: NanoIR): String { val spacing = ir.props["spacing"]?.jsonPrimitive?.content ?: "md" val align = ir.props["align"]?.jsonPrimitive?.content ?: "center" val justify = ir.props["justify"]?.jsonPrimitive?.content ?: "start" val wrapRaw = ir.props["wrap"]?.jsonPrimitive?.content val wrapEnabled = wrapRaw == "wrap" || wrapRaw.equals("true", ignoreCase = true) val wrapClass = if (wrapEnabled) " wrap" else "" return buildString { append("
\n") ir.children?.forEach { append(renderNode(it)) } append("
\n") } } // ============================================================================ // Container Components // ============================================================================ override fun renderCard(ir: NanoIR): String { val padding = ir.props["padding"]?.jsonPrimitive?.content ?: "md" val shadow = ir.props["shadow"]?.jsonPrimitive?.content ?: "sm" return buildString { append("
\n") ir.children?.forEach { append(renderNode(it)) } append("
\n") } } override fun renderForm(ir: NanoIR): String { val onSubmit = ir.props["onSubmit"]?.jsonPrimitive?.content return buildString { append("
\n") ir.children?.forEach { append(renderNode(it)) } append("
\n") } } // ============================================================================ // Content Components // ============================================================================ override fun renderText(ir: NanoIR): String { val content = ir.props["content"]?.jsonPrimitive?.content ?: "" val style = ir.props["style"]?.jsonPrimitive?.content ?: "body" val tag = when (style) { "h1" -> "h1" "h2" -> "h2" "h3" -> "h3" "h4" -> "h4" "caption" -> "small" else -> "p" } return "<$tag class=\"nano-text style-$style\">$content\n" } override fun renderImage(ir: NanoIR): String { val src = ir.props["src"]?.jsonPrimitive?.content ?: "" val aspect = ir.props["aspect"]?.jsonPrimitive?.content val radius = ir.props["radius"]?.jsonPrimitive?.content ?: "none" val aspectClass = aspect?.replace("/", "-") ?: "auto" val alt = ir.props["alt"]?.jsonPrimitive?.content ?: "Image" // Prevent browsers from loading fake LLM-generated URLs // Only render images with valid data: URLs if (src.isNotBlank() && !src.trim().startsWith("data:image/", ignoreCase = true)) { // Return a placeholder div instead of an img tag to avoid CORS errors return "
" + "🖼️ Image: $alt" + "
\n" } return "\"$alt\"\n" } override fun renderBadge(ir: NanoIR): String { val text = ir.props["text"]?.jsonPrimitive?.content ?: "" val color = ir.props["color"]?.jsonPrimitive?.content ?: "default" return "$text\n" } override fun renderDivider(ir: NanoIR): String { return "
\n" } // ============================================================================ // Input Components // ============================================================================ override fun renderButton(ir: NanoIR): String { val label = ir.props["label"]?.jsonPrimitive?.content ?: "" val intent = ir.props["intent"]?.jsonPrimitive?.content ?: "default" val icon = ir.props["icon"]?.jsonPrimitive?.content val disabledIf = ir.props["disabled_if"]?.jsonPrimitive?.content val actionAttr = renderActionAttribute(ir) val bindingAttr = renderBindingAttribute(ir) val disabledIfAttr = disabledIf?.takeIf { it.isNotBlank() }?.let { " data-disabled-if=\"${escapeHtmlAttr(it)}\"" } ?: "" return buildString { append("\n") } } override fun renderInput(ir: NanoIR): String { val placeholder = ir.props["placeholder"]?.jsonPrimitive?.content ?: "" val type = ir.props["type"]?.jsonPrimitive?.content ?: "text" val bindingAttr = renderBindingAttribute(ir) val actionAttr = renderActionAttribute(ir) return buildString { append("\n") } } override fun renderCheckbox(ir: NanoIR): String { val label = ir.props["label"]?.jsonPrimitive?.content val bindingAttr = renderBindingAttribute(ir) val actionAttr = renderActionAttribute(ir) return if (label != null) { buildString { append("\n") } } else { buildString { append("\n") } } } override fun renderTextArea(ir: NanoIR): String { val placeholder = ir.props["placeholder"]?.jsonPrimitive?.content ?: "" val rows = ir.props["rows"]?.jsonPrimitive?.content ?: "4" val bindingAttr = renderBindingAttribute(ir) val actionAttr = renderActionAttribute(ir) return buildString { append("\n") } } override fun renderSelect(ir: NanoIR): String { val placeholder = ir.props["placeholder"]?.jsonPrimitive?.content val options = ir.props["options"]?.jsonPrimitive?.content val bindingAttr = renderBindingAttribute(ir) val actionAttr = renderActionAttribute(ir) return buildString { append("\n") } } // ============================================================================ // Control Flow Components // ============================================================================ override fun renderConditional(ir: NanoIR): String { // In static HTML, we render the then branch // Dynamic evaluation would happen on client-side return buildString { append("\n") ir.children?.forEach { append(renderNode(it)) } append("\n") } } override fun renderForLoop(ir: NanoIR): String { val loop = ir.loop return buildString { append("\n") ir.children?.forEach { append(renderNode(it)) } append("\n") } } // ============================================================================ // Meta Components // ============================================================================ override fun renderComponent(ir: NanoIR): String { val name = ir.props["name"]?.jsonPrimitive?.content ?: "Component" return buildString { append("
\n") ir.children?.forEach { append(renderNode(it)) } append("
\n") } } override fun renderUnknown(ir: NanoIR): String { return "\n" } // ============================================================================ // P0: Core Form Input Components // ============================================================================ fun renderDatePicker(ir: NanoIR): String { val format = ir.props["format"]?.jsonPrimitive?.content ?: "YYYY-MM-DD" val placeholder = ir.props["placeholder"]?.jsonPrimitive?.content ?: "" val bindingAttr = renderBindingAttribute(ir) val actionAttr = renderActionAttribute(ir) return "\n" } fun renderRadio(ir: NanoIR): String { val option = ir.props["option"]?.jsonPrimitive?.content ?: "" val label = ir.props["label"]?.jsonPrimitive?.content val name = ir.props["name"]?.jsonPrimitive?.content ?: "" val bindingAttr = renderBindingAttribute(ir) return buildString { append("\n") } } fun renderRadioGroup(ir: NanoIR): String { val name = ir.props["name"]?.jsonPrimitive?.content ?: "" val bindingAttr = renderBindingAttribute(ir) return buildString { append("
\n") ir.children?.forEach { append(renderNode(it)) } append("
\n") } } fun renderSwitch(ir: NanoIR): String { val label = ir.props["label"]?.jsonPrimitive?.content val bindingAttr = renderBindingAttribute(ir) val actionAttr = renderActionAttribute(ir) return buildString { append("\n") } } fun renderNumberInput(ir: NanoIR): String { val min = ir.props["min"]?.jsonPrimitive?.content val max = ir.props["max"]?.jsonPrimitive?.content val step = ir.props["step"]?.jsonPrimitive?.content ?: "1" val placeholder = ir.props["placeholder"]?.jsonPrimitive?.content ?: "" val bindingAttr = renderBindingAttribute(ir) val actionAttr = renderActionAttribute(ir) return buildString { append("
") append("") append("") append("") append("
\n") } } // ============================================================================ // P0: Feedback Components // ============================================================================ fun renderModal(ir: NanoIR): String { val title = ir.props["title"]?.jsonPrimitive?.content val size = ir.props["size"]?.jsonPrimitive?.content ?: "md" val closable = ir.props["closable"]?.jsonPrimitive?.content?.toBoolean() ?: true val bindingAttr = renderBindingAttribute(ir) val actionAttr = renderActionAttribute(ir) return buildString { append("
\n") append("
\n") append("
\n") if (title != null) append("

$title

") if (closable) append("") if (title != null) append("
\n") append("
\n") ir.children?.forEach { append(" ${renderNode(it).replace("\n", "\n ")}") } append("
\n") append("
\n") append("
\n") } } fun renderAlert(ir: NanoIR): String { val type = ir.props["type"]?.jsonPrimitive?.content ?: "info" val message = ir.props["message"]?.jsonPrimitive?.content val closable = ir.props["closable"]?.jsonPrimitive?.content?.toBoolean() ?: false val actionAttr = renderActionAttribute(ir) return buildString { append("
\n") if (message != null) append(" $message\n") ir.children?.forEach { append(" ${renderNode(it).replace("\n", "\n ")}") } if (closable) append(" \n") append("
\n") } } fun renderProgress(ir: NanoIR): String { val valueStr = ir.props["value"]?.jsonPrimitive?.content val maxStr = ir.props["max"]?.jsonPrimitive?.content val value = valueStr?.toFloatOrNull() ?: 0f val max = maxStr?.toFloatOrNull() ?: 100f val showText = ir.props["showText"]?.jsonPrimitive?.content?.toBoolean() ?: true val status = ir.props["status"]?.jsonPrimitive?.content ?: "normal" val isBinding = (valueStr != null && valueStr.toFloatOrNull() == null) || (maxStr != null && maxStr.toFloatOrNull() == null) val percentage = if (max > 0f) ((value / max) * 100).toInt().coerceIn(0, 100) else 0 return buildString { append("
\n") append("
\n") if (showText) { val displayText = if (isBinding) "${valueStr ?: "0"} / ${maxStr ?: "100"}" else "$percentage%" append(" $displayText\n") } append("
\n") } } fun renderSpinner(ir: NanoIR): String { val size = ir.props["size"]?.jsonPrimitive?.content ?: "md" val text = ir.props["text"]?.jsonPrimitive?.content return buildString { append("
\n") append("
\n") if (text != null) append(" $text\n") append("
\n") } } // ============================================================================ // Tier 1-3: GenUI Components // ============================================================================ fun renderSplitView(ir: NanoIR): String { val ratio = ir.props["ratio"]?.jsonPrimitive?.content?.toFloatOrNull() ?: 0.5f val leftWidth = (ratio * 100).toInt() val rightWidth = 100 - leftWidth return buildString { append("
\n") append("
\n") if (ir.children?.isNotEmpty() == true) append(" ${renderNode(ir.children[0]).replace("\n", "\n ")}") append("
\n") append("
\n") if (ir.children?.size ?: 0 > 1) append(" ${renderNode(ir.children!![1]).replace("\n", "\n ")}") append("
\n") append("
\n") } } fun renderGenCanvas(ir: NanoIR): String { val bindingAttr = renderBindingAttribute(ir) return buildString { append("
") append("") append("
\n") } } fun renderSmartTextField(ir: NanoIR): String { val label = ir.props["label"]?.jsonPrimitive?.content val placeholder = ir.props["placeholder"]?.jsonPrimitive?.content ?: "" val validation = ir.props["validation"]?.jsonPrimitive?.content val bindingAttr = renderBindingAttribute(ir) return buildString { if (label != null) append("\n") append("\n") } } fun renderSlider(ir: NanoIR): String { val label = ir.props["label"]?.jsonPrimitive?.content val min = ir.props["min"]?.jsonPrimitive?.content?.toFloatOrNull() ?: 0f val max = ir.props["max"]?.jsonPrimitive?.content?.toFloatOrNull() ?: 100f val step = ir.props["step"]?.jsonPrimitive?.content?.toFloatOrNull() ?: 1f val bindingAttr = renderBindingAttribute(ir) val actionAttr = renderActionAttribute(ir) // Get the binding expression to show the current value val binding = ir.bindings?.get("bind") val valueExpression = binding?.expression return buildString { append("
\n") // Render label and value in a row if label exists if (label != null || valueExpression != null) { append("
\n") if (label != null) { append(" \n") } if (valueExpression != null) { append(" ") append("${min.toInt()}") // Default value append("\n") } append("
\n") } append(" \n") append("
\n") } } fun renderDateRangePicker(ir: NanoIR): String { val bindingAttr = renderBindingAttribute(ir) val actionAttr = renderActionAttribute(ir) return "
\n" } fun renderDataChart(ir: NanoIR): String { val type = ir.props["type"]?.jsonPrimitive?.content ?: "line" val data = ir.props["data"]?.jsonPrimitive?.content val dataAttr = data?.let { " data-data=\"${escapeHtmlAttr(it)}\"" } ?: "" return "
\n" } fun renderDataTable(ir: NanoIR): String { val columns = ir.props["columns"]?.jsonPrimitive?.content val data = ir.props["data"]?.jsonPrimitive?.content val actionAttr = renderActionAttribute(ir) val columnsAttr = columns?.let { " data-columns=\"${escapeHtmlAttr(it)}\"" } ?: "" val dataAttr = data?.let { " data-data=\"${escapeHtmlAttr(it)}\"" } ?: "" return buildString { append("\n") append(" \n") append(" \n") append("
\n") } } // ============================================================================ // Action & Binding Helpers // ============================================================================ /** * Render data-action attribute for components with actions * * Example output: data-action='{"on_click":{"type":"StateMutation","payload":{...}}}' */ private fun renderActionAttribute(ir: NanoIR): String { val actions = ir.actions ?: return "" if (actions.isEmpty()) return "" val actionsJson = buildString { append("{") actions.entries.forEachIndexed { index, (event, action) -> if (index > 0) append(",") append("\"$event\":{") append("\"type\":\"${action.type}\"") action.payload?.let { payload -> append(",\"payload\":{") payload.entries.forEachIndexed { pIndex, (key, value) -> if (pIndex > 0) append(",") append("\"$key\":$value") } append("}") } append("}") } append("}") } return " data-actions='$actionsJson'" } /** * Render data-binding attribute for bound components * * Example output: data-bindings='{"value":{"mode":"twoWay","expression":"state.new_task"}}' */ private fun renderBindingAttribute(ir: NanoIR): String { val bindings = ir.bindings ?: return "" if (bindings.isEmpty()) return "" val bindingsJson = buildString { append("{") bindings.entries.forEachIndexed { index, (prop, binding) -> if (index > 0) append(",") append("\"$prop\":{") append("\"mode\":\"${binding.mode}\",") append("\"expression\":\"${binding.expression}\"") append("}") } append("}") } return " data-bindings='$bindingsJson'" } private fun generateCss(): String = """ * { box-sizing: border-box; margin: 0; padding: 0; } body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; } .nano-component { width: 100%; } .nano-vstack { display: flex; flex-direction: column; width: 100%; } .nano-hstack { display: flex; flex-direction: row; align-items: center; width: 100%; } .nano-hstack.wrap { flex-wrap: wrap; } /* HStack child flex support */ .nano-hstack > .flex-1 { flex: 1; } .nano-hstack > .flex-2 { flex: 2; } .nano-hstack > .flex-3 { flex: 3; } .nano-hstack > .flex-auto { flex: 1 1 auto; } .nano-hstack > .flex-none { flex: none; } /* VStack children should take full width by default */ .nano-vstack > * { width: 100%; } /* But in HStack, children should NOT take full width - let them size naturally */ /* Only VStack children should flex, other elements (Text, Button) should size to content */ .nano-hstack > .nano-vstack { flex: 1 1 0; min-width: 0; } .nano-hstack > .nano-text { flex: 0 0 auto; width: auto; } .nano-hstack > .nano-button { flex: 0 0 auto; width: auto; } .nano-hstack > .nano-input { flex: 1 1 0; min-width: 0; } .spacing-xs { gap: 4px; } .spacing-sm { gap: 8px; } .spacing-md { gap: 16px; } .spacing-lg { gap: 24px; } .spacing-xl { gap: 32px; } .align-start { align-items: flex-start; } .align-center { align-items: center; } .align-end { align-items: flex-end; } .align-stretch { align-items: stretch; } /* Prevent flex children from stretching height in HStack when align is not stretch */ .nano-hstack.align-start > div, .nano-hstack.align-center > div, .nano-hstack.align-end > div { align-self: inherit; height: fit-content; } .justify-start { justify-content: flex-start; } .justify-center { justify-content: center; } .justify-end { justify-content: flex-end; } .justify-between { justify-content: space-between; } .justify-around { justify-content: space-around; } .justify-evenly { justify-content: space-evenly; } .nano-card { background: white; border-radius: 8px; overflow: hidden; } .padding-xs { padding: 4px; } .padding-sm { padding: 8px; } .padding-md { padding: 16px; } .padding-lg { padding: 24px; } .shadow-none { box-shadow: none; } .shadow-sm { box-shadow: 0 1px 2px rgba(0,0,0,0.1); } .shadow-md { box-shadow: 0 4px 6px rgba(0,0,0,0.1); } .shadow-lg { box-shadow: 0 10px 15px rgba(0,0,0,0.1); } .nano-text { margin: 0; } .style-h1 { font-size: 2rem; font-weight: bold; } .style-h2 { font-size: 1.5rem; font-weight: bold; } .style-h3 { font-size: 1.25rem; font-weight: bold; } .style-h4 { font-size: 1rem; font-weight: bold; } .style-body { font-size: 1rem; } .style-caption { font-size: 0.875rem; color: #666; } .nano-button { padding: 8px 16px; border: none; border-radius: 4px; cursor: pointer; font-size: 1rem; } .intent-primary { background: #6200EE; color: white; } .intent-secondary { background: #03DAC6; color: black; } .intent-default { background: #E0E0E0; color: black; } .intent-error, .intent-danger { background: #B00020; color: white; } .nano-image { max-width: 100%; height: auto; display: block; } .nano-image-placeholder { max-width: 100%; min-height: 200px; display: flex; align-items: center; justify-content: center; background: #F5F5F5; border: 2px dashed #BDBDBD; } .nano-image-placeholder .placeholder-text { color: #757575; font-size: 0.875rem; text-align: center; padding: 16px; } .radius-sm { border-radius: 4px; } .radius-md { border-radius: 8px; } .radius-lg { border-radius: 16px; } .radius-full { border-radius: 9999px; } .nano-badge { display: inline-block; padding: 2px 8px; border-radius: 12px; font-size: 0.75rem; font-weight: 500; } .color-green { background: #C8E6C9; color: #2E7D32; } .color-red { background: #FFCDD2; color: #C62828; } .color-blue { background: #BBDEFB; color: #1565C0; } .color-default { background: #E0E0E0; color: #424242; } .nano-input { padding: 8px 12px; border: 1px solid #E0E0E0; border-radius: 4px; font-size: 1rem; width: 100%; min-width: 0; /* Allow input to shrink in flex containers */ } .nano-textarea { padding: 8px 12px; border: 1px solid #E0E0E0; border-radius: 4px; font-size: 1rem; width: 100%; resize: vertical; font-family: inherit; } .nano-select { padding: 8px 12px; border: 1px solid #E0E0E0; border-radius: 4px; font-size: 1rem; width: 100%; background: white; } .nano-form { display: flex; flex-direction: column; gap: 16px; } .nano-checkbox-wrapper { display: inline-flex; align-items: center; gap: 8px; cursor: pointer; } .nano-checkbox { width: 16px; height: 16px; accent-color: #1976D2; cursor: pointer; } .nano-divider { border: none; border-top: 1px solid #E0E0E0; margin: 16px 0; } .nano-slider-container { width: 100%; } .nano-slider-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px; } .nano-slider-label { font-size: 0.875rem; color: #424242; font-weight: 500; } .nano-slider-value { font-size: 0.875rem; color: #1976D2; font-weight: 600; } .nano-slider { width: 100%; height: 6px; border-radius: 3px; background: #E0E0E0; outline: none; -webkit-appearance: none; appearance: none; } .nano-slider::-webkit-slider-thumb { -webkit-appearance: none; appearance: none; width: 18px; height: 18px; border-radius: 50%; background: #1976D2; cursor: pointer; transition: all 0.2s ease; } .nano-slider::-webkit-slider-thumb:hover { background: #1565C0; transform: scale(1.1); } .nano-slider::-moz-range-thumb { width: 18px; height: 18px; border-radius: 50%; background: #1976D2; cursor: pointer; border: none; transition: all 0.2s ease; } .nano-slider::-moz-range-thumb:hover { background: #1565C0; transform: scale(1.1); } .nano-slider::-webkit-slider-runnable-track { width: 100%; height: 6px; background: linear-gradient(to right, #1976D2 0%, #1976D2 var(--value, 0%), #E0E0E0 var(--value, 0%), #E0E0E0 100%); border-radius: 3px; } .nano-slider::-moz-range-track { width: 100%; height: 6px; background: #E0E0E0; border-radius: 3px; } """.trimIndent() }