add html-to-htmgo tool

This commit is contained in:
maddalax 2024-10-11 10:25:41 -05:00
parent 7d748ec7e6
commit 8e4a63d224
10 changed files with 306 additions and 119 deletions

View file

@ -1,119 +0,0 @@
package main
import (
"bytes"
"fmt"
"log"
"strings"
"github.com/dave/jennifer/jen"
"golang.org/x/net/html"
)
func main() {
// Example HTML input
htmlData := `
<body><nav class="flex gap-4 items-center p-4 text-slate-600 "><a href="/" class="cursor-pointer hover:text-blue-400 ">Home</a><a class="cursor-pointer hover:text-blue-400 " href="/news">News</a><a href="/patients" class="cursor-pointer hover:text-blue-400 ">Patients</a></nav><div id="active-modal"></div><div class="flex flex-col gap-2 bg-white h-full "><div class="flex flex-col p-4 w-full "><div><div class="flex justify-between items-center "><p class="text-lg font-bold ">Manage Patients</p><button hx-target="#active-modal" type="button" id="add-patient" class="flex gap-1 items-center border p-4 rounded cursor-hover bg-blue-700 text-white rounded p-2 h-12 " hx-get="htmgo/partials/patient.AddPatientSheet">Add Patient</button></div><div hx-get="htmgo/partials/patient.List" hx-trigger="load, patient-added from:body" class=""><div class="mt-8" id="patient-list"><div class="flex flex-col gap-2 rounded p-4 bg-red-100 "><p>Name: Sydne</p><p>Reason for visit: arm hurts</p></div></div></div></div></div></div><div hx-get="/livereload" hx-trigger="every 200ms" class=""></div></body>
`
// Parse the HTML
doc, err := html.Parse(bytes.NewReader([]byte(htmlData)))
if err != nil {
log.Fatal(err)
}
// Create a new Jennifer file
f := jen.NewFile("main")
// Generate Jennifer code for the parsed HTML tree
generatedCode := processNode(doc.FirstChild)
// Add the generated code to the file
f.Func().Id("Render").Params().Block(generatedCode...)
// Render the generated code
var buf bytes.Buffer
err = f.Render(&buf)
if err != nil {
log.Fatal(err)
}
//// Format the generated code
//formattedCode, err := format.Source(buf.Bytes())
//if err != nil {
// log.Fatal(err)
//}
// Output the formatted code
fmt.Println(string(buf.Bytes()))
}
// Recursively process the HTML nodes and generate Jennifer code
func processNode(n *html.Node) []jen.Code {
var code []jen.Code
// Only process element nodes
if n.Type == html.ElementNode {
// Create a dynamic method call based on the tag name
tagMethod := strings.Title(n.Data) // Capitalize the first letter of the tag
// Add dynamic method call for the tag (e.g., h.Div(), h.Button(), etc.)
code = append(code, jen.Id("h").Dot(tagMethod).Call(mergeArgs(n)...))
}
return code
}
// Merge attributes and children into a single slice for Call()
func mergeArgs(n *html.Node) []jen.Code {
// Process attributes
attrs := processAttributes(n.Attr)
// Process children
children := processChildren(n)
// Combine attributes and children into one slice
return append(attrs, children...)
}
// Process child nodes of a given HTML node
func processChildren(n *html.Node) []jen.Code {
var children []jen.Code
for c := n.FirstChild; c != nil; c = c.NextSibling {
children = append(children, processNode(c)...)
}
return children
}
func FormatFieldName(name string) string {
split := strings.Split(name, "_")
if strings.Contains(name, "-") {
split = strings.Split(name, "-")
}
parts := make([]string, 0)
for _, s := range split {
parts = append(parts, PascalCase(s))
}
return strings.Join(parts, "")
}
func PascalCase(s string) string {
if s == "" {
return s
}
// Convert the first rune (character) to uppercase and concatenate with the rest of the string
return strings.ToUpper(string(s[0])) + s[1:]
}
// Process the attributes of an HTML node and return Jennifer code
func processAttributes(attrs []html.Attribute) []jen.Code {
var args []jen.Code
for _, attr := range attrs {
// Dynamically handle all attributes
attrMethod := FormatFieldName(attr.Key) // E.g., convert "data-role" to "DataRole"
args = append(args, jen.Id("h").Dot(attrMethod).Call(jen.Lit(attr.Val)))
}
return args
}

View file

