Add Go CLI with container expansion parser and development server

- Implement Go-based CLI for build-time HTML enhancement
- Add container expansion: div.insertr auto-expands to viable children
- Create servedev command with live development workflow
- Add Air configuration for automatic rebuilds and serving
- Enable transition from runtime JS to build-time enhancement approach
This commit is contained in:
2025-09-02 22:58:59 +02:00
parent afd4879cef
commit 0e84af98bc
13 changed files with 1429 additions and 1 deletions

46
insertr-cli/.air.toml Normal file
View File

@@ -0,0 +1,46 @@
root = "."
testdata_dir = "testdata"
tmp_dir = "tmp"
[build]
args_bin = []
bin = "./tmp/insertr"
cmd = "go build -o ./tmp/insertr ."
delay = 1000
exclude_dir = ["assets", "tmp", "vendor", "testdata", "node_modules"]
exclude_file = []
exclude_regex = ["_test.go"]
exclude_unchanged = false
follow_symlink = false
full_bin = "./tmp/insertr servedev -i ../demo-site -p 3000"
include_dir = []
include_ext = ["go", "tpl", "tmpl", "html"]
include_file = []
kill_delay = "0s"
log = "build-errors.log"
poll = false
poll_interval = 0
post_cmd = []
pre_cmd = []
rerun = false
rerun_delay = 500
send_interrupt = false
stop_on_root = false
[color]
app = ""
build = "yellow"
main = "magenta"
runner = "green"
watcher = "cyan"
[log]
main_only = false
time = false
[misc]
clean_on_exit = true
[screen]
clear_on_rebuild = false
keep_scroll = true

71
insertr-cli/cmd/parse.go Normal file
View File

@@ -0,0 +1,71 @@
package cmd
import (
"fmt"
"os"
"path/filepath"
"github.com/insertr/cli/pkg/parser"
"github.com/spf13/cobra"
)
var parseCmd = &cobra.Command{
Use: "parse [input-dir]",
Short: "Parse HTML files and detect editable elements",
Long: `Parse HTML files in the specified directory and detect elements
with the 'insertr' class. This command analyzes the HTML structure
and reports what editable elements would be enhanced.`,
Args: cobra.ExactArgs(1),
Run: func(cmd *cobra.Command, args []string) {
inputDir := args[0]
if _, err := os.Stat(inputDir); os.IsNotExist(err) {
fmt.Fprintf(os.Stderr, "Error: Directory %s does not exist\n", inputDir)
os.Exit(1)
}
fmt.Printf("🔍 Parsing HTML files in: %s\n\n", inputDir)
p := parser.New()
result, err := p.ParseDirectory(inputDir)
if err != nil {
fmt.Fprintf(os.Stderr, "Error parsing directory: %v\n", err)
os.Exit(1)
}
printParseResults(result)
},
}
func printParseResults(result *parser.ParseResult) {
fmt.Printf("📊 Parse Results:\n")
fmt.Printf(" Files processed: %d\n", result.Stats.FilesProcessed)
fmt.Printf(" Elements found: %d\n", result.Stats.TotalElements)
fmt.Printf(" Existing IDs: %d\n", result.Stats.ExistingIDs)
fmt.Printf(" Generated IDs: %d\n", result.Stats.GeneratedIDs)
if len(result.Stats.TypeBreakdown) > 0 {
fmt.Printf("\n📝 Content Types:\n")
for contentType, count := range result.Stats.TypeBreakdown {
fmt.Printf(" %s: %d\n", contentType, count)
}
}
if len(result.Elements) > 0 {
fmt.Printf("\n🎯 Found Elements:\n")
for _, element := range result.Elements {
fmt.Printf(" %s <%s> id=%s type=%s\n",
filepath.Base(element.FilePath),
element.Tag,
element.ContentID,
element.Type)
}
}
if len(result.Warnings) > 0 {
fmt.Printf("\n⚠ Warnings:\n")
for _, warning := range result.Warnings {
fmt.Printf(" %s\n", warning)
}
}
}

31
insertr-cli/cmd/root.go Normal file
View File

@@ -0,0 +1,31 @@
package cmd
import (
"fmt"
"os"
"github.com/spf13/cobra"
)
var rootCmd = &cobra.Command{
Use: "insertr",
Short: "Insertr CLI - HTML enhancement for static sites",
Long: `Insertr CLI adds editing capabilities to static HTML sites by detecting
editable elements and injecting content management functionality.
The tool parses HTML files, finds elements with the 'insertr' class,
and enhances them with editing capabilities while preserving
static site performance.`,
Version: "0.0.1",
}
func Execute() {
if err := rootCmd.Execute(); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}
func init() {
rootCmd.AddCommand(parseCmd)
}

