|
| 1 | +package cc.unitmesh.xuiper.render |
| 2 | + |
| 3 | +import cc.unitmesh.xuiper.ir.NanoIR |
| 4 | +import kotlinx.serialization.json.jsonPrimitive |
| 5 | + |
| 6 | +/** |
| 7 | + * HTML Renderer for NanoIR |
| 8 | + * |
| 9 | + * Renders NanoIR components to static HTML. |
| 10 | + * Useful for server-side rendering and testing. |
| 11 | + */ |
| 12 | +class HtmlRenderer( |
| 13 | + private val context: RenderContext = RenderContext() |
| 14 | +) : NanoRenderer<String> { |
| 15 | + |
| 16 | + private val supportedTypes = setOf( |
| 17 | + "Component", "VStack", "HStack", "Card", "Text", "Button", |
| 18 | + "Image", "Badge", "Input", "Checkbox", "Divider", |
| 19 | + "Conditional", "ForLoop" |
| 20 | + ) |
| 21 | + |
| 22 | + override fun render(ir: NanoIR): String { |
| 23 | + return buildString { |
| 24 | + append("<!DOCTYPE html>\n<html>\n<head>\n") |
| 25 | + append("<meta charset=\"UTF-8\">\n") |
| 26 | + append("<meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n") |
| 27 | + append("<style>\n") |
| 28 | + append(generateCss()) |
| 29 | + append("</style>\n") |
| 30 | + append("</head>\n<body>\n") |
| 31 | + append(renderComponent(ir)) |
| 32 | + append("</body>\n</html>") |
| 33 | + } |
| 34 | + } |
| 35 | + |
| 36 | + override fun renderComponent(ir: NanoIR): String { |
| 37 | + return when (ir.type) { |
| 38 | + "Component" -> renderComponentNode(ir) |
| 39 | + "VStack" -> renderVStack(ir) |
| 40 | + "HStack" -> renderHStack(ir) |
| 41 | + "Card" -> renderCard(ir) |
| 42 | + "Text" -> renderText(ir) |
| 43 | + "Button" -> renderButton(ir) |
| 44 | + "Image" -> renderImage(ir) |
| 45 | + "Badge" -> renderBadge(ir) |
| 46 | + "Input" -> renderInput(ir) |
| 47 | + "Checkbox" -> renderCheckbox(ir) |
| 48 | + "Divider" -> "<hr class=\"nano-divider\">" |
| 49 | + "Conditional" -> renderConditional(ir) |
| 50 | + "ForLoop" -> renderForLoop(ir) |
| 51 | + else -> "<!-- Unknown component: ${ir.type} -->" |
| 52 | + } |
| 53 | + } |
| 54 | + |
| 55 | + override fun supports(type: String): Boolean = type in supportedTypes |
| 56 | + |
| 57 | + private fun renderComponentNode(ir: NanoIR): String { |
| 58 | + val name = ir.props["name"]?.jsonPrimitive?.content ?: "Component" |
| 59 | + return buildString { |
| 60 | + append("<div class=\"nano-component\" data-name=\"$name\">\n") |
| 61 | + ir.children?.forEach { append(renderComponent(it)) } |
| 62 | + append("</div>\n") |
| 63 | + } |
| 64 | + } |
| 65 | + |
| 66 | + private fun renderVStack(ir: NanoIR): String { |
| 67 | + val spacing = ir.props["spacing"]?.jsonPrimitive?.content ?: "md" |
| 68 | + val align = ir.props["align"]?.jsonPrimitive?.content ?: "stretch" |
| 69 | + return buildString { |
| 70 | + append("<div class=\"nano-vstack spacing-$spacing align-$align\">\n") |
| 71 | + ir.children?.forEach { append(renderComponent(it)) } |
| 72 | + append("</div>\n") |
| 73 | + } |
| 74 | + } |
| 75 | + |
| 76 | + private fun renderHStack(ir: NanoIR): String { |
| 77 | + val spacing = ir.props["spacing"]?.jsonPrimitive?.content ?: "md" |
| 78 | + val align = ir.props["align"]?.jsonPrimitive?.content ?: "center" |
| 79 | + val justify = ir.props["justify"]?.jsonPrimitive?.content ?: "start" |
| 80 | + return buildString { |
| 81 | + append("<div class=\"nano-hstack spacing-$spacing align-$align justify-$justify\">\n") |
| 82 | + ir.children?.forEach { append(renderComponent(it)) } |
| 83 | + append("</div>\n") |
| 84 | + } |
| 85 | + } |
| 86 | + |
| 87 | + private fun renderCard(ir: NanoIR): String { |
| 88 | + val padding = ir.props["padding"]?.jsonPrimitive?.content ?: "md" |
| 89 | + val shadow = ir.props["shadow"]?.jsonPrimitive?.content ?: "sm" |
| 90 | + return buildString { |
| 91 | + append("<div class=\"nano-card padding-$padding shadow-$shadow\">\n") |
| 92 | + ir.children?.forEach { append(renderComponent(it)) } |
| 93 | + append("</div>\n") |
| 94 | + } |
| 95 | + } |
| 96 | + |
| 97 | + private fun renderText(ir: NanoIR): String { |
| 98 | + val content = ir.props["content"]?.jsonPrimitive?.content ?: "" |
| 99 | + val style = ir.props["style"]?.jsonPrimitive?.content ?: "body" |
| 100 | + val tag = when (style) { |
| 101 | + "h1" -> "h1" |
| 102 | + "h2" -> "h2" |
| 103 | + "h3" -> "h3" |
| 104 | + "h4" -> "h4" |
| 105 | + "caption" -> "small" |
| 106 | + else -> "p" |
| 107 | + } |
| 108 | + return "<$tag class=\"nano-text style-$style\">$content</$tag>\n" |
| 109 | + } |
| 110 | + |
| 111 | + private fun renderButton(ir: NanoIR): String { |
| 112 | + val label = ir.props["label"]?.jsonPrimitive?.content ?: "" |
| 113 | + val intent = ir.props["intent"]?.jsonPrimitive?.content ?: "default" |
| 114 | + val icon = ir.props["icon"]?.jsonPrimitive?.content |
| 115 | + return buildString { |
| 116 | + append("<button class=\"nano-button intent-$intent\">") |
| 117 | + if (icon != null) append("<span class=\"icon\">$icon</span> ") |
| 118 | + append(label) |
| 119 | + append("</button>\n") |
| 120 | + } |
| 121 | + } |
| 122 | + |
| 123 | + private fun renderImage(ir: NanoIR): String { |
| 124 | + val src = ir.props["src"]?.jsonPrimitive?.content ?: "" |
| 125 | + val aspect = ir.props["aspect"]?.jsonPrimitive?.content |
| 126 | + val radius = ir.props["radius"]?.jsonPrimitive?.content ?: "none" |
| 127 | + val aspectClass = aspect?.replace("/", "-") ?: "auto" |
| 128 | + return "<img src=\"$src\" class=\"nano-image aspect-$aspectClass radius-$radius\" alt=\"\">\n" |
| 129 | + } |
| 130 | + |
| 131 | + private fun renderBadge(ir: NanoIR): String { |
| 132 | + val text = ir.props["text"]?.jsonPrimitive?.content ?: "" |
| 133 | + val color = ir.props["color"]?.jsonPrimitive?.content ?: "default" |
| 134 | + return "<span class=\"nano-badge color-$color\">$text</span>\n" |
| 135 | + } |
| 136 | + |
| 137 | + private fun renderInput(ir: NanoIR): String { |
| 138 | + val placeholder = ir.props["placeholder"]?.jsonPrimitive?.content ?: "" |
| 139 | + val type = ir.props["type"]?.jsonPrimitive?.content ?: "text" |
| 140 | + return "<input type=\"$type\" class=\"nano-input\" placeholder=\"$placeholder\">\n" |
| 141 | + } |
| 142 | + |
| 143 | + private fun renderCheckbox(ir: NanoIR): String { |
| 144 | + return "<input type=\"checkbox\" class=\"nano-checkbox\">\n" |
| 145 | + } |
| 146 | + |
| 147 | + private fun renderConditional(ir: NanoIR): String { |
| 148 | + // In static HTML, we render the then branch |
| 149 | + // Dynamic evaluation would happen on client-side |
| 150 | + return buildString { |
| 151 | + append("<!-- if: ${ir.condition} -->\n") |
| 152 | + ir.children?.forEach { append(renderComponent(it)) } |
| 153 | + append("<!-- endif -->\n") |
| 154 | + } |
| 155 | + } |
| 156 | + |
| 157 | + private fun renderForLoop(ir: NanoIR): String { |
| 158 | + val loop = ir.loop |
| 159 | + return buildString { |
| 160 | + append("<!-- for ${loop?.variable} in ${loop?.iterable} -->\n") |
| 161 | + ir.children?.forEach { append(renderComponent(it)) } |
| 162 | + append("<!-- endfor -->\n") |
| 163 | + } |
| 164 | + } |
| 165 | + |
| 166 | + private fun generateCss(): String = """ |
| 167 | + * { box-sizing: border-box; margin: 0; padding: 0; } |
| 168 | + body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; } |
| 169 | +
|
| 170 | + .nano-component { width: 100%; } |
| 171 | +
|
| 172 | + .nano-vstack { display: flex; flex-direction: column; } |
| 173 | + .nano-hstack { display: flex; flex-direction: row; align-items: center; } |
| 174 | +
|
| 175 | + .spacing-xs { gap: 4px; } |
| 176 | + .spacing-sm { gap: 8px; } |
| 177 | + .spacing-md { gap: 16px; } |
| 178 | + .spacing-lg { gap: 24px; } |
| 179 | + .spacing-xl { gap: 32px; } |
| 180 | +
|
| 181 | + .align-start { align-items: flex-start; } |
| 182 | + .align-center { align-items: center; } |
| 183 | + .align-end { align-items: flex-end; } |
| 184 | + .align-stretch { align-items: stretch; } |
| 185 | +
|
| 186 | + .justify-start { justify-content: flex-start; } |
| 187 | + .justify-center { justify-content: center; } |
| 188 | + .justify-end { justify-content: flex-end; } |
| 189 | + .justify-between { justify-content: space-between; } |
| 190 | +
|
| 191 | + .nano-card { |
| 192 | + background: white; |
| 193 | + border-radius: 8px; |
| 194 | + overflow: hidden; |
| 195 | + } |
| 196 | + .padding-xs { padding: 4px; } |
| 197 | + .padding-sm { padding: 8px; } |
| 198 | + .padding-md { padding: 16px; } |
| 199 | + .padding-lg { padding: 24px; } |
| 200 | +
|
| 201 | + .shadow-none { box-shadow: none; } |
| 202 | + .shadow-sm { box-shadow: 0 1px 2px rgba(0,0,0,0.1); } |
| 203 | + .shadow-md { box-shadow: 0 4px 6px rgba(0,0,0,0.1); } |
| 204 | + .shadow-lg { box-shadow: 0 10px 15px rgba(0,0,0,0.1); } |
| 205 | +
|
| 206 | + .nano-text { margin: 0; } |
| 207 | + .style-h1 { font-size: 2rem; font-weight: bold; } |
| 208 | + .style-h2 { font-size: 1.5rem; font-weight: bold; } |
| 209 | + .style-h3 { font-size: 1.25rem; font-weight: bold; } |
| 210 | + .style-h4 { font-size: 1rem; font-weight: bold; } |
| 211 | + .style-body { font-size: 1rem; } |
| 212 | + .style-caption { font-size: 0.875rem; color: #666; } |
| 213 | +
|
| 214 | + .nano-button { |
| 215 | + padding: 8px 16px; |
| 216 | + border: none; |
| 217 | + border-radius: 4px; |
| 218 | + cursor: pointer; |
| 219 | + font-size: 1rem; |
| 220 | + } |
| 221 | + .intent-primary { background: #6200EE; color: white; } |
| 222 | + .intent-secondary { background: #03DAC6; color: black; } |
| 223 | + .intent-default { background: #E0E0E0; color: black; } |
| 224 | + .intent-error, .intent-danger { background: #B00020; color: white; } |
| 225 | +
|
| 226 | + .nano-image { max-width: 100%; height: auto; display: block; } |
| 227 | + .radius-sm { border-radius: 4px; } |
| 228 | + .radius-md { border-radius: 8px; } |
| 229 | + .radius-lg { border-radius: 16px; } |
| 230 | + .radius-full { border-radius: 9999px; } |
| 231 | +
|
| 232 | + .nano-badge { |
| 233 | + display: inline-block; |
| 234 | + padding: 2px 8px; |
| 235 | + border-radius: 12px; |
| 236 | + font-size: 0.75rem; |
| 237 | + font-weight: 500; |
| 238 | + } |
| 239 | + .color-green { background: #C8E6C9; color: #2E7D32; } |
| 240 | + .color-red { background: #FFCDD2; color: #C62828; } |
| 241 | + .color-blue { background: #BBDEFB; color: #1565C0; } |
| 242 | + .color-default { background: #E0E0E0; color: #424242; } |
| 243 | +
|
| 244 | + .nano-input { |
| 245 | + padding: 8px 12px; |
| 246 | + border: 1px solid #E0E0E0; |
| 247 | + border-radius: 4px; |
| 248 | + font-size: 1rem; |
| 249 | + width: 100%; |
| 250 | + } |
| 251 | +
|
| 252 | + .nano-divider { border: none; border-top: 1px solid #E0E0E0; margin: 16px 0; } |
| 253 | + """.trimIndent() |
| 254 | +} |
| 255 | + |
0 commit comments