Implement complete content injection and enhancement pipeline
- 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
This commit is contained in:
85
insertr-cli/assets/editor/insertr-editor.js
Normal file
85
insertr-cli/assets/editor/insertr-editor.js
Normal file
@@ -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);
|
||||
}
|
||||
})();
|
||||
76
insertr-cli/cmd/enhance.go
Normal file
76
insertr-cli/cmd/enhance.go
Normal file
@@ -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)
|
||||
}
|
||||
@@ -9,6 +9,8 @@ import (
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/insertr/cli/pkg/content"
|
||||
)
|
||||
|
||||
var servedevCmd = &cobra.Command{
|
||||
@@ -23,6 +25,8 @@ with live rebuilds via Air.`,
|
||||
var (
|
||||
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,
|
||||
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
|
||||
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)
|
||||
}
|
||||
|
||||
164
insertr-cli/pkg/content/client.go
Normal file
164
insertr-cli/pkg/content/client.go
Normal file
@@ -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
|
||||
}
|
||||
215
insertr-cli/pkg/content/enhancer.go
Normal file
215
insertr-cli/pkg/content/enhancer.go
Normal file
@@ -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)
|
||||
}
|
||||
204
insertr-cli/pkg/content/injector.go
Normal file
204
insertr-cli/pkg/content/injector.go
Normal file
@@ -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 <head> 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
|
||||
}
|
||||
147
insertr-cli/pkg/content/mock.go
Normal file
147
insertr-cli/pkg/content/mock.go
Normal file
@@ -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
|
||||
}
|
||||
28
insertr-cli/pkg/content/types.go
Normal file
28
insertr-cli/pkg/content/types.go
Normal file
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user