Files
mq/renderer/schema.go
2025-09-18 18:26:35 +05:45

1345 lines
40 KiB
Go

package renderer
import (
"bytes"
"fmt"
"html/template"
"os"
"sort"
"strings"
"sync"
"github.com/oarkflow/jsonschema"
)
// A single template for the entire group structure
const groupTemplateStr = `
<div class="form-group-container">
{{if .Title.Text}}
{{if .Title.Class}}
<div class="{{.Title.Class}}">{{.Title.Text}}</div>
{{else}}
<h3 class="group-title">{{.Title.Text}}</h3>
{{end}}
{{end}}
<div class="{{.GroupClass}}">{{.FieldsHTML}}</div>
</div>
`
// Templates for field rendering - now supports all HTML DOM elements
var fieldTemplates = map[string]string{
// Form elements
"input": `<div class="{{.Class}}">{{.LabelHTML}}<input {{.AllAttributes}} />{{.ContentHTML}}</div>`,
"input_hidden": `<input {{.AllAttributes}} />`, // Special template for hidden inputs without wrapper
"textarea": `<div class="{{.Class}}">{{.LabelHTML}}<textarea {{.AllAttributes}}>{{.Content}}</textarea>{{.ContentHTML}}</div>`,
"select": `<div class="{{.Class}}">{{.LabelHTML}}<select {{.AllAttributes}}>{{.OptionsHTML}}</select>{{.ContentHTML}}</div>`,
"button": `<button {{.AllAttributes}}>{{.Content}}</button>`,
"option": `<option {{.AllAttributes}}>{{.Content}}</option>`,
"optgroup": `<optgroup {{.AllAttributes}}>{{.OptionsHTML}}</optgroup>`,
"label": `<label {{.AllAttributes}}>{{.Content}}</label>`,
"fieldset": `<fieldset {{.AllAttributes}}>{{.ContentHTML}}</fieldset>`,
"legend": `<legend {{.AllAttributes}}>{{.Content}}</legend>`,
"datalist": `<datalist {{.AllAttributes}}>{{.OptionsHTML}}</datalist>`,
"output": `<output {{.AllAttributes}}>{{.Content}}</output>`,
"progress": `<progress {{.AllAttributes}}>{{.Content}}</progress>`,
"meter": `<meter {{.AllAttributes}}>{{.Content}}</meter>`,
// Text content elements
"h1": `<h1 {{.AllAttributes}}>{{.Content}}</h1>`,
"h2": `<h2 {{.AllAttributes}}>{{.Content}}</h2>`,
"h3": `<h3 {{.AllAttributes}}>{{.Content}}</h3>`,
"h4": `<h4 {{.AllAttributes}}>{{.Content}}</h4>`,
"h5": `<h5 {{.AllAttributes}}>{{.Content}}</h5>`,
"h6": `<h6 {{.AllAttributes}}>{{.Content}}</h6>`,
"p": `<p {{.AllAttributes}}>{{.Content}}</p>`,
"div": `<div {{.AllAttributes}}>{{.ContentHTML}}</div>`,
"span": `<span {{.AllAttributes}}>{{.Content}}</span>`,
"pre": `<pre {{.AllAttributes}}>{{.Content}}</pre>`,
"code": `<code {{.AllAttributes}}>{{.Content}}</code>`,
"blockquote": `<blockquote {{.AllAttributes}}>{{.ContentHTML}}</blockquote>`,
"cite": `<cite {{.AllAttributes}}>{{.Content}}</cite>`,
"strong": `<strong {{.AllAttributes}}>{{.Content}}</strong>`,
"em": `<em {{.AllAttributes}}>{{.Content}}</em>`,
"small": `<small {{.AllAttributes}}>{{.Content}}</small>`,
"mark": `<mark {{.AllAttributes}}>{{.Content}}</mark>`,
"del": `<del {{.AllAttributes}}>{{.Content}}</del>`,
"ins": `<ins {{.AllAttributes}}>{{.Content}}</ins>`,
"sub": `<sub {{.AllAttributes}}>{{.Content}}</sub>`,
"sup": `<sup {{.AllAttributes}}>{{.Content}}</sup>`,
"abbr": `<abbr {{.AllAttributes}}>{{.Content}}</abbr>`,
"address": `<address {{.AllAttributes}}>{{.ContentHTML}}</address>`,
"time": `<time {{.AllAttributes}}>{{.Content}}</time>`,
// List elements
"ul": `<ul {{.AllAttributes}}>{{.ContentHTML}}</ul>`,
"ol": `<ol {{.AllAttributes}}>{{.ContentHTML}}</ol>`,
"li": `<li {{.AllAttributes}}>{{.Content}}</li>`,
"dl": `<dl {{.AllAttributes}}>{{.ContentHTML}}</dl>`,
"dt": `<dt {{.AllAttributes}}>{{.Content}}</dt>`,
"dd": `<dd {{.AllAttributes}}>{{.Content}}</dd>`,
// Links and media
"a": `<a {{.AllAttributes}}>{{.Content}}</a>`,
"img": `<img {{.AllAttributes}} />`,
"figure": `<figure {{.AllAttributes}}>{{.ContentHTML}}</figure>`,
"figcaption": `<figcaption {{.AllAttributes}}>{{.Content}}</figcaption>`,
"audio": `<audio {{.AllAttributes}}>{{.ContentHTML}}</audio>`,
"video": `<video {{.AllAttributes}}>{{.ContentHTML}}</video>`,
"source": `<source {{.AllAttributes}} />`,
"track": `<track {{.AllAttributes}} />`,
// Table elements
"table": `<table {{.AllAttributes}}>{{.ContentHTML}}</table>`,
"caption": `<caption {{.AllAttributes}}>{{.Content}}</caption>`,
"thead": `<thead {{.AllAttributes}}>{{.ContentHTML}}</thead>`,
"tbody": `<tbody {{.AllAttributes}}>{{.ContentHTML}}</tbody>`,
"tfoot": `<tfoot {{.AllAttributes}}>{{.ContentHTML}}</tfoot>`,
"tr": `<tr {{.AllAttributes}}>{{.ContentHTML}}</tr>`,
"th": `<th {{.AllAttributes}}>{{.Content}}</th>`,
"td": `<td {{.AllAttributes}}>{{.Content}}</td>`,
"colgroup": `<colgroup {{.AllAttributes}}>{{.ContentHTML}}</colgroup>`,
"col": `<col {{.AllAttributes}} />`,
// Sectioning elements
"article": `<article {{.AllAttributes}}>{{.ContentHTML}}</article>`,
"section": `<section {{.AllAttributes}}>{{.ContentHTML}}</section>`,
"nav": `<nav {{.AllAttributes}}>{{.ContentHTML}}</nav>`,
"aside": `<aside {{.AllAttributes}}>{{.ContentHTML}}</aside>`,
"header": `<header {{.AllAttributes}}>{{.ContentHTML}}</header>`,
"footer": `<footer {{.AllAttributes}}>{{.ContentHTML}}</footer>`,
"main": `<main {{.AllAttributes}}>{{.ContentHTML}}</main>`,
// Interactive elements
"details": `<details {{.AllAttributes}}>{{.ContentHTML}}</details>`,
"summary": `<summary {{.AllAttributes}}>{{.Content}}</summary>`,
"dialog": `<dialog {{.AllAttributes}}>{{.ContentHTML}}</dialog>`,
// Embedded content
"iframe": `<iframe {{.AllAttributes}}>{{.Content}}</iframe>`,
"embed": `<embed {{.AllAttributes}} />`,
"object": `<object {{.AllAttributes}}>{{.ContentHTML}}</object>`,
"param": `<param {{.AllAttributes}} />`,
"picture": `<picture {{.AllAttributes}}>{{.ContentHTML}}</picture>`,
"canvas": `<canvas {{.AllAttributes}}>{{.Content}}</canvas>`,
"svg": `<svg {{.AllAttributes}}>{{.ContentHTML}}</svg>`,
// Meta elements
"br": `<br {{.AllAttributes}} />`,
"hr": `<hr {{.AllAttributes}} />`,
"wbr": `<wbr {{.AllAttributes}} />`,
// Generic template for any unlisted element
"generic": `<{{.Element}} {{.AllAttributes}}>{{.ContentHTML}}</{{.Element}}>`,
"void": `<{{.Element}} {{.AllAttributes}} />`,
}
// Void elements that don't have closing tags
var voidElements = map[string]bool{
"area": true, "base": true, "br": true, "col": true, "embed": true,
"hr": true, "img": true, "input": true, "link": true, "meta": true,
"param": true, "source": true, "track": true, "wbr": true,
}
// Template cache for compiled templates
var (
compiledFieldTemplates = make(map[string]*template.Template)
compiledGroupTemplate *template.Template
templateCacheMutex sync.RWMutex
)
// Initialize compiled templates once
func init() {
var err error
compiledGroupTemplate, err = template.New("group").Parse(groupTemplateStr)
if err != nil {
panic(fmt.Sprintf("Failed to compile group template: %v", err))
}
templateCacheMutex.Lock()
defer templateCacheMutex.Unlock()
for element, tmplStr := range fieldTemplates {
compiled, err := template.New(element).Parse(tmplStr)
if err == nil {
compiledFieldTemplates[element] = compiled
}
}
}
var standardAttrs = []string{
"id", "class", "name", "type", "value", "placeholder", "href", "src",
"alt", "title", "target", "rel", "role", "tabindex", "accesskey",
"contenteditable", "draggable", "hidden", "spellcheck", "translate",
"autocomplete", "autofocus", "disabled", "readonly", "required",
"multiple", "checked", "selected", "defer", "async", "loop", "muted",
"controls", "autoplay", "preload", "poster", "width", "height",
"rows", "cols", "size", "maxlength", "minlength", "min", "max",
"step", "pattern", "accept", "capture", "form", "formaction",
"formenctype", "formmethod", "formnovalidate", "formtarget",
"colspan", "rowspan", "headers", "scope", "start", "reversed",
"datetime", "open", "label", "high", "low", "optimum", "span",
}
// FieldInfo represents metadata for a field extracted from JSONSchema
type FieldInfo struct {
Name string
FieldPath string // Full path for nested fields (e.g., "user.address.street")
Order int
Schema *jsonschema.Schema
IsRequired bool
Validation ValidationInfo // Add validation information
}
// GroupInfo represents metadata for a group extracted from JSONSchema
type GroupInfo struct {
Title GroupTitle
Fields []FieldInfo
GroupClass string
}
// GroupTitle represents the title configuration for a group
type GroupTitle struct {
Text string
Class string
}
// JSONSchemaRenderer is responsible for rendering HTML fields based on JSONSchema
type JSONSchemaRenderer struct {
Schema *jsonschema.Schema
HTMLLayout string
compiledLayout *template.Template
cachedGroups []GroupInfo
cachedButtons string
formConfig FormConfig
cacheMutex sync.RWMutex
}
// FormConfig holds cached form configuration
type FormConfig struct {
Class string
Action string
Method string
Enctype string
}
// NewJSONSchemaRenderer creates a new instance of JSONSchemaRenderer
func NewJSONSchemaRenderer(schema *jsonschema.Schema, htmlLayout string) *JSONSchemaRenderer {
renderer := &JSONSchemaRenderer{
Schema: schema,
HTMLLayout: htmlLayout,
}
// Pre-compile layout template
renderer.compileLayoutTemplate()
// Pre-parse and cache groups and form config
renderer.precomputeStaticData()
return renderer
}
// compileLayoutTemplate pre-compiles the layout template
func (r *JSONSchemaRenderer) compileLayoutTemplate() {
tmpl, err := template.New("layout").Funcs(template.FuncMap{
"form_groups": func(groupsHTML string) template.HTML {
return template.HTML(groupsHTML)
},
"form_buttons": func() template.HTML {
return template.HTML(r.cachedButtons)
},
"form_attributes": func(formAction string) template.HTMLAttr {
return template.HTMLAttr(fmt.Sprintf(`class="%s" action="%s" method="%s" enctype="%s"`,
r.formConfig.Class, formAction, r.formConfig.Method, r.formConfig.Enctype))
},
}).Parse(r.HTMLLayout)
if err == nil {
r.compiledLayout = tmpl
}
}
// precomputeStaticData caches groups and form configuration
func (r *JSONSchemaRenderer) precomputeStaticData() {
r.cachedGroups = r.parseGroupsFromSchema()
r.cachedButtons = r.renderButtons()
// Cache form configuration
if r.Schema.Form != nil {
if class, ok := r.Schema.Form["class"].(string); ok {
r.formConfig.Class = class
}
if action, ok := r.Schema.Form["action"].(string); ok {
r.formConfig.Action = action
}
if method, ok := r.Schema.Form["method"].(string); ok {
r.formConfig.Method = method
}
if enctype, ok := r.Schema.Form["enctype"].(string); ok {
r.formConfig.Enctype = enctype
}
}
}
// interpolateTemplate replaces template placeholders with actual values
func (r *JSONSchemaRenderer) interpolateTemplate(templateStr string, data map[string]any) string {
if len(data) == 0 {
return templateStr
}
tmpl, err := template.New("interpolate").Parse(templateStr)
if err != nil {
// Fallback to simple string replacement if template parsing fails
result := templateStr
for key, value := range data {
placeholder := fmt.Sprintf("{{%s}}", key)
if valueStr, ok := value.(string); ok {
result = strings.ReplaceAll(result, placeholder, valueStr)
}
}
return result
}
var templateResult bytes.Buffer
err = tmpl.Execute(&templateResult, data)
if err != nil {
return templateStr // Return original string if execution fails
}
return templateResult.String()
}
// RenderFields generates HTML for fields based on the JSONSchema
func (r *JSONSchemaRenderer) RenderFields(data map[string]any) (string, error) {
r.cacheMutex.RLock()
defer r.cacheMutex.RUnlock()
// Use a string builder for efficient string concatenation
var groupHTML strings.Builder
groupHTML.Grow(1024) // Pre-allocate reasonable capacity
for _, group := range r.cachedGroups {
groupHTML.WriteString(renderGroupWithData(group, data))
}
// Interpolate dynamic form action
formAction := r.interpolateTemplate(r.formConfig.Action, data)
// Use pre-compiled template if available
if r.compiledLayout != nil {
var renderedHTML bytes.Buffer
templateData := struct {
GroupsHTML string
FormAction string
}{
GroupsHTML: groupHTML.String(),
FormAction: formAction,
}
// Update the template functions with current data
updatedTemplate := r.compiledLayout.Funcs(template.FuncMap{
"form_groups": func() template.HTML {
return template.HTML(templateData.GroupsHTML)
},
"form_buttons": func() template.HTML {
return template.HTML(r.cachedButtons)
},
"form_attributes": func() template.HTMLAttr {
return template.HTMLAttr(fmt.Sprintf(`class="%s" action="%s" method="%s" enctype="%s"`,
r.formConfig.Class, templateData.FormAction, r.formConfig.Method, r.formConfig.Enctype))
},
})
if err := updatedTemplate.Execute(&renderedHTML, nil); err != nil {
return "", fmt.Errorf("failed to execute compiled template: %w", err)
}
return renderedHTML.String(), nil
}
// Fallback to original method if compilation failed
return r.renderFieldsFallback(data)
}
// renderFieldsFallback provides fallback rendering when template compilation fails
func (r *JSONSchemaRenderer) renderFieldsFallback(data map[string]any) (string, error) {
var groupHTML strings.Builder
for _, group := range r.cachedGroups {
groupHTML.WriteString(renderGroupWithData(group, data))
}
formAction := r.interpolateTemplate(r.formConfig.Action, data)
// Create a new template with the layout and functions
tmpl, err := template.New("layout").Funcs(template.FuncMap{
"form_groups": func() template.HTML {
return template.HTML(groupHTML.String())
},
"form_buttons": func() template.HTML {
return template.HTML(r.cachedButtons)
},
"form_attributes": func() template.HTMLAttr {
return template.HTMLAttr(fmt.Sprintf(`class="%s" action="%s" method="%s" enctype="%s"`,
r.formConfig.Class, formAction, r.formConfig.Method, r.formConfig.Enctype))
},
}).Parse(r.HTMLLayout)
if err != nil {
return "", fmt.Errorf("failed to parse HTML layout: %w", err)
}
var renderedHTML bytes.Buffer
if err := tmpl.Execute(&renderedHTML, nil); err != nil {
return "", fmt.Errorf("failed to execute HTML template: %w", err)
}
return renderedHTML.String(), nil
}
// parseGroupsFromSchema extracts and sorts groups and fields from schema
func (r *JSONSchemaRenderer) parseGroupsFromSchema() []GroupInfo {
if r.Schema.Form == nil {
return nil
}
groupsData, ok := r.Schema.Form["groups"]
if !ok {
return nil
}
groups, ok := groupsData.([]any)
if !ok {
return nil
}
var result []GroupInfo
var groupedFields = make(map[string]bool) // Track fields that are already in groups
for _, group := range groups {
groupMap, ok := group.(map[string]any)
if !ok {
continue
}
var groupTitle GroupTitle
if titleMap, ok := groupMap["title"].(map[string]any); ok {
if text, ok := titleMap["text"].(string); ok {
groupTitle.Text = text
}
if class, ok := titleMap["class"].(string); ok {
groupTitle.Class = class
}
}
groupClass, _ := groupMap["class"].(string)
if groupClass == "" {
groupClass = "form-group-fields"
}
var fields []FieldInfo
if fieldsData, ok := groupMap["fields"].([]any); ok {
for _, fieldName := range fieldsData {
if fieldNameStr, ok := fieldName.(string); ok {
// Handle nested field paths
fieldInfos := r.extractFieldsFromPath(fieldNameStr, "")
fields = append(fields, fieldInfos...)
// Mark these fields as grouped
for _, field := range fieldInfos {
groupedFields[field.FieldPath] = true
}
}
}
}
// Sort fields by order
sort.Slice(fields, func(i, j int) bool {
orderI := 0
orderJ := 0
if fields[i].Schema.Order != nil {
orderI = *fields[i].Schema.Order
}
if fields[j].Schema.Order != nil {
orderJ = *fields[j].Schema.Order
}
return orderI < orderJ
})
result = append(result, GroupInfo{
Title: groupTitle,
Fields: fields,
GroupClass: groupClass,
})
}
// Add ungrouped hidden fields to the first group or create a hidden group
if r.Schema.Properties != nil {
var hiddenFields []FieldInfo
for propName, propSchema := range *r.Schema.Properties {
if !groupedFields[propName] {
// Check if this is a hidden input
if propSchema.UI != nil {
if element, ok := propSchema.UI["element"].(string); ok && element == "input" {
if inputType, ok := propSchema.UI["type"].(string); ok && inputType == "hidden" {
// This is an ungrouped hidden field, add it
validation := extractValidationInfo(propSchema, false)
hiddenFields = append(hiddenFields, FieldInfo{
Name: propName,
FieldPath: propName,
Order: 0,
Schema: propSchema,
IsRequired: false,
Validation: validation,
})
}
}
}
}
}
// If we have hidden fields, add them to the first group or create a new hidden group
if len(hiddenFields) > 0 {
if len(result) > 0 {
// Prepend hidden fields to the first group
result[0].Fields = append(hiddenFields, result[0].Fields...)
} else {
// Create a hidden group
result = append(result, GroupInfo{
Title: GroupTitle{Text: "", Class: ""},
Fields: hiddenFields,
GroupClass: "hidden-fields",
})
}
}
}
return result
}
// extractFieldsFromPath recursively extracts fields from a path, handling nested properties
func (r *JSONSchemaRenderer) extractFieldsFromPath(fieldPath, parentPath string) []FieldInfo {
var fields []FieldInfo
// Build the full path
fullPath := fieldPath
if parentPath != "" {
fullPath = parentPath + "." + fieldPath
}
// Navigate to the schema at this path
schema := r.getSchemaAtPath(fieldPath)
if schema == nil {
return fields
}
// For nested paths like "company.address.street", we need to check if the final part
// is required in its immediate parent's schema
var fieldName string
var parentSchemaPath string
pathParts := strings.Split(fieldPath, ".")
if len(pathParts) > 1 {
// Extract the field name (last part) and parent path (all but last)
fieldName = pathParts[len(pathParts)-1]
parentSchemaPath = strings.Join(pathParts[:len(pathParts)-1], ".")
} else {
// Single level field
fieldName = fieldPath
parentSchemaPath = parentPath
}
// Check if this field is required at the parent level
isRequired := r.isFieldRequiredAtPath(fieldName, parentSchemaPath)
// If this schema has properties, it's a nested object
if schema.Properties != nil && len(*schema.Properties) > 0 {
// Recursively process nested properties
for propName, propSchema := range *schema.Properties {
nestedFields := r.extractFieldsFromNestedSchema(propName, fullPath, propSchema, isRequired)
fields = append(fields, nestedFields...)
}
} else {
// This is a leaf field
order := 0
if schema.Order != nil {
order = *schema.Order
}
fields = append(fields, FieldInfo{
Name: fieldName,
FieldPath: fullPath,
Order: order,
Schema: schema,
IsRequired: isRequired,
Validation: extractValidationInfo(schema, isRequired),
})
}
return fields
}
// extractFieldsFromNestedSchema processes nested schema properties
func (r *JSONSchemaRenderer) extractFieldsFromNestedSchema(propName, parentPath string, propSchema *jsonschema.Schema, _ bool) []FieldInfo {
var fields []FieldInfo
fullPath := propName
if parentPath != "" {
fullPath = parentPath + "." + propName
}
// Check if this field is required at its immediate parent level
// The parent schema path is the current parentPath, not one level up
isRequired := r.isFieldRequiredAtPath(propName, parentPath)
// If this property has nested properties, recurse
if propSchema.Properties != nil && len(*propSchema.Properties) > 0 {
for nestedPropName, nestedPropSchema := range *propSchema.Properties {
nestedFields := r.extractFieldsFromNestedSchema(nestedPropName, fullPath, nestedPropSchema, isRequired)
fields = append(fields, nestedFields...)
}
} else {
// This is a leaf field
order := 0
if propSchema.Order != nil {
order = *propSchema.Order
}
fields = append(fields, FieldInfo{
Name: propName,
FieldPath: fullPath,
Order: order,
Schema: propSchema,
IsRequired: isRequired,
Validation: extractValidationInfo(propSchema, isRequired),
})
}
return fields
}
// getSchemaAtPath navigates to a schema at a given path
func (r *JSONSchemaRenderer) getSchemaAtPath(path string) *jsonschema.Schema {
if r.Schema.Properties == nil {
return nil
}
parts := strings.Split(path, ".")
currentSchema := r.Schema
for _, part := range parts {
if currentSchema.Properties == nil {
return nil
}
if propSchema, exists := (*currentSchema.Properties)[part]; exists {
currentSchema = propSchema
} else {
return nil
}
}
return currentSchema
}
// isFieldRequiredAtPath checks if a field is required at a specific schema path
func (r *JSONSchemaRenderer) isFieldRequiredAtPath(fieldName, schemaPath string) bool {
var schema *jsonschema.Schema
if schemaPath == "" {
// Check at root level
schema = r.Schema
} else {
// Navigate to the schema at the given path
schema = r.getSchemaAtPath(schemaPath)
}
if schema == nil {
return false
}
return contains(schema.Required, fieldName)
}
// renderGroup generates HTML for a single group
func renderGroup(group GroupInfo) string {
var groupHTML bytes.Buffer
// Render fields
var fieldsHTML bytes.Buffer
for _, field := range group.Fields {
fieldsHTML.WriteString(renderField(field))
}
tmpl := template.Must(template.New("group").Parse(groupTemplateStr))
data := map[string]any{
"Title": group.Title,
"GroupClass": group.GroupClass,
"FieldsHTML": template.HTML(fieldsHTML.String()),
}
if err := tmpl.Execute(&groupHTML, data); err != nil {
return "" // Return empty string on error
}
return groupHTML.String()
}
// renderGroupWithData uses pre-compiled templates and string builder for better performance with data interpolation
func renderGroupWithData(group GroupInfo, data map[string]any) string {
// Collect all validations for dependency checking
allValidations := make(map[string]ValidationInfo)
for _, field := range group.Fields {
allValidations[field.FieldPath] = field.Validation
}
// Use string builder for better performance
var fieldsHTML strings.Builder
fieldsHTML.Grow(512) // Pre-allocate reasonable capacity
for _, field := range group.Fields {
fieldsHTML.WriteString(renderFieldWithContextAndData(field, allValidations, data))
}
// Use pre-compiled group template
templateCacheMutex.RLock()
groupTemplate := compiledGroupTemplate
templateCacheMutex.RUnlock()
if groupTemplate != nil {
var groupHTML bytes.Buffer
templateData := map[string]any{
"Title": group.Title,
"GroupClass": group.GroupClass,
"FieldsHTML": template.HTML(fieldsHTML.String()),
}
if err := groupTemplate.Execute(&groupHTML, templateData); err == nil {
return groupHTML.String()
}
}
// Fallback to original method
return renderGroup(group)
}
func renderField(field FieldInfo) string {
return renderFieldWithContext(field, make(map[string]ValidationInfo))
}
// renderFieldWithContext renders a field with access to all field validations for dependency checking
func renderFieldWithContext(field FieldInfo, allValidations map[string]ValidationInfo) string {
return renderFieldWithContextAndData(field, allValidations, nil)
}
// renderFieldWithContextAndData renders a field with access to all field validations and template data for interpolation
func renderFieldWithContextAndData(field FieldInfo, allValidations map[string]ValidationInfo, data map[string]any) string {
element := determineFieldElement(field.Schema)
if element == "" {
return ""
}
allAttributes := buildAttributesWithValidation(field, data)
content := getFieldContent(field)
contentHTML := getFieldContentHTML(field)
var labelHTML string
if element != "input" || getInputTypeFromSchema(field.Schema) != "hidden" {
labelHTML = generateLabel(field)
}
optionsHTML := generateOptionsFromSchema(field.Schema)
validationJS := generateClientSideValidation(field.FieldPath, field.Validation, allValidations)
templateData := map[string]any{
"Element": element,
"AllAttributes": template.HTMLAttr(allAttributes),
"Content": content,
"ContentHTML": template.HTML(contentHTML + validationJS),
"LabelHTML": template.HTML(labelHTML),
"Class": getFieldWrapperClass(field.Schema),
"OptionsHTML": template.HTML(optionsHTML),
}
var tmplStr string
if element == "input" && getInputTypeFromSchema(field.Schema) == "hidden" {
if template, exists := fieldTemplates["input_hidden"]; exists {
tmplStr = template
} else {
tmplStr = fieldTemplates["input"]
}
} else if template, exists := fieldTemplates[element]; exists {
tmplStr = template
} else if voidElements[element] {
tmplStr = fieldTemplates["void"]
} else {
tmplStr = fieldTemplates["generic"]
}
tmpl := template.Must(template.New(element).Parse(tmplStr))
var buf bytes.Buffer
if err := tmpl.Execute(&buf, templateData); err != nil {
return ""
}
return buf.String()
}
// determineFieldElement determines the appropriate HTML element based on JSON Schema
func determineFieldElement(schema *jsonschema.Schema) string {
if schema.UI != nil {
if element, ok := schema.UI["element"].(string); ok {
return element
}
}
if shouldUseSelect(schema) {
return "select"
}
if shouldUseTextarea(schema) {
return "textarea"
}
var typeStr string
if len(schema.Type) > 0 {
typeStr = schema.Type[0]
}
switch typeStr {
case "boolean":
return "input" // will be type="checkbox"
case "array":
return "input" // could be enhanced for array inputs
case "object":
return "fieldset" // for nested objects
default:
return "input"
}
}
// buildAttributesWithValidation creates all HTML attributes including validation with data interpolation
func buildAttributesWithValidation(field FieldInfo, data map[string]any) string {
var builder strings.Builder
builder.Grow(512) // Pre-allocate capacity
// Check if UI specifies a custom name, otherwise use field path for nested fields
var fieldName string
if field.Schema.UI != nil {
if customName, exists := field.Schema.UI["name"].(string); exists && customName != "" {
fieldName = customName
}
}
// Fallback to field path or field name
if fieldName == "" {
fieldName = field.FieldPath
if fieldName == "" {
fieldName = field.Name
}
}
// Add name attribute
builder.WriteString(`name="`)
builder.WriteString(fieldName)
builder.WriteString(`"`)
// Add id attribute for accessibility
fieldId := strings.ReplaceAll(fieldName, ".", "_")
builder.WriteString(` id="`)
builder.WriteString(fieldId)
builder.WriteString(`"`)
// Determine and add type attribute for input elements
element := determineFieldElement(field.Schema)
if element == "input" {
inputType := getInputTypeFromSchema(field.Schema)
builder.WriteString(` type="`)
builder.WriteString(inputType)
builder.WriteString(`"`)
}
// Add validation attributes
validationAttrs := generateValidationAttributes(field.Validation)
for _, attr := range validationAttrs {
builder.WriteString(` `)
builder.WriteString(attr)
}
// Add default value with interpolation
defaultValue := getDefaultValue(field.Schema)
if field.Schema.UI != nil {
if uiValue, exists := field.Schema.UI["value"].(string); exists {
// Interpolate template values in UI value
if data != nil {
defaultValue = interpolateString(uiValue, data)
} else {
defaultValue = uiValue
}
}
}
if defaultValue != "" {
builder.WriteString(` value="`)
builder.WriteString(defaultValue)
builder.WriteString(`"`)
}
// Add placeholder with interpolation
placeholder := getPlaceholder(field.Schema)
if field.Schema.UI != nil {
if uiPlaceholder, exists := field.Schema.UI["placeholder"].(string); exists {
if data != nil {
placeholder = interpolateString(uiPlaceholder, data)
} else {
placeholder = uiPlaceholder
}
}
}
if placeholder != "" {
builder.WriteString(` placeholder="`)
builder.WriteString(placeholder)
builder.WriteString(`"`)
}
// Add standard attributes from UI with interpolation
if field.Schema.UI != nil {
for _, attr := range standardAttrs {
if attr == "name" || attr == "id" || attr == "type" || attr == "required" ||
attr == "pattern" || attr == "min" || attr == "max" || attr == "minlength" ||
attr == "maxlength" || attr == "step" || attr == "value" || attr == "placeholder" {
continue // Already handled above or in validation
}
if value, exists := field.Schema.UI[attr]; exists {
if attr == "class" && value == "" {
continue // Skip empty class
}
// Apply interpolation to string values if data is available
valueStr := fmt.Sprintf("%v", value)
if data != nil && strings.Contains(valueStr, "{{") {
valueStr = interpolateString(valueStr, data)
}
builder.WriteString(` `)
builder.WriteString(attr)
builder.WriteString(`="`)
builder.WriteString(valueStr)
builder.WriteString(`"`)
}
}
// Add data-* and aria-* attributes with interpolation
for key, value := range field.Schema.UI {
if strings.HasPrefix(key, "data-") || strings.HasPrefix(key, "aria-") {
valueStr := fmt.Sprintf("%v", value)
if data != nil && strings.Contains(valueStr, "{{") {
valueStr = interpolateString(valueStr, data)
}
builder.WriteString(` `)
builder.WriteString(key)
builder.WriteString(`="`)
builder.WriteString(valueStr)
builder.WriteString(`"`)
}
}
}
return builder.String()
}
// generateOptionsFromSchema generates option HTML from schema enum or UI options
func generateOptionsFromSchema(schema *jsonschema.Schema) string {
var optionsHTML strings.Builder
// Check UI options first
if schema.UI != nil {
if options, ok := schema.UI["options"].([]any); ok {
for _, option := range options {
if optionMap, ok := option.(map[string]any); ok {
value := getMapValue(optionMap, "value", "")
text := getMapValue(optionMap, "text", value)
selected := ""
if isSelected, ok := optionMap["selected"].(bool); ok && isSelected {
selected = ` selected="selected"`
}
disabled := ""
if isDisabled, ok := optionMap["disabled"].(bool); ok && isDisabled {
disabled = ` disabled="disabled"`
}
optionsHTML.WriteString(fmt.Sprintf(`<option value="%s"%s%s>%s</option>`,
value, selected, disabled, text))
} else {
optionsHTML.WriteString(fmt.Sprintf(`<option value="%v">%v</option>`, option, option))
}
}
return optionsHTML.String()
}
}
// Generate options from enum
if len(schema.Enum) > 0 {
for _, enumValue := range schema.Enum {
optionsHTML.WriteString(fmt.Sprintf(`<option value="%v">%v</option>`, enumValue, enumValue))
}
}
return optionsHTML.String()
}
// getFieldWrapperClass determines the CSS class for the field wrapper
func getFieldWrapperClass(schema *jsonschema.Schema) string {
if schema.UI != nil {
if class, ok := schema.UI["class"].(string); ok {
return class
}
}
return "form-group" // default class
}
// getDefaultValue extracts default value from schema
func getDefaultValue(schema *jsonschema.Schema) string {
if schema.Default != nil {
return fmt.Sprintf("%v", schema.Default)
}
return ""
}
// getPlaceholder extracts placeholder from schema
func getPlaceholder(schema *jsonschema.Schema) string {
if schema.UI != nil {
if placeholder, ok := schema.UI["placeholder"].(string); ok {
return placeholder
}
}
// Auto-generate placeholder from title or description
if schema.Title != nil {
return fmt.Sprintf("Enter %s", strings.ToLower(*schema.Title))
}
if schema.Description != nil {
return *schema.Description
}
return ""
}
func getFieldContent(field FieldInfo) string {
// Check for content in UI first
if field.Schema.UI != nil {
if content, ok := field.Schema.UI["value"].(string); ok {
return content
}
if content, ok := field.Schema.UI["defaultValue"].(string); ok {
return content
}
if content, ok := field.Schema.UI["content"].(string); ok {
return content
}
}
return ""
}
func getFieldContentHTML(field FieldInfo) string {
// Check for HTML content in UI
if field.Schema.UI != nil {
if contentHTML, ok := field.Schema.UI["contentHTML"].(string); ok {
return contentHTML
}
// Check for children elements
if children, ok := field.Schema.UI["children"].([]any); ok {
return renderChildren(children)
}
}
return ""
}
func renderChildren(children []any) string {
var result strings.Builder
for _, child := range children {
if childMap, ok := child.(map[string]any); ok {
// Create a temporary field info for the child
childSchema := &jsonschema.Schema{
UI: childMap,
}
if title, ok := childMap["title"].(string); ok {
childSchema.Title = &title
}
childField := FieldInfo{
Name: getMapValue(childMap, "name", ""),
Schema: childSchema,
}
result.WriteString(renderField(childField))
}
}
return result.String()
}
func generateLabel(field FieldInfo) string {
// Check if label should be generated
if field.Schema.UI != nil {
if showLabel, ok := field.Schema.UI["showLabel"].(bool); !showLabel && ok {
return ""
}
}
var title string
if field.Schema.Title != nil {
title = *field.Schema.Title
}
if title == "" {
return ""
}
fieldName := field.FieldPath
if fieldName == "" {
fieldName = field.Name
}
// Check if field is required
requiredSpan := ""
if field.IsRequired {
requiredSpan = ` <span class="required">*</span>`
}
return fmt.Sprintf(`<label for="%s">%s%s</label>`, fieldName, title, requiredSpan)
}
func getMapValue(m map[string]any, key, defaultValue string) string {
if value, ok := m[key].(string); ok {
return value
}
return defaultValue
}
func contains(slice []string, item string) bool {
for _, s := range slice {
if s == item {
return true
}
}
return false
}
// renderButtons generates HTML for form buttons
func (r *JSONSchemaRenderer) renderButtons() string {
if r.Schema.Form == nil {
return ""
}
var buttonsHTML bytes.Buffer
if submitConfig, ok := r.Schema.Form["submit"].(map[string]any); ok {
buttonHTML := renderButtonFromConfig(submitConfig, "submit")
buttonsHTML.WriteString(buttonHTML)
}
if resetConfig, ok := r.Schema.Form["reset"].(map[string]any); ok {
buttonHTML := renderButtonFromConfig(resetConfig, "reset")
buttonsHTML.WriteString(buttonHTML)
}
// Support for additional custom buttons
if buttons, ok := r.Schema.Form["buttons"].([]any); ok {
for _, button := range buttons {
if buttonMap, ok := button.(map[string]any); ok {
buttonType := getMapValue(buttonMap, "type", "button")
buttonHTML := renderButtonFromConfig(buttonMap, buttonType)
buttonsHTML.WriteString(buttonHTML)
}
}
}
return buttonsHTML.String()
}
func renderButtonFromConfig(config map[string]any, defaultType string) string {
var attributes []string
buttonType := getMapValue(config, "type", defaultType)
attributes = append(attributes, fmt.Sprintf(`type="%s"`, buttonType))
if class := getMapValue(config, "class", ""); class != "" {
attributes = append(attributes, fmt.Sprintf(`class="%s"`, class))
}
// Add other button attributes
for key, value := range config {
switch key {
case "type", "class", "label", "content":
continue // Already handled
default:
attributes = append(attributes, fmt.Sprintf(`%s="%v"`, key, value))
}
}
content := getMapValue(config, "label", getMapValue(config, "content", "Button"))
return fmt.Sprintf(`<button %s>%s</button>`,
strings.Join(attributes, " "), content)
}
type RequestSchemaTemplate struct {
Schema *jsonschema.Schema `json:"schema"`
Renderer *JSONSchemaRenderer `json:"template"`
}
var (
cache = make(map[string]*RequestSchemaTemplate)
mu = &sync.RWMutex{}
BaseTemplateDir = "templates"
)
// ClearCache clears the template cache
func ClearCache() {
mu.Lock()
defer mu.Unlock()
cache = make(map[string]*RequestSchemaTemplate)
}
// GetCacheSize returns the current cache size
func GetCacheSize() int {
mu.RLock()
defer mu.RUnlock()
return len(cache)
}
func GetFromBytes(schemaContent []byte, template string, templateFiles ...string) (*RequestSchemaTemplate, error) {
template = strings.TrimSpace(template)
compiler := jsonschema.NewCompiler()
schema, err := compiler.Compile(schemaContent)
if err != nil {
return nil, fmt.Errorf("error compiling schema: %w", err)
}
var htmlLayout []byte
if len(templateFiles) > 0 && templateFiles[0] != "" {
templateFile := templateFiles[0]
if !strings.Contains(templateFile, "/") {
template = fmt.Sprintf("%s/%s", BaseTemplateDir, templateFile)
}
if !strings.HasSuffix(templateFile, ".html") {
template += ".html"
}
htmlLayout, err = os.ReadFile(templateFile)
if err != nil {
return nil, fmt.Errorf("failed to load template: %w", err)
}
} else if template != "" {
htmlLayout = []byte(template)
} else {
htmlLayout = []byte(`
<form {{form_attributes}}>
<div>
{{form_groups}}
<div>
{{form_buttons}}
</div>
</div>
</form>
`)
}
renderer := NewJSONSchemaRenderer(schema, string(htmlLayout))
cachedTemplate := &RequestSchemaTemplate{
Schema: schema,
Renderer: renderer,
}
return cachedTemplate, nil
}
func GetFromSchema(schema *jsonschema.Schema, template string, templateFiles ...string) (*RequestSchemaTemplate, error) {
template = strings.TrimSpace(template)
var htmlLayout []byte
var err error
if len(templateFiles) > 0 && templateFiles[0] != "" {
templateFile := templateFiles[0]
if !strings.Contains(templateFile, "/") {
template = fmt.Sprintf("%s/%s", BaseTemplateDir, templateFile)
}
if !strings.HasSuffix(templateFile, ".html") {
template += ".html"
}
htmlLayout, err = os.ReadFile(templateFile)
if err != nil {
return nil, fmt.Errorf("failed to load template: %w", err)
}
} else if template != "" {
htmlLayout = []byte(template)
} else {
htmlLayout = []byte(`
<form {{form_attributes}}>
<div>
{{form_groups}}
<div>
{{form_buttons}}
</div>
</div>
</form>
`)
}
renderer := NewJSONSchemaRenderer(schema, string(htmlLayout))
cachedTemplate := &RequestSchemaTemplate{
Schema: schema,
Renderer: renderer,
}
return cachedTemplate, nil
}
func GetFromFile(schemaPath, template string, templateFiles ...string) (*JSONSchemaRenderer, error) {
path := schemaPath
if len(templateFiles) > 0 {
templateFile := templateFiles[0]
path += fmt.Sprintf(":%s", templateFile)
}
mu.RLock()
if cached, exists := cache[path]; exists {
mu.RUnlock()
return cached.Renderer, nil
}
mu.RUnlock()
mu.Lock()
defer mu.Unlock()
if cached, exists := cache[path]; exists {
return cached.Renderer, nil
}
schemaContent, err := os.ReadFile(schemaPath)
if err != nil {
return nil, fmt.Errorf("error reading schema file: %w", err)
}
schemaRenderer, err := GetFromBytes(schemaContent, template, templateFiles...)
if err != nil {
return nil, fmt.Errorf("error creating renderer from bytes: %w", err)
}
cache[path] = schemaRenderer
return schemaRenderer.Renderer, nil
}
// interpolateString replaces template placeholders in a string with actual values
func interpolateString(templateStr string, data map[string]any) string {
if len(data) == 0 {
return templateStr
}
// First try Go template interpolation
tmpl, err := template.New("interpolate").Parse(templateStr)
if err == nil {
var templateResult bytes.Buffer
err = tmpl.Execute(&templateResult, data)
if err == nil {
return templateResult.String()
}
}
// Fallback to simple string replacement if template parsing/execution fails
result := templateStr
for key, value := range data {
placeholder := fmt.Sprintf("{{%s}}", key)
if valueStr, ok := value.(string); ok {
result = strings.ReplaceAll(result, placeholder, valueStr)
} else {
result = strings.ReplaceAll(result, placeholder, fmt.Sprintf("%v", value))
}
}
return result
}