Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add html2gomponents tool #232

Open
wants to merge 17 commits into
base: main
Choose a base branch
from
Open
21 changes: 21 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -63,3 +63,24 @@ jobs:
uses: golangci/golangci-lint-action@v6
with:
version: latest

test-html2gomponents:
name: Test html2gomponents
runs-on: ubuntu-latest

steps:
- name: Checkout
uses: actions/checkout@v4

- name: Setup Go
uses: actions/setup-go@v5
with:
go-version: stable

- name: Build
run: go build -v ./...
working-directory: cmd/html2gomponents

- name: Test
run: go test -v -shuffle on ./...
working-directory: cmd/html2gomponents
5 changes: 5 additions & 0 deletions cmd/html2gomponents/go.mod
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
module maragu.dev/gomponents/cmd/html2gomponents

go 1.23.2

require golang.org/x/net v0.30.0
2 changes: 2 additions & 0 deletions cmd/html2gomponents/go.sum
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
golang.org/x/net v0.30.0 h1:AcW1SDZMkb8IpzCdQUaIq2sP4sZ4zw+55h6ynffypl4=
golang.org/x/net v0.30.0/go.mod h1:2wGyMJ5iFasEhkwi13ChkO/t1ECNC4X4eBKkVFyYFlU=
211 changes: 211 additions & 0 deletions cmd/html2gomponents/main.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,211 @@
package main

import (
"bytes"
"errors"
"fmt"
"go/format"
"io"
"log/slog"
"os"
"strings"

"golang.org/x/net/html"
)

var attrs = map[string]string{
"autocomplete": "AutoComplete",
"autofocus": "AutoFocus",
"autoplay": "AutoPlay",
"cite": "CiteAttr",
"colspan": "ColSpan",
"crossorigin": "CrossOrigin",
"datetime": "DateTime",
"enctype": "EncType",
"form": "FormAttr",
"id": "ID",
"label": "LabelAttr",
"maxlength": "MaxLength",
"minlength": "MinLength",
"playsinline": "PlaysInline",
"readonly": "ReadOnly",
"rowspan": "RowSpan",
"srcset": "SrcSet",
"tabindex": "TabIndex",
}

var els = map[string]string{
"blockquote": "BlockQuote",
"colgroup": "ColGroup",
"data": "DataEl",
"datalist": "DataList",
"fieldset": "FieldSet",
"figcaption": "FigCaption",
"hgroup": "HGroup",
"html": "HTML",
"iframe": "IFrame",
"noscript": "NoScript",
"optgroup": "OptGroup",
"style": "StyleEl",
"svg": "SVG",
"tbody": "TBody",
"tfoot": "TFoot",
"thead": "THead",
"title": "TitleEl",
}

func main() {
log := slog.New(slog.NewTextHandler(os.Stderr, nil))
if err := start(os.Stdin, os.Stdout); err != nil {
log.Info("Error", "error", err)
os.Exit(1)
}
}

func start(r io.Reader, w2 io.Writer) error {
var b bytes.Buffer
w := &statefulWriter{w: &b}

w.Write("package html\n")
w.Write("\n")
w.Write("import (\n")
w.Write(". \"maragu.dev/gomponents\"\n")
w.Write(". \"maragu.dev/gomponents/html\"\n")
w.Write(")\n")
w.Write("\n")
w.Write("func Component() Node {\n")

z := html.NewTokenizer(r)

var hasContent bool
var depth int
loop:
for {
tt := z.Next()

switch tt {
case html.ErrorToken:
if err := z.Err(); err != nil {
if errors.Is(err, io.EOF) {
if !hasContent {
w.Write("return nil")
}
break loop
}
return err
}

case html.TextToken:
text := strings.TrimSpace(string(z.Text()))
if text == "" {
continue
}

if !hasContent {
w.Write("return ")
}

hasContent = true
w.Write(fmt.Sprintf("Text(%q)", text))
if depth > 0 {
w.Write(",")
}

case html.StartTagToken, html.SelfClosingTagToken:
if !hasContent {
w.Write("return ")
}

if hasContent {
w.Write("\n")
}
hasContent = true

name, hasAttr := z.TagName()
if el, ok := els[string(name)]; ok {
w.Write(el)
} else {
w.Write(strings.ToTitle(string(name[0])))
w.Write(string(name[1:]))
}
w.Write("(")

if hasAttr {
for {
key, val, moreAttr := z.TagAttr()

if attr, ok := attrs[string(key)]; ok {
w.Write(attr)
} else {
w.Write(strings.ToTitle(string(key[0])))
w.Write(string(key[1:]))
}
w.Write("(")

if len(val) > 0 {
w.Write(`"` + string(val) + `"`)
}

w.Write("),")

if !moreAttr {
break
}
}
w.Write("\n")
}
depth++

if tt == html.SelfClosingTagToken {
depth--
w.Write("\n)")
if depth > 0 {
w.Write(",")
}
}

case html.EndTagToken:
depth--
w.Write("\n)")
if depth > 0 {
w.Write(",")
}

case html.CommentToken:
w.Write("// " + string(z.Text()) + "\n")

case html.DoctypeToken:
// TODO Ignore for now
}
}

w.Write("\n}\n")

if w.err != nil {
return w.err
}

formatted, err := format.Source(b.Bytes())
if err != nil {
return fmt.Errorf("error formatting output: %w", err)
}

if _, err = w2.Write(formatted); err != nil {
return err
}

return nil
}

