add html-to-htmgo tool
This commit is contained in:
parent
7d748ec7e6
commit
8e4a63d224
10 changed files with 306 additions and 119 deletions
|
|
@ -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
|
|
||||||
}
|
|
||||||
8
tools/html-to-htmgo/go.mod
Normal file
8
tools/html-to-htmgo/go.mod
Normal 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
|
||||||
|
)
|
||||||
8
tools/html-to-htmgo/go.sum
Normal file
8
tools/html-to-htmgo/go.sum
Normal 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=
|
||||||
|
|
@ -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{}
|
||||||
|
}
|
||||||
|
|
@ -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{}
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
5
tools/html-to-htmgo/internal/domain/formatter.go
Normal file
5
tools/html-to-htmgo/internal/domain/formatter.go
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
package domain
|
||||||
|
|
||||||
|
type Formatter interface {
|
||||||
|
Format(node *CustomNode) (string, error)
|
||||||
|
}
|
||||||
127
tools/html-to-htmgo/internal/domain/node.go
Normal file
127
tools/html-to-htmgo/internal/domain/node.go
Normal 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
|
||||||
|
}
|
||||||
5
tools/html-to-htmgo/internal/domain/parser.go
Normal file
5
tools/html-to-htmgo/internal/domain/parser.go
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
package domain
|
||||||
|
|
||||||
|
type HTMLParser interface {
|
||||||
|
FromBytes(bytes []byte) (*CustomNode, error)
|
||||||
|
}
|
||||||
22
tools/html-to-htmgo/main.go
Normal file
22
tools/html-to-htmgo/main.go
Normal 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))
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue