From cf3ad736b7be11c578f75f41ca39906ae2a9e184 Mon Sep 17 00:00:00 2001 From: Mason Payne Date: Mon, 25 Aug 2025 00:10:18 -0600 Subject: [PATCH] add an html interpreter --- .gitignore | 2 + .idea/copilotDiffState.xml | 36 --- cmd/cli/commands.go | 280 +++++++++++------ examples/lang/sample.masonry | 61 ++++ interpreter/html_interpreter.go | 526 ++++++++++++++++++++++++++++++++ 5 files changed, 777 insertions(+), 128 deletions(-) create mode 100644 .gitignore create mode 100644 examples/lang/sample.masonry create mode 100644 interpreter/html_interpreter.go diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..533b18e --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +/html-output/ +/masonry.exe diff --git a/.idea/copilotDiffState.xml b/.idea/copilotDiffState.xml index 0beb31b..986686f 100644 --- a/.idea/copilotDiffState.xml +++ b/.idea/copilotDiffState.xml @@ -28,42 +28,6 @@ - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/cmd/cli/commands.go b/cmd/cli/commands.go index 5ded549..30861c9 100644 --- a/cmd/cli/commands.go +++ b/cmd/cli/commands.go @@ -11,9 +11,15 @@ import ( vue_gen "masonry/vue-gen" "os" "os/exec" + "path/filepath" "runtime" "strings" "text/template" + + "github.com/alecthomas/participle/v2" + + "masonry/interpreter" + "masonry/lang" ) //go:embed templates/proto/application.proto.tmpl @@ -156,104 +162,143 @@ func generateCmd() *cli.Command { return &cli.Command{ Name: "generate", Aliases: []string{"g"}, - Usage: "Generate code from proto files", + Usage: "Generate code from proto files or Masonry files", Category: "generator", - Description: "This command will generate code from the proto files in the proto directory and place them in a language folder in the gen folder.", + Description: "This command will generate code from proto files or convert Masonry files to various formats.", + Subcommands: []*cli.Command{ + { + Name: "proto", + Usage: "Generate code from proto files", + Action: func(c *cli.Context) error { + return generateProtoCode() + }, + }, + { + Name: "html", + Usage: "Generate HTML from Masonry files", + Flags: []cli.Flag{ + &cli.StringFlag{ + Name: "input", + Usage: "Input Masonry file path", + Required: true, + Aliases: []string{"i"}, + }, + &cli.StringFlag{ + Name: "output", + Usage: "Output directory for generated HTML files", + Value: "./output", + Aliases: []string{"o"}, + }, + }, + Action: func(c *cli.Context) error { + inputFile := c.String("input") + outputDir := c.String("output") + + return generateHTML(inputFile, outputDir) + }, + }, + }, Action: func(c *cli.Context) error { - fmt.Println("Generating code...") - - protocArgs := []string{ - "-I", - ".", - "--go_out", - "gen/go", - "--go-grpc_out", - "gen/go", - "--go-grpc_opt=require_unimplemented_servers=false", - "--gorm_out", - "gen/go", - "--grpc-gateway_out", - "gen/go", - "--grpc-gateway_opt", - "logtostderr=true", - "--openapiv2_out", - "gen/openapi", - "--openapiv2_opt", - "logtostderr=true", - "--proto_path=./proto_include", - "proto/*.proto", - } - - // generate go code - cmd := exec.Command( - "protoc", - protocArgs..., - ) - cmd.Stdout = os.Stdout - cmd.Stderr = os.Stderr - err := cmd.Run() - if err != nil { - - var buff bytes.Buffer - cmd.Stderr = &buff - fmt.Println(buff.String()) - - return fmt.Errorf("error generating go code | %w", err) - } - - // Generate ts code - // if webapp folder is present, generate typescript code - if _, err := os.Stat("webapp"); err == nil { - err = os.Chdir("webapp") - if err != nil { - return fmt.Errorf("error changing directory to webapp | %w", err) - } - - cmd = exec.Command("npx", - "openapi-typescript-codegen", - "--input", - "../gen/openapi/proto/service.swagger.json", - "--output", - "src/generated", - "--client", - "fetch", - ) - cmd.Stdout = os.Stdout - cmd.Stderr = os.Stderr - err = cmd.Run() - if err != nil { - return fmt.Errorf("error generating typescript code | %w", err) - } - - // make sure src/generated-sample-components exists - err = os.Mkdir("src/generated-sample-components", 0755) - if err != nil { - return fmt.Errorf("error creating src/generated-components directory | %w", err) - } - - // generate vue components - err = vue_gen.GenVueFromSwagger("../gen/openapi/proto/service.swagger.json", "src/generated-sample-components") - if err != nil { - return fmt.Errorf("error generating vue components | %w", err) - } - - cmd := exec.Command("npm", "install", "@masonitestudios/dynamic-vue") - err = cmd.Run() - if err != nil { - return fmt.Errorf("error installing @masonitestudios/dynamic-vue | %w", err) - } - - err = os.Chdir("..") - if err != nil { - return fmt.Errorf("error changing directory back to root | %w", err) - } - } - - return nil + // Default action - generate proto code for backward compatibility + return generateProtoCode() }, } } +// generateProtoCode handles the original proto code generation logic +func generateProtoCode() error { + fmt.Println("Generating code...") + + protocArgs := []string{ + "-I", + ".", + "--go_out", + "gen/go", + "--go-grpc_out", + "gen/go", + "--go-grpc_opt=require_unimplemented_servers=false", + "--gorm_out", + "gen/go", + "--grpc-gateway_out", + "gen/go", + "--grpc-gateway_opt", + "logtostderr=true", + "--openapiv2_out", + "gen/openapi", + "--openapiv2_opt", + "logtostderr=true", + "--proto_path=./proto_include", + "proto/*.proto", + } + + // generate go code + cmd := exec.Command( + "protoc", + protocArgs..., + ) + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + err := cmd.Run() + if err != nil { + + var buff bytes.Buffer + cmd.Stderr = &buff + fmt.Println(buff.String()) + + return fmt.Errorf("error generating go code | %w", err) + } + + // Generate ts code + // if webapp folder is present, generate typescript code + if _, err := os.Stat("webapp"); err == nil { + err = os.Chdir("webapp") + if err != nil { + return fmt.Errorf("error changing directory to webapp | %w", err) + } + + cmd = exec.Command("npx", + "openapi-typescript-codegen", + "--input", + "../gen/openapi/proto/service.swagger.json", + "--output", + "src/generated", + "--client", + "fetch", + ) + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + err = cmd.Run() + if err != nil { + return fmt.Errorf("error generating typescript code | %w", err) + } + + // make sure src/generated-sample-components exists + err = os.Mkdir("src/generated-sample-components", 0755) + if err != nil { + return fmt.Errorf("error creating src/generated-components directory | %w", err) + } + + // generate vue components + err = vue_gen.GenVueFromSwagger("../gen/openapi/proto/service.swagger.json", "src/generated-sample-components") + if err != nil { + return fmt.Errorf("error generating vue components | %w", err) + } + + cmd := exec.Command("npm", "install", "@masonitestudios/dynamic-vue") + err = cmd.Run() + if err != nil { + return fmt.Errorf("error installing @masonitestudios/dynamic-vue | %w", err) + } + + err = os.Chdir("..") + if err != nil { + return fmt.Errorf("error changing directory back to root | %w", err) + } + } + + return nil +} + func webappCmd() *cli.Command { return &cli.Command{ Name: "webapp", @@ -368,3 +413,54 @@ func vueGenCmd() *cli.Command { }, } } + +// generateHTML parses a Masonry file and generates HTML output +func generateHTML(inputFile, outputDir string) error { + fmt.Printf("Generating HTML from %s to %s\n", inputFile, outputDir) + + // Read the input file + content, err := os.ReadFile(inputFile) + if err != nil { + return fmt.Errorf("failed to read input file: %w", err) + } + + // Create parser + parser, err := participle.Build[lang.AST]() + if err != nil { + return fmt.Errorf("failed to create parser: %w", err) + } + + // Parse the Masonry file + ast, err := parser.ParseString(inputFile, string(content)) + if err != nil { + return fmt.Errorf("failed to parse Masonry file: %w", err) + } + + // Create HTML interpreter + htmlInterpreter := interpreter.NewHTMLInterpreter() + + // Generate HTML + htmlFiles, err := htmlInterpreter.GenerateHTML(ast) + if err != nil { + return fmt.Errorf("failed to generate HTML: %w", err) + } + + // Create output directory + err = os.MkdirAll(outputDir, 0755) + if err != nil { + return fmt.Errorf("failed to create output directory: %w", err) + } + + // Write HTML files + for filename, content := range htmlFiles { + outputPath := filepath.Join(outputDir, filename) + err = os.WriteFile(outputPath, []byte(content), 0644) + if err != nil { + return fmt.Errorf("failed to write file %s: %w", outputPath, err) + } + fmt.Printf("Generated: %s\n", outputPath) + } + + fmt.Printf("Successfully generated %d HTML file(s)\n", len(htmlFiles)) + return nil +} diff --git a/examples/lang/sample.masonry b/examples/lang/sample.masonry new file mode 100644 index 0000000..015704c --- /dev/null +++ b/examples/lang/sample.masonry @@ -0,0 +1,61 @@ +// Sample Masonry application demonstrating the language features +entity User { + name: string required + email: string required unique + age: number + role: string default "user" + password: string required +} + +page UserDashboard at "/dashboard" layout main title "User Dashboard" { + meta description "User management dashboard" + meta keywords "user, dashboard, management" + + section header type container class "header" { + component nav { + field logo type text value "MyApp" + button profile label "Profile" + button logout label "Logout" + } + } + + section content type tab { + section users label "Users" active { + component table for User { + field name type text label "Full Name" sortable searchable + field email type email label "Email Address" sortable + field role type select options ["admin", "user", "moderator"] + button edit label "Edit" + button delete label "Delete" + } + } + + section settings label "Settings" { + component form for User { + field name type text label "Full Name" required placeholder "Enter your name" + field email type email label "Email" required placeholder "Enter your email" + field age type number label "Age" placeholder "Enter your age" + field role type select label "Role" options ["admin", "user", "moderator"] default "user" + field password type password label "Password" required + + when role equals "admin" { + field permissions type select label "Admin Permissions" options ["read", "write", "delete"] + } + + button save label "Save Changes" + button cancel label "Cancel" + } + } + + section profile label "Profile" { + component form { + field avatar type file label "Profile Picture" accept ".jpg,.png" + field bio type textarea label "Biography" rows 4 placeholder "Tell us about yourself" + field theme type select label "Theme" options ["light", "dark"] default "light" + field notifications type checkbox label "Enable Notifications" default "true" + + button update label "Update Profile" + } + } + } +} diff --git a/interpreter/html_interpreter.go b/interpreter/html_interpreter.go new file mode 100644 index 0000000..c35afdc --- /dev/null +++ b/interpreter/html_interpreter.go @@ -0,0 +1,526 @@ +package interpreter + +import ( + "fmt" + "masonry/lang" + "strings" +) + +// HTMLInterpreter converts Masonry AST to HTML/JavaScript +type HTMLInterpreter struct { + entities map[string]*lang.Entity + pages map[string]*lang.Page +} + +// NewHTMLInterpreter creates a new HTML interpreter +func NewHTMLInterpreter() *HTMLInterpreter { + return &HTMLInterpreter{ + entities: make(map[string]*lang.Entity), + pages: make(map[string]*lang.Page), + } +} + +// cleanString removes surrounding quotes from string literals +func (hi *HTMLInterpreter) cleanString(s string) string { + if len(s) >= 2 && ((s[0] == '"' && s[len(s)-1] == '"') || (s[0] == '\'' && s[len(s)-1] == '\'')) { + return s[1 : len(s)-1] + } + return s +} + +// escapeHTML escapes HTML special characters +func (hi *HTMLInterpreter) escapeHTML(s string) string { + s = strings.ReplaceAll(s, "&", "&") + s = strings.ReplaceAll(s, "<", "<") + s = strings.ReplaceAll(s, ">", ">") + s = strings.ReplaceAll(s, "\"", """) + s = strings.ReplaceAll(s, "'", "'") + return s +} + +// GenerateHTML converts a Masonry AST to HTML output +func (hi *HTMLInterpreter) GenerateHTML(ast *lang.AST) (map[string]string, error) { + // First pass: collect entities and pages + for _, def := range ast.Definitions { + if def.Entity != nil { + hi.entities[def.Entity.Name] = def.Entity + } + if def.Page != nil { + hi.pages[def.Page.Name] = def.Page + } + } + + // Second pass: generate HTML for each page + htmlFiles := make(map[string]string) + + for pageName, page := range hi.pages { + html, err := hi.generatePageHTML(page) + if err != nil { + return nil, fmt.Errorf("error generating HTML for page %s: %w", pageName, err) + } + htmlFiles[fmt.Sprintf("%s.html", strings.ToLower(pageName))] = html + } + + return htmlFiles, nil +} + +// generatePageHTML creates HTML for a single page +func (hi *HTMLInterpreter) generatePageHTML(page *lang.Page) (string, error) { + var html strings.Builder + + // HTML document structure + html.WriteString("\n\n\n") + html.WriteString(" \n") + html.WriteString(" \n") + + // Page title + title := page.Name + if page.Title != nil { + title = hi.cleanString(*page.Title) + } + html.WriteString(fmt.Sprintf(" %s\n", hi.escapeHTML(title))) + + // Meta tags + for _, meta := range page.Meta { + cleanName := hi.cleanString(meta.Name) + cleanContent := hi.cleanString(meta.Content) + html.WriteString(fmt.Sprintf(" \n", + hi.escapeHTML(cleanName), hi.escapeHTML(cleanContent))) + } + + // Basic CSS for styling + html.WriteString(" \n") + html.WriteString("\n\n") + + // Page content + html.WriteString("
\n") + html.WriteString(fmt.Sprintf("