// statefulWriter only writes if no errors have occurred earlier in its lifetime.
type statefulWriter struct {
w io.Writer
err error
}

func (w *statefulWriter) Write(s string) {
if w.err != nil {
return
}
_, w.err = w.w.Write([]byte(s))
}
47 changes: 47 additions & 0 deletions cmd/html2gomponents/main_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
package main

import (
"os"
"strings"
"testing"
)

func TestStart(t *testing.T) {
entries, err := os.ReadDir("testdata")
if err != nil {
t.Fatal(err)
}
for _, e := range entries {
name := e.Name()
if !strings.HasSuffix(name, ".html") {
continue
}
name = strings.TrimSuffix(name, ".html")

t.Run(name, func(t *testing.T) {
in := readTestData(t, name+".html")
out := readTestData(t, "_out/"+name+".go")

r := strings.NewReader(in)
var w strings.Builder
err := start(r, &w)

if err != nil {
t.Fatal(err)
}
if out != w.String() {
t.Fatalf("expected %v, got %v", out, w.String())
}
})
}
}

func readTestData(t *testing.T, path string) string {
t.Helper()

b, err := os.ReadFile("testdata/" + path)
if err != nil {
t.Fatal(err)
}
return string(b)
}
12 changes: 12 additions & 0 deletions cmd/html2gomponents/testdata/_out/attributes.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package html

import (
. "maragu.dev/gomponents"
. "maragu.dev/gomponents/html"
)

func Component() Node {
return A(Href("#"), Title("halløj"),
Text("Halløj!"),
)
}
11 changes: 11 additions & 0 deletions cmd/html2gomponents/testdata/_out/comment.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package html

import (
. "maragu.dev/gomponents"
. "maragu.dev/gomponents/html"
)

func Component() Node {
// halløj
return Div()
}
20 changes: 20 additions & 0 deletions cmd/html2gomponents/testdata/_out/complex.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
package html

import (
. "maragu.dev/gomponents"
. "maragu.dev/gomponents/html"
)

func Component() Node {
return Div(
H1(ID("title"), Class("pretty"),
Text("Halløj!"),
),
H2(ID("subtitle"), Class("prettier"),
Text("What is this?"),
),
P(Class("prettiest"),
Text("It's a parser and converter for converting HTML to gomponents Go code."),
),
)
}
10 changes: 10 additions & 0 deletions cmd/html2gomponents/testdata/_out/div.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package html

import (
. "maragu.dev/gomponents"
. "maragu.dev/gomponents/html"
)

func Component() Node {
return Div()
}
12 changes: 12 additions & 0 deletions cmd/html2gomponents/testdata/_out/divspan.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package html

import (
. "maragu.dev/gomponents"
. "maragu.dev/gomponents/html"
)

func Component() Node {
return Div(
Span(),
)
}
13 changes: 13 additions & 0 deletions cmd/html2gomponents/testdata/_out/divspanspan.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
package html

import (
. "maragu.dev/gomponents"
. "maragu.dev/gomponents/html"
)

func Component() Node {
return Div(
Span(),
Span(),
)
}
10 changes: 10 additions & 0 deletions cmd/html2gomponents/testdata/_out/divtext.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package html

import (
. "maragu.dev/gomponents"
. "maragu.dev/gomponents/html"
)

func Component() Node {
return Div(Text("halløj"))
}
10 changes: 10 additions & 0 deletions cmd/html2gomponents/testdata/_out/doctype.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package html

import (
. "maragu.dev/gomponents"
. "maragu.dev/gomponents/html"
)

func Component() Node {
return nil
}
Loading
Loading