View File

@@ -0,0 +1,87 @@
package cmd
import (
"fmt"
"log"
"net/http"
"os"
"path/filepath"
"strings"
"github.com/spf13/cobra"
)
var servedevCmd = &cobra.Command{
Use: "servedev",
Short: "Development server that parses and serves enhanced HTML files",
Long: `Servedev starts a development HTTP server that automatically parses HTML files
for insertr elements and serves the enhanced content. Perfect for development workflow
with live rebuilds via Air.`,
Run: runServedev,
}
var (
inputDir string
port int
)
func init() {
rootCmd.AddCommand(servedevCmd)
servedevCmd.Flags().StringVarP(&inputDir, "input", "i", ".", "Input directory to serve")
servedevCmd.Flags().IntVarP(&port, "port", "p", 3000, "Port to serve on")
}
func runServedev(cmd *cobra.Command, args []string) {
// Resolve absolute path for input directory
absInputDir, err := filepath.Abs(inputDir)
if err != nil {
log.Fatalf("Error resolving input directory: %v", err)
}
// Check if input directory exists
if _, err := os.Stat(absInputDir); os.IsNotExist(err) {
log.Fatalf("Input directory does not exist: %s", absInputDir)
}
fmt.Printf("🚀 Starting development server...\n")
fmt.Printf("📁 Serving directory: %s\n", absInputDir)
fmt.Printf("🌐 Server running at: http://localhost:%d\n", port)
fmt.Printf("🔄 Manually refresh browser to see changes\n\n")
// Create file server
fileServer := http.FileServer(&enhancedFileSystem{
fs: http.Dir(absInputDir),
dir: absInputDir,
})
// Handle all requests with our enhanced file server
http.Handle("/", fileServer)
// Start server
addr := fmt.Sprintf(":%d", port)
log.Fatal(http.ListenAndServe(addr, nil))
}
// enhancedFileSystem wraps http.FileSystem to provide enhanced HTML serving
type enhancedFileSystem struct {
fs http.FileSystem
dir string
}
func (efs *enhancedFileSystem) Open(name string) (http.File, error) {
file, err := efs.fs.Open(name)
if err != nil {
return nil, err
}
// For HTML files, we'll eventually enhance them here
// For now, just serve them as-is
if strings.HasSuffix(name, ".html") {
fmt.Printf("📄 Serving HTML: %s\n", name)
fmt.Println("🔍 Parser ran!")
// TODO: Parse for insertr elements and enhance
}
return file, nil
}

15
insertr-cli/go.mod Normal file
View File

@@ -0,0 +1,15 @@
module github.com/insertr/cli
go 1.23.0
toolchain go1.24.6
require (
github.com/spf13/cobra v1.8.0
golang.org/x/net v0.43.0
)
require (
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/spf13/pflag v1.0.5 // indirect
)

12
insertr-cli/go.sum Normal file
View File

@@ -0,0 +1,12 @@
github.com/cpuguy83/go-md2man/v2 v2.0.3/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/spf13/cobra v1.8.0 h1:7aJaZx1B85qltLMc546zn58BxxfZdR/W22ej9CFoEf0=
github.com/spf13/cobra v1.8.0/go.mod h1:WXLWApfZ71AjXPya3WOlMsY9yMs7YeiHhFVlvLyhcho=
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
golang.org/x/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE=
golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

9
insertr-cli/main.go Normal file
View File

@@ -0,0 +1,9 @@
package main
import (
"github.com/insertr/cli/cmd"
)
func main() {
cmd.Execute()
}

View File

