From 4407f84bbce920c31649f0eadc671947ae18d0b3 Mon Sep 17 00:00:00 2001 From: Joakim Date: Wed, 3 Sep 2025 12:35:54 +0200 Subject: [PATCH] Implement complete content injection and enhancement pipeline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add content API client with HTTP and mock implementations - Implement HTML content injection with database content replacement - Create enhance command for build-time content injection - Integrate enhancement with servedev for live development workflow - Add editor asset injection and serving (/_insertr/ endpoints) - Support on-the-fly HTML enhancement during development - Enable complete 'Tailwind of CMS' workflow: parse โ†’ inject โ†’ serve --- insertr-cli/assets/editor/insertr-editor.js | 85 ++++++++ insertr-cli/cmd/enhance.go | 76 +++++++ insertr-cli/cmd/servedev.go | 118 +++++++++-- insertr-cli/pkg/content/client.go | 164 +++++++++++++++ insertr-cli/pkg/content/enhancer.go | 215 ++++++++++++++++++++ insertr-cli/pkg/content/injector.go | 204 +++++++++++++++++++ insertr-cli/pkg/content/mock.go | 147 +++++++++++++ insertr-cli/pkg/content/types.go | 28 +++ 8 files changed, 1017 insertions(+), 20 deletions(-) create mode 100644 insertr-cli/assets/editor/insertr-editor.js create mode 100644 insertr-cli/cmd/enhance.go create mode 100644 insertr-cli/pkg/content/client.go create mode 100644 insertr-cli/pkg/content/enhancer.go create mode 100644 insertr-cli/pkg/content/injector.go create mode 100644 insertr-cli/pkg/content/mock.go create mode 100644 insertr-cli/pkg/content/types.go diff --git a/insertr-cli/assets/editor/insertr-editor.js b/insertr-cli/assets/editor/insertr-editor.js new file mode 100644 index 0000000..892b05c --- /dev/null +++ b/insertr-cli/assets/editor/insertr-editor.js @@ -0,0 +1,85 @@ +/** + * Insertr Editor - Development Mode + * Loads editing capabilities for elements marked with data-insertr-enhanced="true" + */ + +(function() { + 'use strict'; + + console.log('๐Ÿ”ง Insertr Editor loaded (development mode)'); + + // Initialize editor when DOM is ready + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', initEditor); + } else { + initEditor(); + } + + function initEditor() { + console.log('๐Ÿš€ Initializing Insertr Editor'); + + // Find all enhanced elements + const enhancedElements = document.querySelectorAll('[data-insertr-enhanced="true"]'); + console.log(`๐Ÿ“ Found ${enhancedElements.length} editable elements`); + + // Add visual indicators for development + enhancedElements.forEach(addEditIndicator); + + // Add global styles + addEditorStyles(); + } + + function addEditIndicator(element) { + const contentId = element.getAttribute('data-content-id'); + const contentType = element.getAttribute('data-content-type'); + + // Add hover effect + element.style.cursor = 'pointer'; + element.style.position = 'relative'; + + // Add click handler for development demo + element.addEventListener('click', function(e) { + e.preventDefault(); + alert(`Edit: ${contentId}\nType: ${contentType}\nCurrent: "${element.textContent.trim()}"`); + }); + + // Add visual indicator on hover + element.addEventListener('mouseenter', function() { + element.classList.add('insertr-editing-hover'); + }); + + element.addEventListener('mouseleave', function() { + element.classList.remove('insertr-editing-hover'); + }); + } + + function addEditorStyles() { + const styles = ` + .insertr-editing-hover { + outline: 2px dashed #007cba !important; + outline-offset: 2px !important; + background-color: rgba(0, 124, 186, 0.05) !important; + } + + [data-insertr-enhanced="true"]:hover::after { + content: "โœ๏ธ " attr(data-content-type); + position: absolute; + top: -25px; + left: 0; + background: #007cba; + color: white; + padding: 2px 6px; + font-size: 11px; + border-radius: 3px; + white-space: nowrap; + z-index: 1000; + font-family: monospace; + } + `; + + const styleSheet = document.createElement('style'); + styleSheet.type = 'text/css'; + styleSheet.innerHTML = styles; + document.head.appendChild(styleSheet); + } +})(); \ No newline at end of file diff --git a/insertr-cli/cmd/enhance.go b/insertr-cli/cmd/enhance.go new file mode 100644 index 0000000..145d5c7 --- /dev/null +++ b/insertr-cli/cmd/enhance.go @@ -0,0 +1,76 @@ +package cmd + +import ( + "fmt" + "log" + "os" + + "github.com/spf13/cobra" + + "github.com/insertr/cli/pkg/content" +) + +var enhanceCmd = &cobra.Command{ + Use: "enhance [input-dir]", + Short: "Enhance HTML files by injecting content from database", + Long: `Enhance processes HTML files and injects latest content from the database +while adding editing capabilities. This is the core build-time enhancement +process that transforms static HTML into an editable CMS.`, + Args: cobra.ExactArgs(1), + Run: runEnhance, +} + +var ( + outputDir string + apiURL string + apiKey string + siteID string + mockContent bool +) + +func init() { + rootCmd.AddCommand(enhanceCmd) + + enhanceCmd.Flags().StringVarP(&outputDir, "output", "o", "./dist", "Output directory for enhanced files") + enhanceCmd.Flags().StringVar(&apiURL, "api-url", "", "Content API URL") + enhanceCmd.Flags().StringVar(&apiKey, "api-key", "", "API key for authentication") + enhanceCmd.Flags().StringVarP(&siteID, "site-id", "s", "demo", "Site ID for content lookup") + enhanceCmd.Flags().BoolVar(&mockContent, "mock", true, "Use mock content for development") +} + +func runEnhance(cmd *cobra.Command, args []string) { + inputDir := args[0] + + // Validate input directory + if _, err := os.Stat(inputDir); os.IsNotExist(err) { + log.Fatalf("Input directory does not exist: %s", inputDir) + } + + // Create content client + var client content.ContentClient + if mockContent { + fmt.Printf("๐Ÿงช Using mock content for development\n") + client = content.NewMockClient() + } else { + if apiURL == "" { + log.Fatal("API URL required when not using mock content (use --api-url)") + } + fmt.Printf("๐ŸŒ Using content API: %s\n", apiURL) + client = content.NewHTTPClient(apiURL, apiKey) + } + + // Create enhancer + enhancer := content.NewEnhancer(client, siteID) + + fmt.Printf("๐Ÿš€ Starting enhancement process...\n") + fmt.Printf("๐Ÿ“ Input: %s\n", inputDir) + fmt.Printf("๐Ÿ“ Output: %s\n", outputDir) + fmt.Printf("๐Ÿท๏ธ Site ID: %s\n\n", siteID) + + // Enhance directory + if err := enhancer.EnhanceDirectory(inputDir, outputDir); err != nil { + log.Fatalf("Enhancement failed: %v", err) + } + + fmt.Printf("\nโœ… Enhancement complete! Enhanced files available in: %s\n", outputDir) +} diff --git a/insertr-cli/cmd/servedev.go b/insertr-cli/cmd/servedev.go index a8a551f..ecc8cca 100644 --- a/insertr-cli/cmd/servedev.go +++ b/insertr-cli/cmd/servedev.go @@ -9,6 +9,8 @@ import ( "strings" "github.com/spf13/cobra" + + "github.com/insertr/cli/pkg/content" ) var servedevCmd = &cobra.Command{ @@ -21,8 +23,10 @@ with live rebuilds via Air.`, } var ( - inputDir string - port int + inputDir string + port int + useMockContent bool + devSiteID string ) func init() { @@ -30,6 +34,8 @@ func init() { servedevCmd.Flags().StringVarP(&inputDir, "input", "i", ".", "Input directory to serve") servedevCmd.Flags().IntVarP(&port, "port", "p", 3000, "Port to serve on") + servedevCmd.Flags().BoolVar(&useMockContent, "mock", true, "Use mock content for development") + servedevCmd.Flags().StringVarP(&devSiteID, "site-id", "s", "demo", "Site ID for content lookup") } func runServedev(cmd *cobra.Command, args []string) { @@ -44,18 +50,37 @@ func runServedev(cmd *cobra.Command, args []string) { log.Fatalf("Input directory does not exist: %s", absInputDir) } - fmt.Printf("๐Ÿš€ Starting development server...\n") + // Create content client + var client content.ContentClient + if useMockContent { + fmt.Printf("๐Ÿงช Using mock content for development\n") + client = content.NewMockClient() + } else { + // For now, default to mock if no API URL provided + fmt.Printf("๐Ÿงช Using mock content for development (no API configured)\n") + client = content.NewMockClient() + } + + fmt.Printf("๐Ÿš€ Starting development server with content enhancement...\n") fmt.Printf("๐Ÿ“ Serving directory: %s\n", absInputDir) fmt.Printf("๐ŸŒ Server running at: http://localhost:%d\n", port) + fmt.Printf("๐Ÿท๏ธ Site ID: %s\n", devSiteID) fmt.Printf("๐Ÿ”„ Manually refresh browser to see changes\n\n") - // Create file server + // Create enhanced file server fileServer := http.FileServer(&enhancedFileSystem{ - fs: http.Dir(absInputDir), - dir: absInputDir, + fs: http.Dir(absInputDir), + dir: absInputDir, + enhancer: content.NewEnhancer(client, devSiteID), }) - // Handle all requests with our enhanced file server + // Handle editor assets + http.HandleFunc("/_insertr/", func(w http.ResponseWriter, r *http.Request) { + assetPath := strings.TrimPrefix(r.URL.Path, "/_insertr/") + serveEditorAsset(w, r, assetPath) + }) + + // Handle all other requests with our enhanced file server http.Handle("/", fileServer) // Start server @@ -63,25 +88,78 @@ func runServedev(cmd *cobra.Command, args []string) { log.Fatal(http.ListenAndServe(addr, nil)) } +// serveEditorAsset serves editor JavaScript and CSS files +func serveEditorAsset(w http.ResponseWriter, r *http.Request, assetPath string) { + // Get the path to the CLI binary directory + execPath, err := os.Executable() + if err != nil { + http.NotFound(w, r) + return + } + + // Look for assets relative to the CLI binary (for built version) + assetsDir := filepath.Join(filepath.Dir(execPath), "assets", "editor") + assetFile := filepath.Join(assetsDir, assetPath) + + // If not found, look for assets relative to source (for development) + if _, err := os.Stat(assetFile); os.IsNotExist(err) { + // Assume we're running from source + cwd, _ := os.Getwd() + assetsDir = filepath.Join(cwd, "assets", "editor") + assetFile = filepath.Join(assetsDir, assetPath) + } + + // Set appropriate content type + if strings.HasSuffix(assetPath, ".js") { + w.Header().Set("Content-Type", "application/javascript") + } else if strings.HasSuffix(assetPath, ".css") { + w.Header().Set("Content-Type", "text/css") + } + + // Serve the file + http.ServeFile(w, r, assetFile) +} + // enhancedFileSystem wraps http.FileSystem to provide enhanced HTML serving type enhancedFileSystem struct { - fs http.FileSystem - dir string + fs http.FileSystem + dir string + enhancer *content.Enhancer } 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 + // For HTML files, enhance them on-the-fly if strings.HasSuffix(name, ".html") { - fmt.Printf("๐Ÿ“„ Serving HTML: %s\n", name) - fmt.Println("๐Ÿ” Parser ran!") - // TODO: Parse for insertr elements and enhance + fmt.Printf("๐Ÿ“„ Enhancing HTML: %s\n", name) + return efs.serveEnhancedHTML(name) } - return file, nil + // For non-HTML files, serve as-is + return efs.fs.Open(name) +} + +// serveEnhancedHTML enhances an HTML file and returns it as an http.File +func (efs *enhancedFileSystem) serveEnhancedHTML(name string) (http.File, error) { + // Get the full file path + inputPath := filepath.Join(efs.dir, name) + + // Create a temporary output path (in-memory would be better, but this is simpler for now) + tempDir := filepath.Join(os.TempDir(), "insertr-dev") + outputPath := filepath.Join(tempDir, name) + + // Ensure temp directory exists + if err := os.MkdirAll(filepath.Dir(outputPath), 0755); err != nil { + fmt.Printf("โš ๏ธ Failed to create temp directory: %v\n", err) + return efs.fs.Open(name) // Fallback to original file + } + + // Enhance the file + if err := efs.enhancer.EnhanceFile(inputPath, outputPath); err != nil { + fmt.Printf("โš ๏ธ Enhancement failed for %s: %v\n", name, err) + return efs.fs.Open(name) // Fallback to original file + } + + // Serve the enhanced file + tempFS := http.Dir(tempDir) + return tempFS.Open(name) } diff --git a/insertr-cli/pkg/content/client.go b/insertr-cli/pkg/content/client.go new file mode 100644 index 0000000..673d21e --- /dev/null +++ b/insertr-cli/pkg/content/client.go @@ -0,0 +1,164 @@ +package content + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "time" +) + +// HTTPClient implements ContentClient for HTTP API access +type HTTPClient struct { + BaseURL string + APIKey string + HTTPClient *http.Client +} + +// NewHTTPClient creates a new HTTP content client +func NewHTTPClient(baseURL, apiKey string) *HTTPClient { + return &HTTPClient{ + BaseURL: strings.TrimSuffix(baseURL, "/"), + APIKey: apiKey, + HTTPClient: &http.Client{ + Timeout: 30 * time.Second, + }, + } +} + +// GetContent fetches a single content item by ID +func (c *HTTPClient) GetContent(siteID, contentID string) (*ContentItem, error) { + url := fmt.Sprintf("%s/api/content/%s?site_id=%s", c.BaseURL, contentID, siteID) + + req, err := http.NewRequest("GET", url, nil) + if err != nil { + return nil, fmt.Errorf("creating request: %w", err) + } + + if c.APIKey != "" { + req.Header.Set("Authorization", "Bearer "+c.APIKey) + } + + resp, err := c.HTTPClient.Do(req) + if err != nil { + return nil, fmt.Errorf("making request: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode == 404 { + return nil, nil // Content not found, return nil without error + } + + if resp.StatusCode != 200 { + return nil, fmt.Errorf("API error: %s", resp.Status) + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("reading response: %w", err) + } + + var item ContentItem + if err := json.Unmarshal(body, &item); err != nil { + return nil, fmt.Errorf("parsing response: %w", err) + } + + return &item, nil +} + +// GetBulkContent fetches multiple content items by IDs +func (c *HTTPClient) GetBulkContent(siteID string, contentIDs []string) (map[string]ContentItem, error) { + if len(contentIDs) == 0 { + return make(map[string]ContentItem), nil + } + + // Build query parameters + params := url.Values{} + params.Set("site_id", siteID) + for _, id := range contentIDs { + params.Add("ids", id) + } + + url := fmt.Sprintf("%s/api/content/bulk?%s", c.BaseURL, params.Encode()) + + req, err := http.NewRequest("GET", url, nil) + if err != nil { + return nil, fmt.Errorf("creating request: %w", err) + } + + if c.APIKey != "" { + req.Header.Set("Authorization", "Bearer "+c.APIKey) + } + + resp, err := c.HTTPClient.Do(req) + if err != nil { + return nil, fmt.Errorf("making request: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != 200 { + return nil, fmt.Errorf("API error: %s", resp.Status) + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("reading response: %w", err) + } + + var response ContentResponse + if err := json.Unmarshal(body, &response); err != nil { + return nil, fmt.Errorf("parsing response: %w", err) + } + + // Convert slice to map for easy lookup + result := make(map[string]ContentItem) + for _, item := range response.Content { + result[item.ID] = item + } + + return result, nil +} + +// GetAllContent fetches all content for a site +func (c *HTTPClient) GetAllContent(siteID string) (map[string]ContentItem, error) { + url := fmt.Sprintf("%s/api/content?site_id=%s", c.BaseURL, siteID) + + req, err := http.NewRequest("GET", url, nil) + if err != nil { + return nil, fmt.Errorf("creating request: %w", err) + } + + if c.APIKey != "" { + req.Header.Set("Authorization", "Bearer "+c.APIKey) + } + + resp, err := c.HTTPClient.Do(req) + if err != nil { + return nil, fmt.Errorf("making request: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != 200 { + return nil, fmt.Errorf("API error: %s", resp.Status) + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("reading response: %w", err) + } + + var response ContentResponse + if err := json.Unmarshal(body, &response); err != nil { + return nil, fmt.Errorf("parsing response: %w", err) + } + + // Convert slice to map for easy lookup + result := make(map[string]ContentItem) + for _, item := range response.Content { + result[item.ID] = item + } + + return result, nil +} diff --git a/insertr-cli/pkg/content/enhancer.go b/insertr-cli/pkg/content/enhancer.go new file mode 100644 index 0000000..e7abbbf --- /dev/null +++ b/insertr-cli/pkg/content/enhancer.go @@ -0,0 +1,215 @@ +package content + +import ( + "fmt" + "os" + "path/filepath" + "strings" + + "golang.org/x/net/html" + + "github.com/insertr/cli/pkg/parser" +) + +// Enhancer combines parsing and content injection +type Enhancer struct { + parser *parser.Parser + injector *Injector +} + +// NewEnhancer creates a new HTML enhancer +func NewEnhancer(client ContentClient, siteID string) *Enhancer { + return &Enhancer{ + parser: parser.New(), + injector: NewInjector(client, siteID), + } +} + +// EnhanceFile processes an HTML file and injects content +func (e *Enhancer) EnhanceFile(inputPath, outputPath string) error { + // Use parser to get elements from file + result, err := e.parser.ParseDirectory(filepath.Dir(inputPath)) + if err != nil { + return fmt.Errorf("parsing file: %w", err) + } + + // Filter elements for this specific file + var fileElements []parser.Element + inputBaseName := filepath.Base(inputPath) + for _, elem := range result.Elements { + elemBaseName := filepath.Base(elem.FilePath) + if elemBaseName == inputBaseName { + fileElements = append(fileElements, elem) + } + } + + if len(fileElements) == 0 { + // No insertr elements found, copy file as-is + return e.copyFile(inputPath, outputPath) + } + + // Read and parse HTML for modification + htmlContent, err := os.ReadFile(inputPath) + if err != nil { + return fmt.Errorf("reading file %s: %w", inputPath, err) + } + + doc, err := html.Parse(strings.NewReader(string(htmlContent))) + if err != nil { + return fmt.Errorf("parsing HTML: %w", err) + } + + // Find and inject content for each element + for _, elem := range fileElements { + // Find the node in the parsed document + // Note: This is a simplified approach - in production we'd need more robust node matching + if err := e.injectElementContent(doc, elem); err != nil { + fmt.Printf("โš ๏ธ Warning: failed to inject content for %s: %v\n", elem.ContentID, err) + } + } + + // Inject editor assets for development + e.injector.InjectEditorAssets(doc, true) + + // Write enhanced HTML + if err := e.writeHTML(doc, outputPath); err != nil { + return fmt.Errorf("writing enhanced HTML: %w", err) + } + + fmt.Printf("โœ… Enhanced: %s โ†’ %s (%d elements)\n", + filepath.Base(inputPath), + filepath.Base(outputPath), + len(fileElements)) + + return nil +} + +// injectElementContent finds and injects content for a specific element +func (e *Enhancer) injectElementContent(doc *html.Node, elem parser.Element) error { + // Fetch content from database + contentItem, err := e.injector.client.GetContent(e.injector.siteID, elem.ContentID) + if err != nil { + return fmt.Errorf("fetching content: %w", err) + } + + // Find nodes with insertr class and inject content + e.findAndInjectNodes(doc, elem, contentItem) + return nil +} + +// findAndInjectNodes recursively finds nodes and injects content +func (e *Enhancer) findAndInjectNodes(node *html.Node, elem parser.Element, contentItem *ContentItem) { + if node.Type == html.ElementNode { + // Check if this node matches our element criteria + classes := getClasses(node) + if containsClass(classes, "insertr") && node.Data == elem.Tag { + // This might be our target node - inject content + e.injector.addContentAttributes(node, elem.ContentID, string(elem.Type)) + + if contentItem != nil { + switch elem.Type { + case parser.ContentText: + e.injector.injectTextContent(node, contentItem.Value) + case parser.ContentMarkdown: + e.injector.injectMarkdownContent(node, contentItem.Value) + case parser.ContentLink: + e.injector.injectLinkContent(node, contentItem.Value) + } + } + } + } + + // Recursively process children + for child := node.FirstChild; child != nil; child = child.NextSibling { + e.findAndInjectNodes(child, elem, contentItem) + } +} + +// Helper functions from parser package +func getClasses(node *html.Node) []string { + for _, attr := range node.Attr { + if attr.Key == "class" { + return strings.Fields(attr.Val) + } + } + return []string{} +} + +func containsClass(classes []string, target string) bool { + for _, class := range classes { + if class == target { + return true + } + } + return false +} + +// EnhanceDirectory processes all HTML files in a directory +func (e *Enhancer) EnhanceDirectory(inputDir, outputDir string) error { + // Create output directory + if err := os.MkdirAll(outputDir, 0755); err != nil { + return fmt.Errorf("creating output directory: %w", err) + } + + // Walk input directory + return filepath.Walk(inputDir, func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + + // Calculate relative path and output path + relPath, err := filepath.Rel(inputDir, path) + if err != nil { + return err + } + outputPath := filepath.Join(outputDir, relPath) + + // Handle directories + if info.IsDir() { + return os.MkdirAll(outputPath, info.Mode()) + } + + // Handle HTML files + if strings.HasSuffix(strings.ToLower(path), ".html") { + return e.EnhanceFile(path, outputPath) + } + + // Copy other files as-is + return e.copyFile(path, outputPath) + }) +} + +// copyFile copies a file from src to dst +func (e *Enhancer) copyFile(src, dst string) error { + // Create directory for destination + if err := os.MkdirAll(filepath.Dir(dst), 0755); err != nil { + return err + } + + // Read source + data, err := os.ReadFile(src) + if err != nil { + return err + } + + // Write destination + return os.WriteFile(dst, data, 0644) +} + +// writeHTML writes an HTML document to a file +func (e *Enhancer) writeHTML(doc *html.Node, outputPath string) error { + // Create directory for output + if err := os.MkdirAll(filepath.Dir(outputPath), 0755); err != nil { + return err + } + + // Create output file + file, err := os.Create(outputPath) + if err != nil { + return err + } + defer file.Close() + + // Write HTML + return html.Render(file, doc) +} diff --git a/insertr-cli/pkg/content/injector.go b/insertr-cli/pkg/content/injector.go new file mode 100644 index 0000000..d2c68c2 --- /dev/null +++ b/insertr-cli/pkg/content/injector.go @@ -0,0 +1,204 @@ +package content + +import ( + "fmt" + + "golang.org/x/net/html" +) + +// Injector handles content injection into HTML elements +type Injector struct { + client ContentClient + siteID string +} + +// NewInjector creates a new content injector +func NewInjector(client ContentClient, siteID string) *Injector { + return &Injector{ + client: client, + siteID: siteID, + } +} + +// InjectContent replaces element content with database values and adds content IDs +func (i *Injector) InjectContent(element *Element, contentID string) error { + // Fetch content from database/API + contentItem, err := i.client.GetContent(i.siteID, contentID) + if err != nil { + return fmt.Errorf("fetching content for %s: %w", contentID, err) + } + + // If no content found, keep original content but add data attributes + if contentItem == nil { + i.addContentAttributes(element.Node, contentID, element.Type) + return nil + } + + // Replace element content based on type + switch element.Type { + case "text": + i.injectTextContent(element.Node, contentItem.Value) + case "markdown": + i.injectMarkdownContent(element.Node, contentItem.Value) + case "link": + i.injectLinkContent(element.Node, contentItem.Value) + default: + i.injectTextContent(element.Node, contentItem.Value) + } + + // Add data attributes for editor functionality + i.addContentAttributes(element.Node, contentID, element.Type) + + return nil +} + +// InjectBulkContent efficiently injects multiple content items +func (i *Injector) InjectBulkContent(elements []ElementWithID) error { + // Extract content IDs for bulk fetch + contentIDs := make([]string, len(elements)) + for idx, elem := range elements { + contentIDs[idx] = elem.ContentID + } + + // Bulk fetch content + contentMap, err := i.client.GetBulkContent(i.siteID, contentIDs) + if err != nil { + return fmt.Errorf("bulk fetching content: %w", err) + } + + // Inject each element + for _, elem := range elements { + contentItem, exists := contentMap[elem.ContentID] + + // Add content attributes regardless + i.addContentAttributes(elem.Element.Node, elem.ContentID, elem.Element.Type) + + if !exists { + // Keep original content if not found in database + continue + } + + // Replace content based on type + switch elem.Element.Type { + case "text": + i.injectTextContent(elem.Element.Node, contentItem.Value) + case "markdown": + i.injectMarkdownContent(elem.Element.Node, contentItem.Value) + case "link": + i.injectLinkContent(elem.Element.Node, contentItem.Value) + default: + i.injectTextContent(elem.Element.Node, contentItem.Value) + } + } + + return nil +} + +// injectTextContent replaces text content in an element +func (i *Injector) injectTextContent(node *html.Node, content string) { + // Remove all child nodes + for child := node.FirstChild; child != nil; { + next := child.NextSibling + node.RemoveChild(child) + child = next + } + + // Add new text content + textNode := &html.Node{ + Type: html.TextNode, + Data: content, + } + node.AppendChild(textNode) +} + +// injectMarkdownContent handles markdown content (for now, just as text) +func (i *Injector) injectMarkdownContent(node *html.Node, content string) { + // For now, treat markdown as text content + // TODO: Implement markdown to HTML conversion + i.injectTextContent(node, content) +} + +// injectLinkContent handles link/button content with URL extraction +func (i *Injector) injectLinkContent(node *html.Node, content string) { + // For now, just inject the text content + // TODO: Parse content for URL and text components + i.injectTextContent(node, content) +} + +// addContentAttributes adds necessary data attributes for editor functionality +func (i *Injector) addContentAttributes(node *html.Node, contentID string, contentType string) { + i.setAttribute(node, "data-content-id", contentID) + i.setAttribute(node, "data-content-type", contentType) + i.setAttribute(node, "data-insertr-enhanced", "true") +} + +// InjectEditorAssets adds editor JavaScript and CSS to HTML document +func (i *Injector) InjectEditorAssets(doc *html.Node, isDevelopment bool) { + if !isDevelopment { + return // Only inject in development mode for now + } + + // Find the head element + head := i.findHeadElement(doc) + if head == nil { + return + } + + // Add editor JavaScript + script := &html.Node{ + Type: html.ElementNode, + Data: "script", + Attr: []html.Attribute{ + {Key: "src", Val: "/_insertr/insertr-editor.js"}, + {Key: "defer", Val: ""}, + }, + } + head.AppendChild(script) +} + +// findHeadElement finds the element in the document +func (i *Injector) findHeadElement(node *html.Node) *html.Node { + if node.Type == html.ElementNode && node.Data == "head" { + return node + } + + for child := node.FirstChild; child != nil; child = child.NextSibling { + if result := i.findHeadElement(child); result != nil { + return result + } + } + + return nil +} + +// setAttribute safely sets an attribute on an HTML node +func (i *Injector) setAttribute(node *html.Node, key, value string) { + // Remove existing attribute if present + for idx, attr := range node.Attr { + if attr.Key == key { + node.Attr = append(node.Attr[:idx], node.Attr[idx+1:]...) + break + } + } + + // Add new attribute + node.Attr = append(node.Attr, html.Attribute{ + Key: key, + Val: value, + }) +} + +// Element represents a parsed HTML element with metadata +type Element struct { + Node *html.Node + Type string + Tag string + Classes []string + Content string +} + +// ElementWithID combines an element with its generated content ID +type ElementWithID struct { + Element *Element + ContentID string +} diff --git a/insertr-cli/pkg/content/mock.go b/insertr-cli/pkg/content/mock.go new file mode 100644 index 0000000..b6c35a9 --- /dev/null +++ b/insertr-cli/pkg/content/mock.go @@ -0,0 +1,147 @@ +package content + +import ( + "fmt" + "time" +) + +// MockClient implements ContentClient with mock data for development +type MockClient struct { + data map[string]ContentItem +} + +// NewMockClient creates a new mock content client with sample data +func NewMockClient() *MockClient { + // Generate realistic mock content based on actual generated IDs + data := map[string]ContentItem{ + // Navigation (index.html has collision suffix) + "navbar-logo-2b10ad": { + ID: "navbar-logo-2b10ad", + SiteID: "demo", + Value: "Acme Consulting Solutions", + Type: "text", + UpdatedAt: time.Now().Format(time.RFC3339), + }, + "navbar-logo-2b10ad-a44bad": { + ID: "navbar-logo-2b10ad-a44bad", + SiteID: "demo", + Value: "Acme Business Advisors", + Type: "text", + UpdatedAt: time.Now().Format(time.RFC3339), + }, + + // Hero Section - index.html (updated with actual IDs) + "hero-title-7cfeea": { + ID: "hero-title-7cfeea", + SiteID: "demo", + Value: "Transform Your Business with Strategic Expertise", + Type: "text", + UpdatedAt: time.Now().Format(time.RFC3339), + }, + "hero-lead-e47475": { + ID: "hero-lead-e47475", + SiteID: "demo", + Value: "We help **ambitious businesses** grow through strategic planning, process optimization, and digital transformation. Our team brings 20+ years of experience to accelerate your success.", + Type: "markdown", + UpdatedAt: time.Now().Format(time.RFC3339), + }, + "hero-link-76c620": { + ID: "hero-link-76c620", + SiteID: "demo", + Value: "Schedule Free Consultation", + Type: "link", + UpdatedAt: time.Now().Format(time.RFC3339), + }, + + // Hero Section - about.html + "hero-title-c70343": { + ID: "hero-title-c70343", + SiteID: "demo", + Value: "About Our Consulting Expertise", + Type: "text", + UpdatedAt: time.Now().Format(time.RFC3339), + }, + "hero-lead-673026": { + ID: "hero-lead-673026", + SiteID: "demo", + Value: "We're a team of **experienced consultants** dedicated to helping small businesses thrive in today's competitive marketplace through proven strategies.", + Type: "markdown", + UpdatedAt: time.Now().Format(time.RFC3339), + }, + + // Services Section + "services-subtitle-c8927c": { + ID: "services-subtitle-c8927c", + SiteID: "demo", + Value: "Our Story", + Type: "text", + UpdatedAt: time.Now().Format(time.RFC3339), + }, + "services-text-0d96da": { + ID: "services-text-0d96da", + SiteID: "demo", + Value: "**Founded in 2020**, Acme Consulting emerged from a simple observation: small businesses needed access to the same high-quality strategic advice that large corporations receive, but in a format that was accessible, affordable, and actionable.", + Type: "markdown", + UpdatedAt: time.Now().Format(time.RFC3339), + }, + + // Default fallback for any missing content + "default": { + ID: "default", + SiteID: "demo", + Value: "[Enhanced Content]", + Type: "text", + UpdatedAt: time.Now().Format(time.RFC3339), + }, + } + + return &MockClient{data: data} +} + +// GetContent fetches a single content item by ID +func (m *MockClient) GetContent(siteID, contentID string) (*ContentItem, error) { + if item, exists := m.data[contentID]; exists && item.SiteID == siteID { + return &item, nil + } + + // Return fallback content for missing items during development + fallback := &ContentItem{ + ID: contentID, + SiteID: siteID, + Value: fmt.Sprintf("[Mock: %s]", contentID), + Type: "text", + UpdatedAt: time.Now().Format(time.RFC3339), + } + + return fallback, nil +} + +// GetBulkContent fetches multiple content items by IDs +func (m *MockClient) GetBulkContent(siteID string, contentIDs []string) (map[string]ContentItem, error) { + result := make(map[string]ContentItem) + + for _, id := range contentIDs { + item, err := m.GetContent(siteID, id) + if err != nil { + return nil, err + } + if item != nil { + result[id] = *item + } + } + + return result, nil +} + +// GetAllContent fetches all content for a site +func (m *MockClient) GetAllContent(siteID string) (map[string]ContentItem, error) { + result := make(map[string]ContentItem) + + for _, item := range m.data { + if item.SiteID == siteID { + result[item.ID] = item + } + } + + return result, nil +} diff --git a/insertr-cli/pkg/content/types.go b/insertr-cli/pkg/content/types.go new file mode 100644 index 0000000..b28270f --- /dev/null +++ b/insertr-cli/pkg/content/types.go @@ -0,0 +1,28 @@ +package content + +// ContentItem represents a piece of content from the database +type ContentItem struct { + ID string `json:"id"` + SiteID string `json:"site_id"` + Value string `json:"value"` + Type string `json:"type"` + UpdatedAt string `json:"updated_at"` +} + +// ContentResponse represents the API response structure +type ContentResponse struct { + Content []ContentItem `json:"content"` + Error string `json:"error,omitempty"` +} + +// ContentClient interface for content retrieval +type ContentClient interface { + // GetContent fetches content by ID + GetContent(siteID, contentID string) (*ContentItem, error) + + // GetBulkContent fetches multiple content items by IDs + GetBulkContent(siteID string, contentIDs []string) (map[string]ContentItem, error) + + // GetAllContent fetches all content for a site + GetAllContent(siteID string) (map[string]ContentItem, error) +}