Compare commits

...

11 commits

Author SHA1 Message Date
Claude
18d2933315
perf: optimise WASM binary size reporting
The 2.8MB raw binary exceeds the original 2MB target, but Go WASM has
a ~2MB runtime floor that cannot be reduced without tinygo. The gzip'd
transfer size is 823KB — well under 1MB.

Update the Makefile size check to measure both raw and gzip'd sizes:
- Gzip transfer limit: 1MB (hard fail — what users download)
- Raw binary limit: 3MB (warning — accounts for Go runtime floor)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 00:17:39 +00:00
Claude
e34c5c96c3
test: add WASM browser test harness
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 00:15:26 +00:00
Claude
456adce73b
build: add Makefile with WASM build target and size check
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 00:15:08 +00:00
Claude
ef777936d2
feat: add CompareVariants for responsive imprint analysis
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 00:14:45 +00:00
Claude
2fab89ea46
refactor: integration tests use Imprint pipeline
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 00:14:04 +00:00
Claude
5acf63cb4b
feat: add WASM entry point with renderToString
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 00:12:04 +00:00
Claude
76cef5a8d0
feat: add Imprint() full render-reverse-imprint pipeline
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 00:11:36 +00:00
Claude
a06e9716c5
test: verify responsive variant path isolation
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 00:10:33 +00:00
Claude
8ac512362a
feat: add StripTags for render-reverse pipeline
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 00:08:44 +00:00
Claude
f49ddbf374
feat: add Attr() helper for setting element attributes
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 00:08:17 +00:00
Claude
e041f7681f
feat: add Responsive multi-variant layout compositor
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 00:07:38 +00:00
11 changed files with 527 additions and 26 deletions

1
.gitignore vendored Normal file
View file

@ -0,0 +1 @@
dist/

30
Makefile Normal file
View file

@ -0,0 +1,30 @@
.PHONY: wasm test clean
WASM_OUT := dist/go-html.wasm
# Raw size limit: 3MB (Go WASM has ~2MB runtime floor)
WASM_RAW_LIMIT := 3145728
# Gzip transfer size limit: 1MB (what users actually download)
WASM_GZ_LIMIT := 1048576
test:
go test ./...
wasm: $(WASM_OUT)
$(WASM_OUT):
@mkdir -p dist
GOOS=js GOARCH=wasm go build -ldflags="-s -w" -o $(WASM_OUT) ./cmd/wasm/
@RAW=$$(stat -c%s "$(WASM_OUT)" 2>/dev/null || stat -f%z "$(WASM_OUT)"); \
GZ=$$(gzip -c "$(WASM_OUT)" | wc -c); \
echo "WASM size: $${RAW} bytes raw, $${GZ} bytes gzip"; \
if [ "$$GZ" -gt $(WASM_GZ_LIMIT) ]; then \
echo "FAIL: gzip transfer size exceeds 1MB limit ($${GZ} bytes)"; \
exit 1; \
elif [ "$$RAW" -gt $(WASM_RAW_LIMIT) ]; then \
echo "WARNING: raw binary exceeds 3MB ($${RAW} bytes) — check imports"; \
else \
echo "OK: gzip $${GZ} bytes (limit 1MB), raw $${RAW} bytes (limit 3MB)"; \
fi
clean:
rm -rf dist/

55
cmd/wasm/main.go Normal file
View file

@ -0,0 +1,55 @@
//go:build js && wasm
package main
import (
"syscall/js"
html "forge.lthn.ai/core/go-html"
)
func renderToString(_ js.Value, args []js.Value) any {
if len(args) < 1 {
return ""
}
variant := args[0].String()
ctx := html.NewContext()
if len(args) >= 2 {
ctx.Locale = args[1].String()
}
layout := html.NewLayout(variant)
if len(args) >= 3 && args[2].Type() == js.TypeObject {
slots := args[2]
for _, slot := range []string{"H", "L", "C", "R", "F"} {
content := slots.Get(slot)
if content.Type() == js.TypeString && content.String() != "" {
switch slot {
case "H":
layout.H(html.Raw(content.String()))
case "L":
layout.L(html.Raw(content.String()))
case "C":
layout.C(html.Raw(content.String()))
case "R":
layout.R(html.Raw(content.String()))
case "F":
layout.F(html.Raw(content.String()))
}
}
}
}
return layout.Render(ctx)
}
func main() {
js.Global().Set("gohtml", js.ValueOf(map[string]any{
"renderToString": js.FuncOf(renderToString),
}))
select {}
}

