refactor: implement unified binary architecture
🏗️ **Major Architecture Refactoring: Separate CLI + Server → Unified Binary** **Key Changes:** ✅ **Unified Binary**: Single 'insertr' binary with subcommands (enhance, serve) ✅ **Preserved Database Architecture**: Maintained sophisticated sqlc multi-DB setup ✅ **Smart Configuration**: Viper + YAML config with CLI flag precedence ✅ **Updated Build System**: Unified justfile, Air, and npm scripts **Command Structure:** - `insertr enhance [input-dir]` - Build-time content injection - `insertr serve` - HTTP API server (dev + production modes) - `insertr --config insertr.yaml` - YAML configuration support **Architecture Benefits:** - **Shared Database Layer**: Single source of truth for content models - **Flexible Workflows**: Local DB for dev, remote API for production - **Simple Deployment**: One binary for all use cases - **Better UX**: Consistent configuration across build and runtime **Preserved Features:** - Multi-database support (SQLite + PostgreSQL) - sqlc code generation and type safety - Version control system with rollback - Professional API endpoints - Content enhancement pipeline **Development Workflow:** - `just dev` - Full-stack development (API server + demo site) - `just serve` - API server only - `just enhance` - Build-time content injection - `air` - Hot reload unified binary **Migration:** Consolidated insertr-cli/ and insertr-server/ → unified root structure
This commit is contained in:
164
internal/content/client.go
Normal file
164
internal/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
|
||||
}
|
||||
216
internal/content/enhancer.go
Normal file
216
internal/content/enhancer.go
Normal file
@@ -0,0 +1,216 @@
|
||||
package content
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"golang.org/x/net/html"
|
||||
|
||||
"github.com/insertr/insertr/internal/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
|
||||
libraryScript := GetLibraryScript(false) // Use non-minified for development debugging
|
||||
e.injector.InjectEditorAssets(doc, true, libraryScript)
|
||||
|
||||
// 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)
|
||||
}
|
||||
236
internal/content/injector.go
Normal file
236
internal/content/injector.go
Normal file
@@ -0,0 +1,236 @@
|
||||
package content
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"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 and insertr class 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.addClass(node, "insertr")
|
||||
}
|
||||
|
||||
// InjectEditorAssets adds editor JavaScript to HTML document
|
||||
func (i *Injector) InjectEditorAssets(doc *html.Node, isDevelopment bool, libraryScript string) {
|
||||
// TODO: Implement script injection strategy when we have CDN hosting
|
||||
// For now, script injection is disabled since HTML files should include their own script tags
|
||||
// Future options:
|
||||
// 1. Inject CDN script tag: <script src="https://cdn.jsdelivr.net/npm/@insertr/lib@1.0.0/dist/insertr.js"></script>
|
||||
// 2. Inject local script tag for development: <script src="/insertr/insertr.js"></script>
|
||||
// 3. Continue with inline injection for certain use cases
|
||||
|
||||
// Currently disabled to avoid duplicate scripts
|
||||
return
|
||||
}
|
||||
|
||||
// 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,
|
||||
})
|
||||
}
|
||||
|
||||
// addClass safely adds a class to an HTML node
|
||||
func (i *Injector) addClass(node *html.Node, className string) {
|
||||
var classAttr *html.Attribute
|
||||
var classIndex int = -1
|
||||
|
||||
// Find existing class attribute
|
||||
for idx, attr := range node.Attr {
|
||||
if attr.Key == "class" {
|
||||
classAttr = &attr
|
||||
classIndex = idx
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
var classes []string
|
||||
if classAttr != nil {
|
||||
classes = strings.Fields(classAttr.Val)
|
||||
}
|
||||
|
||||
// Check if class already exists
|
||||
for _, class := range classes {
|
||||
if class == className {
|
||||
return // Class already exists
|
||||
}
|
||||
}
|
||||
|
||||
// Add new class
|
||||
classes = append(classes, className)
|
||||
newClassValue := strings.Join(classes, " ")
|
||||
|
||||
if classIndex >= 0 {
|
||||
// Update existing class attribute
|
||||
node.Attr[classIndex].Val = newClassValue
|
||||
} else {
|
||||
// Add new class attribute
|
||||
node.Attr = append(node.Attr, html.Attribute{
|
||||
Key: "class",
|
||||
Val: newClassValue,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
50
internal/content/library.go
Normal file
50
internal/content/library.go
Normal file
@@ -0,0 +1,50 @@
|
||||
package content
|
||||
|
||||
import (
|
||||
_ "embed"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// Embedded library assets
|
||||
//
|
||||
//go:embed assets/insertr.min.js
|
||||
var libraryMinJS string
|
||||
|
||||
//go:embed assets/insertr.js
|
||||
var libraryJS string
|
||||
|
||||
// GetLibraryScript returns the appropriate library version
|
||||
func GetLibraryScript(minified bool) string {
|
||||
if minified {
|
||||
return libraryMinJS
|
||||
}
|
||||
return libraryJS
|
||||
}
|
||||
|
||||
// GetLibraryVersion returns the current embedded library version
|
||||
func GetLibraryVersion() string {
|
||||
return "1.0.0"
|
||||
}
|
||||
|
||||
// GetLibraryURL returns the appropriate library URL for script injection
|
||||
func GetLibraryURL(minified bool, isDevelopment bool) string {
|
||||
if isDevelopment {
|
||||
// Local development URLs - relative to served content
|
||||
if minified {
|
||||
return "/insertr/insertr.min.js"
|
||||
}
|
||||
return "/insertr/insertr.js"
|
||||
}
|
||||
|
||||
// Production URLs - use CDN
|
||||
return GetLibraryCDNURL(minified)
|
||||
}
|
||||
|
||||
// GetLibraryCDNURL returns the CDN URL for production use
|
||||
func GetLibraryCDNURL(minified bool) string {
|
||||
version := GetLibraryVersion()
|
||||
if minified {
|
||||
return fmt.Sprintf("https://cdn.jsdelivr.net/npm/@insertr/lib@%s/dist/insertr.min.js", version)
|
||||
}
|
||||
return fmt.Sprintf("https://cdn.jsdelivr.net/npm/@insertr/lib@%s/dist/insertr.js", version)
|
||||
}
|
||||
138
internal/content/mock.go
Normal file
138
internal/content/mock.go
Normal file
@@ -0,0 +1,138 @@
|
||||
package content
|
||||
|
||||
import (
|
||||
"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 nil for missing content - this will preserve original HTML content
|
||||
return nil, 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
internal/content/types.go
Normal file
28
internal/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