%s

\n", hi.escapeHTML(title))) + + // Generate sections + for _, section := range page.Sections { + sectionHTML, err := hi.generateSectionHTML(§ion, 2) + if err != nil { + return "", err + } + html.WriteString(sectionHTML) + } + + // Generate direct components + for _, component := range page.Components { + componentHTML, err := hi.generateComponentHTML(&component, 2) + if err != nil { + return "", err + } + html.WriteString(componentHTML) + } + + html.WriteString("
\n") + + // JavaScript for interactivity + html.WriteString(" \n") + + html.WriteString("\n") + + return html.String(), nil +} + +// generateSectionHTML creates HTML for a section +func (hi *HTMLInterpreter) generateSectionHTML(section *lang.Section, indent int) (string, error) { + var html strings.Builder + indentStr := strings.Repeat(" ", indent) + + sectionType := "container" + if section.Type != nil { + sectionType = *section.Type + } + + switch sectionType { + case "tab": + html.WriteString(fmt.Sprintf("%s
\n", indentStr, section.Name)) + + // Generate tab buttons + for _, element := range section.Elements { + if element.Section != nil && element.Section.Label != nil { + activeClass := "" + if element.Section.Active { + activeClass = " active" + } + cleanLabel := hi.cleanString(*element.Section.Label) + html.WriteString(fmt.Sprintf("%s \n", + indentStr, activeClass, element.Section.Name, hi.escapeHTML(cleanLabel))) + } + } + + // Generate tab content + for _, element := range section.Elements { + if element.Section != nil { + activeClass := "" + if element.Section.Active { + activeClass = " active" + } + html.WriteString(fmt.Sprintf("%s
\n", + indentStr, activeClass, element.Section.Name)) + + sectionHTML, err := hi.generateSectionHTML(element.Section, indent+2) + if err != nil { + return "", err + } + html.WriteString(sectionHTML) + html.WriteString(fmt.Sprintf("%s
\n", indentStr)) + } + } + + html.WriteString(fmt.Sprintf("%s
\n", indentStr)) + + case "modal": + html.WriteString(fmt.Sprintf("%s
\n", indentStr, section.Name)) + html.WriteString(fmt.Sprintf("%s
\n", indentStr)) + if section.Label != nil { + cleanLabel := hi.cleanString(*section.Label) + html.WriteString(fmt.Sprintf("%s