@@ -0,0 +1,177 @@
package parser
import (
"crypto/sha1"
"fmt"
"regexp"
"strings"
"golang.org/x/net/html"
)
// IDGenerator generates unique content IDs for elements
type IDGenerator struct {
usedIDs map[string]bool
}
// NewIDGenerator creates a new ID generator
func NewIDGenerator() *IDGenerator {
return &IDGenerator{
usedIDs: make(map[string]bool),
}
}
// Generate creates a content ID for an HTML element
func (g *IDGenerator) Generate(node *html.Node) string {
context := g.getSemanticContext(node)
purpose := g.getPurpose(node)
content := g.getContentSample(node)
baseID := g.createBaseID(context, purpose, content)
return g.ensureUnique(baseID)
}
// getSemanticContext determines the semantic context from parent elements
func (g *IDGenerator) getSemanticContext(node *html.Node) string {
// Walk up the tree to find semantic containers
parent := node.Parent
for parent != nil && parent.Type == html.ElementNode {
classes := getClasses(parent)
// Check for common semantic section classes
for _, class := range []string{"hero", "services", "nav", "navbar", "footer", "about", "contact", "testimonial"} {
if containsClass(classes, class) {
return class
}
}
// Check for semantic HTML elements
switch parent.Data {
case "nav":
return "nav"
case "header":
return "header"
case "footer":
return "footer"
case "main":
return "main"
case "aside":
return "aside"
}
parent = parent.Parent
}
return "content"
}
// getPurpose determines the purpose/role of the element
func (g *IDGenerator) getPurpose(node *html.Node) string {
tag := strings.ToLower(node.Data)
classes := getClasses(node)
// Check for specific CSS classes that indicate purpose
for _, class := range classes {
switch {
case strings.Contains(class, "title"):
return "title"
case strings.Contains(class, "headline"):
return "headline"
case strings.Contains(class, "description"):
return "description"
case strings.Contains(class, "subtitle"):
return "subtitle"
case strings.Contains(class, "cta"):
return "cta"
case strings.Contains(class, "button"):
return "button"
case strings.Contains(class, "logo"):
return "logo"
case strings.Contains(class, "lead"):
return "lead"
}
}
// Infer purpose from HTML tag
switch tag {
case "h1":
return "title"
case "h2":
return "subtitle"
case "h3", "h4", "h5", "h6":
return "heading"
case "p":
return "text"
case "a":
return "link"
case "button":
return "button"
default:
return "content"
}
}
// getContentSample gets a sample of content for ID generation
func (g *IDGenerator) getContentSample(node *html.Node) string {
text := extractTextContent(node)
// Clean and normalize text
text = strings.ToLower(text)
text = regexp.MustCompile(`[^a-z0-9\s]+`).ReplaceAllString(text, "")
text = regexp.MustCompile(`\s+`).ReplaceAllString(text, " ")
text = strings.TrimSpace(text)
// Take first few words
words := strings.Fields(text)
if len(words) > 3 {
words = words[:3]
}
return strings.Join(words, "-")
}
// createBaseID creates the base ID from components
func (g *IDGenerator) createBaseID(context, purpose, content string) string {
parts := []string{}
// Add context if meaningful
if context != "content" {
parts = append(parts, context)
}
// Add purpose
parts = append(parts, purpose)
// Add content sample if available and meaningful
if content != "" && content != purpose {
parts = append(parts, content)
}
baseID := strings.Join(parts, "-")
// Clean up the ID
baseID = regexp.MustCompile(`-+`).ReplaceAllString(baseID, "-")
baseID = strings.Trim(baseID, "-")
// Ensure it's not empty
if baseID == "" {
baseID = "content"
}
return baseID
}
// ensureUnique makes sure the ID is unique by adding a suffix if needed
func (g *IDGenerator) ensureUnique(baseID string) string {
if !g.usedIDs[baseID] {
g.usedIDs[baseID] = true
return baseID
}
// If base ID is taken, add a hash suffix
hash := fmt.Sprintf("%x", sha1.Sum([]byte(baseID)))[:6]
uniqueID := fmt.Sprintf("%s-%s", baseID, hash)
g.usedIDs[uniqueID] = true
return uniqueID
}

View File