@ -0,0 +1,8 @@
module html-to-htmgo
go 1.23.0
require (
golang.org/x/net v0.30.0
golang.org/x/text v0.19.0
)

View file

@ -0,0 +1,8 @@
golang.org/x/net v0.29.0 h1:5ORfpBpCs4HzDYoodCDBbwHzdR5UrLBZ3sOnUJmFoHo=
golang.org/x/net v0.29.0/go.mod h1:gLkgy8jTGERgjzMic6DS9+SP0ajcu6Xu3Orq/SpETg0=
golang.org/x/net v0.30.0 h1:AcW1SDZMkb8IpzCdQUaIq2sP4sZ4zw+55h6ynffypl4=
golang.org/x/net v0.30.0/go.mod h1:2wGyMJ5iFasEhkwi13ChkO/t1ECNC4X4eBKkVFyYFlU=
golang.org/x/text v0.18.0 h1:XvMDiNzPAl0jr17s6W9lcaIhGUfUORdGCNsuLmPG224=
golang.org/x/text v0.18.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY=
golang.org/x/text v0.19.0 h1:kTxAhCbGbxhK0IwgSKiMO5awPoDQ0RpfiVYBfK860YM=
golang.org/x/text v0.19.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY=

View file

@ -0,0 +1,29 @@
package formatter
import (
"go/format"
"html-to-htmgo/internal/domain"
)
type Formatter struct {
}
func (f Formatter) Format(node *domain.CustomNode) string {
b := []byte(`package main
import (
"github.com/maddalax/htmgo/framework/h"
)
func MyComponent() *h.Element {
return ` + node.String() + `
}`)
dist, err := format.Source(b)
if err != nil {
return string(b)
}
return string(dist)
}
func New() Formatter {
return Formatter{}
}

View file

@ -0,0 +1,69 @@
package parser
import (
"bytes"
"errors"
"fmt"
"golang.org/x/net/html"
"html-to-htmgo/internal/domain"
"strings"
)
type Parser struct {
}
var ParseErr = errors.New("parse error")
func (p Parser) FromBytes(in []byte) (*domain.CustomNode, error) {
hNode, err := html.Parse(bytes.NewReader(in))
if err != nil {
return nil, fmt.Errorf("%w: %v", ParseErr, err)
}
var findBody func(n *html.Node) *html.Node
findBody = func(n *html.Node) *html.Node {
if n.Data == "body" {
return n
}
var e *html.Node
for c := n.FirstChild; c != nil; c = c.NextSibling {
e = findBody(c)
}
return e
}
body := findBody(hNode)
if body == nil {
return nil, fmt.Errorf("%w", ParseErr)
}
var f func(*html.Node, *domain.CustomNode) *domain.CustomNode
f = func(n *html.Node, cNode *domain.CustomNode) *domain.CustomNode {
if n.Type == html.ElementNode {
cNode.SetType(n.Data)
for _, attr := range n.Attr {
cNode.AddAttr(attr.Key, attr.Val)
}
}
if n.Type == html.TextNode && len(strings.TrimSpace(n.Data)) > 0 {
cNode.ParentNode.AddAttr("h.Text", strings.TrimSpace(n.Data))
}
var i uint
for c := n.FirstChild; c != nil; c = c.NextSibling {
cNode.Nodes = append(cNode.Nodes, &domain.CustomNode{ParentNode: cNode, Level: cNode.Level + 1})
cNode.Nodes[i] = f(c, cNode.Nodes[i])
i++
}
return cNode
}
output := &domain.CustomNode{}
out := f(body, output)
return out, nil
}
func New() Parser {
return Parser{}
}

View file

@ -0,0 +1,33 @@
package parser
import (
"errors"
"testing"
)
func FuzzFromBytes(f *testing.F) {
serviceParser := New()
f.Add([]byte("<html><body>Hello World</body></html>"))
f.Add([]byte("<html><head><title>Test</title></head><body>Sample</body></html>"))
f.Add([]byte("<div>Some random text</div>"))
f.Add([]byte("Invalid HTML"))
f.Add([]byte("<a/><p/>"))
f.Add([]byte("</BodY><!0"))
f.Fuzz(func(t *testing.T, data []byte) {
if len(data) > 10000 { // (10KB)
t.Skip()
}
_, err := serviceParser.FromBytes(data)
if err != nil {
return
}
if err != nil && !isExpectedError(err) {
t.Errorf("Unexpected error: %v", err)
}
})
}
func isExpectedError(err error) bool {
return err != nil && errors.Is(err, ParseErr)
}