%s

\n", indentStr, hi.escapeHTML(cleanLabel))) + } + + // Generate modal content + for _, element := range section.Elements { + elementHTML, err := hi.generateSectionElementHTML(&element, indent+2) + if err != nil { + return "", err + } + html.WriteString(elementHTML) + } + + html.WriteString(fmt.Sprintf("%s \n", indentStr, section.Name)) + html.WriteString(fmt.Sprintf("%s
\n", indentStr)) + html.WriteString(fmt.Sprintf("%s
\n", indentStr)) + + default: // container, panel, master, detail + cssClass := "section" + if section.Class != nil { + cssClass = hi.cleanString(*section.Class) + } + + html.WriteString(fmt.Sprintf("%s
\n", indentStr, hi.escapeHTML(cssClass), section.Name)) + + if section.Label != nil { + cleanLabel := hi.cleanString(*section.Label) + html.WriteString(fmt.Sprintf("%s

%s

\n", indentStr, hi.escapeHTML(cleanLabel))) + } + + // Generate section content + for _, element := range section.Elements { + elementHTML, err := hi.generateSectionElementHTML(&element, indent+1) + if err != nil { + return "", err + } + html.WriteString(elementHTML) + } + + html.WriteString(fmt.Sprintf("%s
\n", indentStr)) + } + + return html.String(), nil +} + +// generateSectionElementHTML creates HTML for section elements +func (hi *HTMLInterpreter) generateSectionElementHTML(element *lang.SectionElement, indent int) (string, error) { + if element.Component != nil { + return hi.generateComponentHTML(element.Component, indent) + } + if element.Section != nil { + return hi.generateSectionHTML(element.Section, indent) + } + if element.When != nil { + return hi.generateWhenConditionHTML(element.When, indent) + } + + return "", nil +} + +// generateComponentHTML creates HTML for a component +func (hi *HTMLInterpreter) generateComponentHTML(component *lang.Component, indent int) (string, error) { + var html strings.Builder + indentStr := strings.Repeat(" ", indent) + + switch component.Type { + case "form": + formId := fmt.Sprintf("form_%s", component.Type) + if component.Entity != nil { + formId = fmt.Sprintf("form_%s", *component.Entity) + } + + html.WriteString(fmt.Sprintf("%s
\n", indentStr, formId, formId)) + + // Generate form fields + for _, element := range component.Elements { + if element.Field != nil { + fieldHTML, err := hi.generateFieldHTML(element.Field, indent+1) + if err != nil { + return "", err + } + html.WriteString(fieldHTML) + } + if element.Button != nil { + buttonHTML, err := hi.generateButtonHTML(element.Button, indent+1) + if err != nil { + return "", err + } + html.WriteString(buttonHTML) + } + } + + html.WriteString(fmt.Sprintf("%s \n", indentStr)) + html.WriteString(fmt.Sprintf("%s
\n", indentStr)) + + case "table": + html.WriteString(fmt.Sprintf("%s\n", indentStr)) + html.WriteString(fmt.Sprintf("%s \n", indentStr)) + + // Generate table headers from fields + for _, element := range component.Elements { + if element.Field != nil { + label := element.Field.Name + for _, attr := range element.Field.Attributes { + if attr.Label != nil { + label = hi.cleanString(*attr.Label) + break + } + } + html.WriteString(fmt.Sprintf("%s \n", indentStr, hi.escapeHTML(label))) + } + } + + html.WriteString(fmt.Sprintf("%s \n", indentStr)) + html.WriteString(fmt.Sprintf("%s \n", indentStr)) + html.WriteString(fmt.Sprintf("%s \n", indentStr)) + html.WriteString(fmt.Sprintf("%s \n", indentStr)) + html.WriteString(fmt.Sprintf("%s
%s
Data will be loaded here...
\n", indentStr)) + + default: + html.WriteString(fmt.Sprintf("%s
\n", indentStr, component.Type)) + + // Generate component content + for _, element := range component.Elements { + if element.Field != nil { + fieldHTML, err := hi.generateFieldHTML(element.Field, indent+1) + if err != nil { + return "", err + } + html.WriteString(fieldHTML) + } + if element.Button != nil { + buttonHTML, err := hi.generateButtonHTML(element.Button, indent+1) + if err != nil { + return "", err + } + html.WriteString(buttonHTML) + } + } + + html.WriteString(fmt.Sprintf("%s
\n", indentStr)) + } + + return html.String(), nil +} + +// generateFieldHTML creates HTML for a field +func (hi *HTMLInterpreter) generateFieldHTML(field *lang.ComponentField, indent int) (string, error) { + var html strings.Builder + indentStr := strings.Repeat(" ", indent) + + // Get field attributes + var label, placeholder, defaultValue string + var required bool + var options []string + + label = field.Name + for _, attr := range field.Attributes { + if attr.Label != nil { + label = hi.cleanString(*attr.Label) + } + if attr.Placeholder != nil { + placeholder = hi.cleanString(*attr.Placeholder) + } + if attr.Default != nil { + defaultValue = hi.cleanString(*attr.Default) + } + if attr.Required { + required = true + } + if attr.Options != nil { + // Clean each option in the array + options = make([]string, len(attr.Options)) + for i, opt := range attr.Options { + options[i] = hi.cleanString(opt) + } + } + } + + html.WriteString(fmt.Sprintf("%s
\n", indentStr)) + + requiredAttr := "" + if required { + requiredAttr = " required" + } + + html.WriteString(fmt.Sprintf("%s \n", indentStr, field.Name, hi.escapeHTML(label))) + + switch field.Type { + case "text", "email", "password", "number", "tel", "url": + html.WriteString(fmt.Sprintf("%s \n", + indentStr, field.Type, field.Name, field.Name, hi.escapeHTML(placeholder), hi.escapeHTML(defaultValue), requiredAttr)) + + case "textarea": + html.WriteString(fmt.Sprintf("%s \n", + indentStr, field.Name, field.Name, hi.escapeHTML(placeholder), requiredAttr, hi.escapeHTML(defaultValue))) + + case "select": + html.WriteString(fmt.Sprintf("%s \n", indentStr)) + + case "checkbox": + checked := "" + if defaultValue == "true" { + checked = " checked" + } + html.WriteString(fmt.Sprintf("%s \n", + indentStr, field.Name, field.Name, checked, requiredAttr)) + + case "file": + html.WriteString(fmt.Sprintf("%s \n", + indentStr, field.Name, field.Name, requiredAttr)) + + default: + html.WriteString(fmt.Sprintf("%s \n", + indentStr, field.Type, field.Name, field.Name, hi.escapeHTML(placeholder), hi.escapeHTML(defaultValue), requiredAttr)) + } + + html.WriteString(fmt.Sprintf("%s
\n", indentStr)) + + return html.String(), nil +} + +// generateButtonHTML creates HTML for a button +func (hi *HTMLInterpreter) generateButtonHTML(button *lang.ComponentButton, indent int) (string, error) { + var html strings.Builder + indentStr := strings.Repeat(" ", indent) + + buttonClass := "button button-primary" + onclick := "" + + // Process button attributes + for _, attr := range button.Attributes { + if attr.Style != nil { + // Handle button style + } + if attr.Target != nil { + // Handle button target/onclick + } + } + + cleanLabel := hi.cleanString(button.Label) + html.WriteString(fmt.Sprintf("%s\n", + indentStr, buttonClass, onclick, hi.escapeHTML(cleanLabel))) + + return html.String(), nil +} + +// generateWhenConditionHTML creates HTML for conditional content +func (hi *HTMLInterpreter) generateWhenConditionHTML(when *lang.WhenCondition, indent int) (string, error) { + var html strings.Builder + indentStr := strings.Repeat(" ", indent) + + // For now, we'll render the content as visible (real implementation would include JavaScript logic) + html.WriteString(fmt.Sprintf("%s
\n", + indentStr, when.Field, when.Operator, when.Value)) + + // Generate conditional content + for _, field := range when.Fields { + fieldHTML, err := hi.generateFieldHTML(&field, indent+1) + if err != nil { + return "", err + } + html.WriteString(fieldHTML) + } + + for _, section := range when.Sections { + sectionHTML, err := hi.generateSectionHTML(§ion, indent+1) + if err != nil { + return "", err + } + html.WriteString(sectionHTML) + } + + for _, component := range when.Components { + componentHTML, err := hi.generateComponentHTML(&component, indent+1) + if err != nil { + return "", err + } + html.WriteString(componentHTML) + } + + for _, button := range when.Buttons { + buttonHTML, err := hi.generateButtonHTML(&button, indent+1) + if err != nil { + return "", err + } + html.WriteString(buttonHTML) + } + + html.WriteString(fmt.Sprintf("%s
\n", indentStr)) + + return html.String(), nil +}