@@ -0,0 +1,229 @@
package parser
import (
"fmt"
"io/fs"
"os"
"path/filepath"
"strings"
"golang.org/x/net/html"
)
// Parser handles HTML parsing and element detection
type Parser struct {
idGenerator *IDGenerator
}
// New creates a new Parser instance
func New() *Parser {
return &Parser{
idGenerator: NewIDGenerator(),
}
}
// ParseDirectory parses all HTML files in the given directory
func (p *Parser) ParseDirectory(dir string) (*ParseResult, error) {
result := &ParseResult{
Elements: []Element{},
Warnings: []string{},
Stats: ParseStats{
TypeBreakdown: make(map[ContentType]int),
},
}
err := filepath.WalkDir(dir, func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
// Only process HTML files
if d.IsDir() || !strings.HasSuffix(strings.ToLower(path), ".html") {
return nil
}
elements, warnings, err := p.parseFile(path)
if err != nil {
result.Warnings = append(result.Warnings,
fmt.Sprintf("Error parsing %s: %v", path, err))
return nil // Continue processing other files
}
result.Elements = append(result.Elements, elements...)
result.Warnings = append(result.Warnings, warnings...)
result.Stats.FilesProcessed++
return nil
})
if err != nil {
return nil, fmt.Errorf("error walking directory: %w", err)
}
// Calculate statistics
p.calculateStats(result)
return result, nil
}
// parseFile parses a single HTML file
func (p *Parser) parseFile(filePath string) ([]Element, []string, error) {
file, err := os.Open(filePath)
if err != nil {
return nil, nil, fmt.Errorf("error opening file: %w", err)
}
defer file.Close()
doc, err := html.Parse(file)
if err != nil {
return nil, nil, fmt.Errorf("error parsing HTML: %w", err)
}
var elements []Element
var warnings []string
p.findInsertrElements(doc, filePath, &elements, &warnings)
return elements, warnings, nil
}
// findInsertrElements recursively finds all elements with "insertr" class
func (p *Parser) findInsertrElements(node *html.Node, filePath string, elements *[]Element, warnings *[]string) {
if node.Type == html.ElementNode {
classes := getClasses(node)
// Check if element has "insertr" class
if containsClass(classes, "insertr") {
if isContainer(node) {
// Container element - expand to viable children
viableChildren := findViableChildren(node)
for _, child := range viableChildren {
childClasses := getClasses(child)
element, warning := p.createElement(child, filePath, childClasses)
*elements = append(*elements, element)
if warning != "" {
*warnings = append(*warnings, warning)
}
}
// Don't process children recursively since we've handled the container's children
return
} else {
// Regular element - process as before
element, warning := p.createElement(node, filePath, classes)
*elements = append(*elements, element)
if warning != "" {
*warnings = append(*warnings, warning)
}
}
}
}
// Recursively check children
for child := node.FirstChild; child != nil; child = child.NextSibling {
p.findInsertrElements(child, filePath, elements, warnings)
}
}
// createElement creates an Element from an HTML node
func (p *Parser) createElement(node *html.Node, filePath string, classes []string) (Element, string) {
var warning string
// Resolve content ID (existing or generated)
contentID, hasExistingID := p.resolveContentID(node)
if !hasExistingID {
contentID = p.idGenerator.Generate(node)
}
// Detect content type
contentType := p.detectContentType(node, classes)
// Extract text content
content := extractTextContent(node)
element := Element{
FilePath: filePath,
Node: node,
ContentID: contentID,
Type: contentType,
Tag: strings.ToLower(node.Data),
Classes: classes,
Content: content,
HasID: hasExistingID,
Generated: !hasExistingID,
}
// Generate warnings for edge cases
if content == "" {
warning = fmt.Sprintf("Element <%s> with id '%s' has no text content",
element.Tag, element.ContentID)
}
return element, warning
}
// resolveContentID gets the content ID from existing attributes
func (p *Parser) resolveContentID(node *html.Node) (string, bool) {
// 1. Check for existing HTML id attribute
if id := getAttribute(node, "id"); id != "" {
return id, true
}
// 2. Check for data-content-id attribute
if contentID := getAttribute(node, "data-content-id"); contentID != "" {
return contentID, true
}
// 3. No existing ID found
return "", false
}
// detectContentType determines the content type based on element and classes
func (p *Parser) detectContentType(node *html.Node, classes []string) ContentType {
// Check for explicit type classes first
if containsClass(classes, "insertr-markdown") {
return ContentMarkdown
}
if containsClass(classes, "insertr-link") {
return ContentLink
}
if containsClass(classes, "insertr-text") {
return ContentText
}
// Infer from HTML tag and context
tag := strings.ToLower(node.Data)
switch tag {
case "h1", "h2", "h3", "h4", "h5", "h6":
return ContentText
case "p":
// Paragraphs default to markdown for rich content
return ContentMarkdown
case "a", "button":
return ContentLink
case "div", "section":
// Default divs/sections to markdown for rich content
return ContentMarkdown
case "span":
return ContentText
default:
return ContentText
}
}
// calculateStats computes statistics for the parse result
func (p *Parser) calculateStats(result *ParseResult) {
result.Stats.TotalElements = len(result.Elements)
for _, element := range result.Elements {
// Count existing vs generated IDs
if element.HasID {
result.Stats.ExistingIDs++
} else {
result.Stats.GeneratedIDs++
}
// Count content types
result.Stats.TypeBreakdown[element.Type]++
}
}

View File