View file

@ -0,0 +1,5 @@
package domain
type Formatter interface {
Format(node *CustomNode) (string, error)
}

View file

@ -0,0 +1,127 @@
package domain
import (
"fmt"
"slices"
"strings"
"golang.org/x/text/cases"
"golang.org/x/text/language"
)
type CustomNode struct {
ParentNode *CustomNode
Level uint
customType bool
Type string
Attrs []Attr
Nodes []*CustomNode
}
func (n *CustomNode) SetType(in string) {
switch in {
case "head":
n.Type = "h.Head"
case "thead":
n.Type = "h.THead"
case "tbody":
n.Type = "h.TBody"
case "id":
n.Type = "h.Id"
case "path":
n.Type = "path"
n.customType = true
case "circle":
n.Type = "circle"
n.customType = true
case "rect":
n.Type = "rect"
n.customType = true
case "line":
n.Type = "line"
n.customType = true
case "polyline":
n.Type = "line"
n.customType = true
case "svg":
n.Type = "h.Svg"
default:
n.Type = fmt.Sprintf("h.%s", cases.Title(language.English).String(in))
}
}
func (n *CustomNode) AddAttr(key, value string) {
if slices.Contains([]string{"xmlns", "fill", "viewBox", "stroke", "stroke-width", "fill-rule", "d", "stroke-linecap", "stroke-linejoin", "cx", "cy", "r", "x", "y", "rx", "ry", "x1", "x2", "y1", "y2", "points"}, key) {
n.Attrs = append(n.Attrs, Attr{
custom: true,
key: key,
value: value,
})
return
}
switch {
case key == "id":
n.Attrs = append(n.Attrs, Attr{key: "h.Id", value: value})
case key == "tabindex":
n.Attrs = append(n.Attrs, Attr{key: "h.TabIndex", value: value})
case key == "h.Text":
n.Attrs = append(n.Attrs, Attr{key: key, value: value})
case strings.ContainsRune(key, '-'):
n.Attrs = append(n.Attrs, Attr{
custom: true,
key: key,
value: value,
})
fmt.Printf("key: %s, value: %s\n", key, value)
default:
n.Attrs = append(n.Attrs, Attr{key: "h." + cases.Title(language.English).String(key), value: value})
}
}
func (n *CustomNode) String() string {
str := ""
if n.customType {
str += "h.Tag(\"" + n.Type + "\","
} else {
str += n.Type + "("
}
if len(n.Attrs) > 0 {
for _, v := range n.Attrs {
switch {
case v.custom:
str = fmt.Sprintf("%sh.Attribute(\"%s\",\"%s\"),", str, v.key, v.value)
case v.hyphenated:
str = fmt.Sprintf("%s%s(\"%s\", \"%s\"),", str, v.key, v.arg, v.value)
case len(v.value) > 0:
if strings.Contains(v.value, "\n") {
str = fmt.Sprintf("%s%s(`%s`),", str, v.key, v.value)
} else {
str = fmt.Sprintf("%s%s(\"%s\"),", str, v.key, v.value)
}
case v.value == "":
str = fmt.Sprintf("%s%s(\"\"),", str, v.key)
default:
str = fmt.Sprintf("%s%s(),", str, v.key)
}
}
}
if len(n.Nodes) > 0 {
for _, v := range n.Nodes {
if v.Type != "" {
str = fmt.Sprintf("%s\n%s%s,", str, strings.Repeat(" ", int(n.Level)), v)
}
}
}
str = fmt.Sprintf("%s\n%s)", str, strings.Repeat(" ", int(n.Level)))
return str
}
type Attr struct {
custom, hyphenated bool
key, value, arg string
}

View file

@ -0,0 +1,5 @@
package domain
type HTMLParser interface {
FromBytes(bytes []byte) (*CustomNode, error)
}

View file

@ -0,0 +1,22 @@
// Forked from https://github.com/PiotrKowalski/html-to-gomponents
package main
import (
serviceformatter "html-to-htmgo/internal/adapters/services/formatter"
serviceparser "html-to-htmgo/internal/adapters/services/parser"
)
func Parse(input []byte) []byte {
parser := serviceparser.New()
formatter := serviceformatter.New()
parsed, err := parser.FromBytes(
input,
)
if err != nil {
return nil
}
return []byte(formatter.Format(parsed))
}