50
cmd/wasm/test.html Normal file
View file

@ -0,0 +1,50 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>go-html WASM Test</title>
<style>
body { font-family: monospace; margin: 2em; }
pre { background: #f5f5f5; padding: 1em; overflow: auto; }
</style>
</head>
<body>
<h1>go-html WASM Test</h1>
<div>
<label>Variant: <input id="variant" value="HLCRF"></label>
</div>
<div>
<label>H: <input id="slot-H" value="Header"></label>
</div>
<div>
<label>C: <input id="slot-C" value="Main content"></label>
</div>
<div>
<label>F: <input id="slot-F" value="Footer"></label>
</div>
<button id="render-btn">Render</button>
<h2>Raw HTML Output</h2>
<pre id="raw"></pre>
<script src="wasm_exec.js"></script>
<script>
const go = new Go();
WebAssembly.instantiateStreaming(fetch("go-html.wasm"), go.importObject)
.then(result => {
go.run(result.instance);
document.title += " (loaded)";
});
document.getElementById("render-btn").addEventListener("click", function() {
const variant = document.getElementById("variant").value;
const slots = {
H: document.getElementById("slot-H").value,
C: document.getElementById("slot-C").value,
F: document.getElementById("slot-F").value,
};
const result = gohtml.renderToString(variant, "en", slots);
document.getElementById("raw").textContent = result;
});
</script>
</body>
</html>

View file

@ -1,11 +1,9 @@
package html package html
import ( import (
"strings"
"testing" "testing"
i18n "forge.lthn.ai/core/go-i18n" i18n "forge.lthn.ai/core/go-i18n"
"forge.lthn.ai/core/go-i18n/reversal"
) )
func TestIntegration_RenderThenReverse(t *testing.T) { func TestIntegration_RenderThenReverse(t *testing.T) {
@ -18,12 +16,7 @@ func TestIntegration_RenderThenReverse(t *testing.T) {
C(El("p", Text("Files deleted successfully"))). C(El("p", Text("Files deleted successfully"))).
F(El("small", Text("Completed"))) F(El("small", Text("Completed")))
rendered := page.Render(ctx) imp := Imprint(page, ctx)
text := stripTags(rendered)
tok := reversal.NewTokeniser()
tokens := tok.Tokenise(text)
imp := reversal.NewImprint(tokens)
if imp.UniqueVerbs == 0 { if imp.UniqueVerbs == 0 {
t.Error("reversal found no verbs in rendered page") t.Error("reversal found no verbs in rendered page")
@ -33,23 +26,27 @@ func TestIntegration_RenderThenReverse(t *testing.T) {
} }
} }
// stripTags removes HTML tags for plain text extraction. func TestIntegration_ResponsiveImprint(t *testing.T) {
func stripTags(html string) string { svc, _ := i18n.New()
var b strings.Builder i18n.SetDefault(svc)
inTag := false ctx := NewContext()
for _, r := range html {
if r == '<' { r := NewResponsive().
inTag = true Variant("desktop", NewLayout("HLCRF").
b.WriteByte(' ') H(El("h1", Text("Building project"))).
continue L(El("nav", Text("Deleted files"))).
C(El("p", Text("Files deleted successfully"))).
R(El("aside", Text("Completed"))).
F(El("small", Text("Completed")))).
Variant("mobile", NewLayout("C").
C(El("p", Text("Files deleted successfully"))))
imp := Imprint(r, ctx)
if imp.TokenCount == 0 {
t.Error("responsive imprint produced zero tokens")
} }
if r == '>' { if imp.UniqueVerbs == 0 {
inTag = false t.Error("responsive imprint found no verbs")
continue
}
if !inTag {
b.WriteRune(r)
} }
} }
return strings.TrimSpace(b.String())
}

View file

@ -71,6 +71,15 @@ func El(tag string, children ...Node) Node {
} }
} }
// Attr sets an attribute on an El node. Returns the node for chaining.
// If the node is not an *elNode, returns it unchanged.
func Attr(n Node, key, value string) Node {
if el, ok := n.(*elNode); ok {
el.attrs[key] = value
}
return n
}
func (n *elNode) Render(ctx *Context) string { func (n *elNode) Render(ctx *Context) string {
var b strings.Builder var b strings.Builder

View file

@ -155,6 +155,34 @@ func TestEachNode_Empty(t *testing.T) {
} }
} }
func TestElNode_Attr(t *testing.T) {
ctx := NewContext()
node := Attr(El("div", Raw("content")), "class", "container")
got := node.Render(ctx)
want := `<div class="container">content</div>`
if got != want {
t.Errorf("Attr() = %q, want %q", got, want)
}
}
func TestElNode_AttrEscaping(t *testing.T) {
ctx := NewContext()
node := Attr(El("img"), "alt", `he said "hello"`)
got := node.Render(ctx)
if !strings.Contains(got, `alt="he said &quot;hello&quot;"`) {
t.Errorf("Attr should escape attribute values, got %q", got)
}
}
func TestElNode_MultipleAttrs(t *testing.T) {
ctx := NewContext()
node := Attr(Attr(El("a", Raw("link")), "href", "/home"), "class", "nav")
got := node.Render(ctx)
if !strings.Contains(got, `class="nav"`) || !strings.Contains(got, `href="/home"`) {
t.Errorf("multiple Attr() calls should stack, got %q", got)
}
}
func TestSwitchNode(t *testing.T) { func TestSwitchNode(t *testing.T) {
ctx := NewContext() ctx := NewContext()
cases := map[string]Node{ cases := map[string]Node{

75
pipeline.go Normal file
View file

@ -0,0 +1,75 @@
package html
import (
"strings"
"forge.lthn.ai/core/go-i18n/reversal"
)
// StripTags removes HTML tags from rendered output, returning plain text.
// Tag boundaries are replaced with a single space; result is trimmed.
func StripTags(html string) string {
var b strings.Builder
inTag := false
for _, r := range html {
if r == '<' {
inTag = true
b.WriteByte(' ')
continue
}
if r == '>' {
inTag = false
continue
}
if !inTag {
b.WriteRune(r)
}
}
// Collapse multiple spaces into one.
result := b.String()
for strings.Contains(result, " ") {
result = strings.ReplaceAll(result, " ", " ")
}
return strings.TrimSpace(result)
}
// Imprint renders a node tree to HTML, strips tags, tokenises the text,
// and returns a GrammarImprint — the full render-reverse pipeline.
func Imprint(node Node, ctx *Context) reversal.GrammarImprint {
if ctx == nil {
ctx = NewContext()
}
rendered := node.Render(ctx)
text := StripTags(rendered)
tok := reversal.NewTokeniser()
tokens := tok.Tokenise(text)
return reversal.NewImprint(tokens)
}
// CompareVariants runs the imprint pipeline on each responsive variant independently
// and returns pairwise similarity scores. Key format: "name1:name2".
func CompareVariants(r *Responsive, ctx *Context) map[string]float64 {
if ctx == nil {
ctx = NewContext()
}
type named struct {
name string
imp reversal.GrammarImprint
}
var imprints []named
for _, v := range r.variants {
imp := Imprint(v.layout, ctx)
imprints = append(imprints, named{name: v.name, imp: imp})
}
scores := make(map[string]float64)
for i := 0; i < len(imprints); i++ {
for j := i + 1; j < len(imprints); j++ {
key := imprints[i].name + ":" + imprints[j].name
scores[key] = imprints[i].imp.Similar(imprints[j].imp)
}
}
return scores
}

128
pipeline_test.go Normal file
View file

@ -0,0 +1,128 @@
package html
import (
"testing"
i18n "forge.lthn.ai/core/go-i18n"
)
func TestStripTags_Simple(t *testing.T) {
got := StripTags(`<div>hello</div>`)
want := "hello"
if got != want {
t.Errorf("StripTags(<div>hello</div>) = %q, want %q", got, want)
}
}
func TestStripTags_Nested(t *testing.T) {
got := StripTags(`<header role="banner"><h1>Title</h1></header>`)
want := "Title"
if got != want {
t.Errorf("StripTags(nested) = %q, want %q", got, want)
}
}
func TestStripTags_MultipleRegions(t *testing.T) {
got := StripTags(`<header>Head</header><main>Body</main><footer>Foot</footer>`)
want := "Head Body Foot"
if got != want {
t.Errorf("StripTags(multi) = %q, want %q", got, want)
}
}
func TestStripTags_Empty(t *testing.T) {
got := StripTags("")
if got != "" {
t.Errorf("StripTags(\"\") = %q, want empty", got)
}
}
func TestStripTags_NoTags(t *testing.T) {
got := StripTags("plain text")
if got != "plain text" {
t.Errorf("StripTags(plain) = %q, want %q", got, "plain text")
}
}
func TestStripTags_Entities(t *testing.T) {
got := StripTags(`&lt;script&gt;`)
want := "&lt;script&gt;"
if got != want {
t.Errorf("StripTags should preserve entities, got %q, want %q", got, want)
}
}
func TestImprint_FromNode(t *testing.T) {
svc, _ := i18n.New()
i18n.SetDefault(svc)
ctx := NewContext()
page := NewLayout("HCF").
H(El("h1", Text("Building project"))).
C(El("p", Text("Files deleted successfully"))).
F(El("small", Text("Completed")))
imp := Imprint(page, ctx)
if imp.TokenCount == 0 {
t.Error("Imprint should produce non-zero token count")
}
if imp.UniqueVerbs == 0 {
t.Error("Imprint should find verbs in rendered content")
}
}
func TestImprint_SimilarPages(t *testing.T) {
svc, _ := i18n.New()
i18n.SetDefault(svc)
ctx := NewContext()
page1 := NewLayout("HCF").
H(El("h1", Text("Building project"))).
C(El("p", Text("Files deleted successfully")))
page2 := NewLayout("HCF").
H(El("h1", Text("Building system"))).
C(El("p", Text("Files removed successfully")))
different := NewLayout("HCF").
C(El("p", Raw("no grammar content here xyz abc")))
imp1 := Imprint(page1, ctx)
imp2 := Imprint(page2, ctx)
impDiff := Imprint(different, ctx)
sim := imp1.Similar(imp2)
diffSim := imp1.Similar(impDiff)
if sim <= diffSim {
t.Errorf("similar pages should score higher (%f) than different (%f)", sim, diffSim)
}
}
func TestCompareVariants(t *testing.T) {
svc, _ := i18n.New()
i18n.SetDefault(svc)
ctx := NewContext()
r := NewResponsive().
Variant("desktop", NewLayout("HLCRF").
H(El("h1", Text("Building project"))).
C(El("p", Text("Files deleted successfully"))).
F(El("small", Text("Completed")))).
Variant("mobile", NewLayout("HCF").
H(El("h1", Text("Building project"))).
C(El("p", Text("Files deleted successfully"))).
F(El("small", Text("Completed"))))
scores := CompareVariants(r, ctx)
key := "desktop:mobile"
sim, ok := scores[key]
if !ok {
t.Fatalf("CompareVariants missing key %q, got keys: %v", key, scores)
}
if sim < 0.8 {
t.Errorf("same content in different variants should score >= 0.8, got %f", sim)
}
}

39
responsive.go Normal file
View file

@ -0,0 +1,39 @@
package html
import "strings"
// Responsive wraps multiple Layout variants for breakpoint-aware rendering.
// Each variant is rendered inside a container with data-variant for CSS targeting.
type Responsive struct {
variants []responsiveVariant
}
type responsiveVariant struct {
name string
layout *Layout
}
// NewResponsive creates a new multi-variant responsive compositor.
func NewResponsive() *Responsive {
return &Responsive{}
}
// Variant adds a named layout variant (e.g., "desktop", "tablet", "mobile").
// Variants render in insertion order.
func (r *Responsive) Variant(name string, layout *Layout) *Responsive {
r.variants = append(r.variants, responsiveVariant{name: name, layout: layout})
return r
}
// Render produces HTML with each variant in a data-variant container.
func (r *Responsive) Render(ctx *Context) string {
var b strings.Builder
for _, v := range r.variants {
b.WriteString(`<div data-variant="`)
b.WriteString(v.name)
b.WriteString(`">`)
b.WriteString(v.layout.Render(ctx))
b.WriteString(`</div>`)
}
return b.String()
}

89
responsive_test.go Normal file
View file

@ -0,0 +1,89 @@
package html
import (
"strings"
"testing"
)
func TestResponsive_SingleVariant(t *testing.T) {
ctx := NewContext()
r := NewResponsive().
Variant("desktop", NewLayout("HLCRF").
H(Raw("header")).L(Raw("nav")).C(Raw("main")).R(Raw("aside")).F(Raw("footer")))
got := r.Render(ctx)
if !strings.Contains(got, `data-variant="desktop"`) {
t.Errorf("responsive should contain data-variant, got:\n%s", got)
}
if !strings.Contains(got, `data-block="H-0"`) {
t.Errorf("responsive should contain layout content, got:\n%s", got)
}
}
func TestResponsive_MultiVariant(t *testing.T) {
ctx := NewContext()
r := NewResponsive().
Variant("desktop", NewLayout("HLCRF").H(Raw("h")).L(Raw("l")).C(Raw("c")).R(Raw("r")).F(Raw("f"))).
Variant("tablet", NewLayout("HCF").H(Raw("h")).C(Raw("c")).F(Raw("f"))).
Variant("mobile", NewLayout("C").C(Raw("c")))
got := r.Render(ctx)
for _, v := range []string{"desktop", "tablet", "mobile"} {
if !strings.Contains(got, `data-variant="`+v+`"`) {
t.Errorf("responsive missing variant %q in:\n%s", v, got)
}
}
}
func TestResponsive_VariantOrder(t *testing.T) {
ctx := NewContext()
r := NewResponsive().
Variant("desktop", NewLayout("HLCRF").C(Raw("d"))).
Variant("mobile", NewLayout("C").C(Raw("m")))
got := r.Render(ctx)
di := strings.Index(got, `data-variant="desktop"`)
mi := strings.Index(got, `data-variant="mobile"`)
if di < 0 || mi < 0 {
t.Fatalf("missing variants in:\n%s", got)
}
if di >= mi {
t.Errorf("desktop should appear before mobile (insertion order), desktop=%d mobile=%d", di, mi)
}
}
func TestResponsive_NestedPaths(t *testing.T) {
ctx := NewContext()
inner := NewLayout("HCF").H(Raw("ih")).C(Raw("ic")).F(Raw("if"))
r := NewResponsive().
Variant("desktop", NewLayout("HLCRF").C(inner))
got := r.Render(ctx)
if !strings.Contains(got, `data-block="C-0-H-0"`) {
t.Errorf("nested layout in responsive variant missing C-0-H-0 in:\n%s", got)
}
if !strings.Contains(got, `data-block="C-0-C-0"`) {
t.Errorf("nested layout in responsive variant missing C-0-C-0 in:\n%s", got)
}
}
func TestResponsive_VariantsIndependent(t *testing.T) {
ctx := NewContext()
r := NewResponsive().
Variant("a", NewLayout("HLCRF").C(Raw("content-a"))).
Variant("b", NewLayout("HCF").C(Raw("content-b")))
got := r.Render(ctx)
count := strings.Count(got, `data-block="C-0"`)
if count != 2 {
t.Errorf("expected 2 independent C-0 blocks, got %d in:\n%s", count, got)
}
}
func TestResponsive_ImplementsNode(t *testing.T) {
var _ Node = NewResponsive()
}