Skip to content

Commit 4db2957

Browse files
committed
feat(xuiper-ui): add NanoUI renderer interface and HTML renderer
Phase 2 implementation of Issue #484: - Add NanoRenderer interface for platform-agnostic rendering - Add RenderContext for stateful rendering with theme support - Add NanoTheme with SpacingScale and ColorScheme - Add HtmlRenderer as reference implementation - Add comprehensive tests for HTML rendering Components supported: - Layout: VStack, HStack - Container: Card - Content: Text, Image, Badge, Divider - Input: Button, Input, Checkbox - Control: Conditional, ForLoop
1 parent a0e3785 commit 4db2957

File tree

3 files changed

+534
-0
lines changed

3 files changed

+534
-0
lines changed
Lines changed: 255 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,255 @@
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+
Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
package cc.unitmesh.xuiper.render
2+
3+
import cc.unitmesh.xuiper.ir.NanoIR
4+
5+
/**
6+
* Platform-agnostic NanoUI Renderer interface
7+
*
8+
* This interface defines the contract for rendering NanoIR components.
9+
* Platform-specific implementations (Compose, React, Flutter) should implement this interface.
10+
*
11+
* The render result type [T] varies by platform:
12+
* - Compose: @Composable functions (Unit)
13+
* - React: ReactElement
14+
* - Flutter: Widget
15+
* - HTML: String
16+
*/
17+
interface NanoRenderer<T> {
18+
/**
19+
* Render a NanoIR tree to platform-specific output
20+
*/
21+
fun render(ir: NanoIR): T
22+
23+
/**
24+
* Render a specific component type
25+
*/
26+
fun renderComponent(ir: NanoIR): T
27+
28+
/**
29+
* Check if this renderer supports the given component type
30+
*/
31+
fun supports(type: String): Boolean
32+
}
33+
34+
/**
35+
* Render context for stateful rendering
36+
*/
37+
data class RenderContext(
38+
/** Current state values */
39+
val state: Map<String, Any> = emptyMap(),
40+
41+
/** Action dispatcher */
42+
val dispatch: ((NanoRenderAction) -> Unit)? = null,
43+
44+
/** Theme configuration */
45+
val theme: NanoTheme = NanoTheme.Default
46+
)
47+
48+
/**
49+
* Action dispatched during rendering
50+
*/
51+
sealed class NanoRenderAction {
52+
data class StateMutation(val path: String, val value: Any) : NanoRenderAction()
53+
data class Navigate(val to: String) : NanoRenderAction()
54+
data class Fetch(val url: String, val method: String = "GET") : NanoRenderAction()
55+
data class ShowToast(val message: String) : NanoRenderAction()
56+
}
57+
58+
/**
59+
* Theme configuration for NanoUI
60+
*/
61+
data class NanoTheme(
62+
val spacing: SpacingScale = SpacingScale.Default,
63+
val colors: ColorScheme = ColorScheme.Default
64+
) {
65+
companion object {
66+
val Default = NanoTheme()
67+
}
68+
}
69+
70+
/**
71+
* Spacing scale following design tokens
72+
*/
73+
data class SpacingScale(
74+
val xs: Int = 4,
75+
val sm: Int = 8,
76+
val md: Int = 16,
77+
val lg: Int = 24,
78+
val xl: Int = 32
79+
) {
80+
companion object {
81+
val Default = SpacingScale()
82+
}
83+
84+
fun resolve(value: String?): Int {
85+
return when (value) {
86+
"xs" -> xs
87+
"sm" -> sm
88+
"md" -> md
89+
"lg" -> lg
90+
"xl" -> xl
91+
else -> value?.toIntOrNull() ?: md
92+
}
93+
}
94+
}
95+
96+
/**
97+
* Color scheme for NanoUI theming
98+
*/
99+
data class ColorScheme(
100+
val primary: String = "#6200EE",
101+
val secondary: String = "#03DAC6",
102+
val background: String = "#FFFFFF",
103+
val surface: String = "#FFFFFF",
104+
val error: String = "#B00020",
105+
val onPrimary: String = "#FFFFFF",
106+
val onSecondary: String = "#000000",
107+
val onBackground: String = "#000000",
108+
val onSurface: String = "#000000",
109+
val onError: String = "#FFFFFF"
110+
) {
111+
companion object {
112+
val Default = ColorScheme()
113+
114+
val Dark = ColorScheme(
115+
primary = "#BB86FC",
116+
secondary = "#03DAC6",
117+
background = "#121212",
118+
surface = "#1E1E1E",
119+
error = "#CF6679",
120+
onPrimary = "#000000",
121+
onSecondary = "#000000",
122+
onBackground = "#FFFFFF",
123+
onSurface = "#FFFFFF",
124+
onError = "#000000"
125+
)
126+
}
127+
128+
fun resolveIntent(intent: String?): String {
129+
return when (intent) {
130+
"primary" -> primary
131+
"secondary" -> secondary
132+
"error", "danger" -> error
133+
else -> primary
134+
}
135+
}
136+
}
137+

0 commit comments

Comments
 (0)