summaryrefslogtreecommitdiffstats
path: root/internal/builder/components.go
blob: 54a226a10a34bc6e3cbecc131d471330df41e2b8 (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
package builder

import (
	"encoding/json"
	"fmt"
	"os"
	"path/filepath"
	"regexp"
	"sort"
	"strings"
)

// componentRe matches <!-- component:tag-name { ...json... } -->
// Props JSON is optional.
var componentRe = regexp.MustCompile(
	`<!--\s*component:([a-z][a-z0-9-]*)\s*(\{[^}]*\})?\s*-->`)

// customElementRe matches opening tags for custom elements (name must contain a hyphen).
var customElementRe = regexp.MustCompile(`<([a-z][a-z0-9]*(?:-[a-z0-9]+)+)[\s/>]`)

// ProcessComponents replaces HTML comment component directives with custom element tags.
//
//	<!-- component:my-counter {"start": 5, "label": "Count"} -->
//	→ <my-counter start="5" label="Count"></my-counter>
func ProcessComponents(html string) string {
	return componentRe.ReplaceAllStringFunc(html, func(match string) string {
		subs := componentRe.FindStringSubmatch(match)
		if len(subs) < 2 {
			return match
		}
		tagName := subs[1]
		attrs := ""
		if len(subs) > 2 && subs[2] != "" {
			var props map[string]any
			if err := json.Unmarshal([]byte(subs[2]), &props); err == nil {
				attrs = propsToAttrs(props)
			}
		}
		if attrs != "" {
			return fmt.Sprintf(`<%s %s></%s>`, tagName, attrs, tagName)
		}
		return fmt.Sprintf(`<%s></%s>`, tagName, tagName)
	})
}

// propsToAttrs converts a JSON props map to an HTML attribute string.
// Keys are emitted in sorted order for deterministic output.
func propsToAttrs(props map[string]any) string {
	keys := make([]string, 0, len(props))
	for k := range props {
		keys = append(keys, k)
	}
	sort.Strings(keys)

	var parts []string
	for _, k := range keys {
		v := props[k]
		switch val := v.(type) {
		case string:
			parts = append(parts, fmt.Sprintf(`%s="%s"`, k, strings.ReplaceAll(val, `"`, `&quot;`)))
		case bool:
			if val {
				parts = append(parts, k) // boolean attribute, no value
			}
		case float64:
			if val == float64(int64(val)) {
				parts = append(parts, fmt.Sprintf(`%s="%d"`, k, int64(val)))
			} else {
				parts = append(parts, fmt.Sprintf(`%s="%g"`, k, val))
			}
		default:
			// Complex value → JSON-encode into single-quoted attribute.
			b, _ := json.Marshal(v)
			parts = append(parts, fmt.Sprintf(`%s='%s'`, k, string(b)))
		}
	}
	return strings.Join(parts, " ")
}

// FindComponentScripts scans HTML for used custom elements and returns
// /components/<name>.js paths for any that exist on disk.
func FindComponentScripts(html, componentsDir string) []string {
	matches := customElementRe.FindAllStringSubmatch(html, -1)
	seen := make(map[string]bool)
	var scripts []string
	for _, m := range matches {
		if len(m) < 2 || seen[m[1]] {
			continue
		}
		seen[m[1]] = true
		jsPath := filepath.Join(componentsDir, m[1]+".js")
		if _, err := os.Stat(jsPath); err == nil {
			scripts = append(scripts, "/components/"+m[1]+".js")
		}
	}
	return scripts
}