@@ -0,0 +1,41 @@
package parser
import "golang.org/x/net/html"
// ContentType represents the type of editable content
type ContentType string
const (
ContentText ContentType = "text"
ContentMarkdown ContentType = "markdown"
ContentLink ContentType = "link"
)
// Element represents a parsed editable element
type Element struct {
FilePath string `json:"file_path"`
Node *html.Node `json:"-"` // Don't serialize HTML node
ContentID string `json:"content_id"`
Type ContentType `json:"type"`
Tag string `json:"tag"`
Classes []string `json:"classes"`
Content string `json:"content"`
HasID bool `json:"has_id"` // Whether element had existing ID
Generated bool `json:"generated"` // Whether ID was generated
}
// ParseResult contains the results of parsing HTML files
type ParseResult struct {
Elements []Element `json:"elements"`
Warnings []string `json:"warnings"`
Stats ParseStats `json:"stats"`
}
// ParseStats provides statistics about the parsing operation
type ParseStats struct {
FilesProcessed int `json:"files_processed"`
TotalElements int `json:"total_elements"`
ExistingIDs int `json:"existing_ids"`
GeneratedIDs int `json:"generated_ids"`
TypeBreakdown map[ContentType]int `json:"type_breakdown"`
}

View File

@@ -0,0 +1,159 @@
package parser
import (
"strings"
"golang.org/x/net/html"
)
// getClasses extracts CSS classes from an HTML node
func getClasses(node *html.Node) []string {
classAttr := getAttribute(node, "class")
if classAttr == "" {
return []string{}
}
classes := strings.Fields(classAttr)
return classes
}
// containsClass checks if a class list contains a specific class
func containsClass(classes []string, target string) bool {
for _, class := range classes {
if class == target {
return true
}
}
return false
}
// getAttribute gets an attribute value from an HTML node
func getAttribute(node *html.Node, key string) string {
for _, attr := range node.Attr {
if attr.Key == key {
return attr.Val
}
}
return ""
}
// extractTextContent gets the text content from an HTML node
func extractTextContent(node *html.Node) string {
var text strings.Builder
extractTextRecursive(node, &text)
return strings.TrimSpace(text.String())
}
// extractTextRecursive recursively extracts text from node and children
func extractTextRecursive(node *html.Node, text *strings.Builder) {
if node.Type == html.TextNode {
text.WriteString(node.Data)
}
for child := node.FirstChild; child != nil; child = child.NextSibling {
// Skip script and style elements
if child.Type == html.ElementNode &&
(child.Data == "script" || child.Data == "style") {
continue
}
extractTextRecursive(child, text)
}
}
// hasOnlyTextContent checks if a node contains only text content (no nested HTML elements)
func hasOnlyTextContent(node *html.Node) bool {
if node.Type != html.ElementNode {
return false
}
for child := node.FirstChild; child != nil; child = child.NextSibling {
switch child.Type {
case html.ElementNode:
// Found a nested HTML element - not text-only
return false
case html.TextNode:
// Text nodes are fine, continue checking
continue
default:
// Comments, etc. - continue checking
continue
}
}
return true
}
// isContainer checks if a tag is typically used as a container element
func isContainer(node *html.Node) bool {
if node.Type != html.ElementNode {
return false
}
containerTags := map[string]bool{
"div": true,
"section": true,
"article": true,
"header": true,
"footer": true,
"main": true,
"aside": true,
"nav": true,
}
return containerTags[node.Data]
}
// findViableChildren finds all child elements that are viable for editing
func findViableChildren(node *html.Node) []*html.Node {
var viable []*html.Node
for child := node.FirstChild; child != nil; child = child.NextSibling {
// Skip whitespace-only text nodes
if child.Type == html.TextNode {
if strings.TrimSpace(child.Data) == "" {
continue
}
}
// Only consider element nodes
if child.Type != html.ElementNode {
continue
}
// Skip self-closing elements for now
if isSelfClosing(child) {
continue
}
// Check if element has only text content
if hasOnlyTextContent(child) {
viable = append(viable, child)
}
}
return viable
}
// isSelfClosing checks if an element is typically self-closing
func isSelfClosing(node *html.Node) bool {
if node.Type != html.ElementNode {
return false
}
selfClosingTags := map[string]bool{
"img": true,
"input": true,
"br": true,
"hr": true,
"meta": true,
"link": true,
"area": true,
"base": true,
"col": true,
"embed": true,
"source": true,
"track": true,
"wbr": true,
}
return selfClosingTags[node.Data]
}