diff --git a/TODO.md b/TODO.md index 8527b45..b7b70da 100644 --- a/TODO.md +++ b/TODO.md @@ -7,7 +7,7 @@ ## ✅ What's Already Built & Working ### **Complete Foundation** -- ✅ **Go CLI Client** - Full REST API client with all CRUD operations (`insertr-cli/pkg/content/client.go`) +- ✅ **Go Content Client** - Full REST API client with all CRUD operations (`internal/content/client.go`) - ✅ **JavaScript API Client** - Browser client with same API endpoints (`lib/src/core/api-client.js`) - ✅ **Content Types** - Well-defined data structures (`ContentItem`, `ContentResponse`) - ✅ **Mock Backend** - Working development server with realistic test data @@ -111,23 +111,16 @@ Static Site Build ← CLI Enhancement ← Database Content ## 🗂️ **Next Steps: Server Implementation** -### **Files to Create** +### **✅ Implemented - Unified Binary Architecture** ``` -insertr-server/ # New HTTP server application -├── cmd/ -│ └── server/ -│ └── main.go # Server entry point -├── internal/ -│ ├── api/ -│ │ ├── handlers.go # HTTP handlers for content endpoints -│ │ └── middleware.go # Auth, CORS, logging middleware -│ ├── db/ -│ │ ├── sqlite.go # SQLite implementation -│ │ └── migrations/ # Database schema versions -│ └── models/ -│ └── content.go # Content model (matches existing ContentItem) -├── go.mod -└── go.sum +✅ COMPLETED: All server functionality integrated into unified binary +cmd/ +├── serve.go # Runtime API server command +└── enhance.go # Build-time enhancement command +internal/ +├── api/ # HTTP handlers and middleware +├── db/ # Multi-database layer with sqlc +└── content/ # Content management logic ``` ### **Files to Modify** diff --git a/insertr-cli/.air.toml b/insertr-cli/.air.toml deleted file mode 100644 index 7aa9f85..0000000 --- a/insertr-cli/.air.toml +++ /dev/null @@ -1,46 +0,0 @@ -root = "." -testdata_dir = "testdata" -tmp_dir = "tmp" - -[build] - args_bin = [] - bin = "./tmp/insertr" - cmd = "go build -o ./tmp/insertr ." - delay = 1000 - exclude_dir = ["tmp", "vendor", "testdata", "node_modules", "dist"] - exclude_file = [] - exclude_regex = ["_test.go"] - exclude_unchanged = false - follow_symlink = false - full_bin = "./tmp/insertr servedev -i ../demo-site -p 3000" - include_dir = ["../lib/src"] - include_ext = ["go", "tpl", "tmpl", "html", "js"] - include_file = [] - kill_delay = "0s" - log = "build-errors.log" - poll = false - poll_interval = 0 - post_cmd = [] - pre_cmd = ["./scripts/rebuild-library.sh"] - rerun = false - rerun_delay = 500 - send_interrupt = false - stop_on_root = false - -[color] - app = "" - build = "yellow" - main = "magenta" - runner = "green" - watcher = "cyan" - -[log] - main_only = false - time = false - -[misc] - clean_on_exit = true - -[screen] - clear_on_rebuild = false - keep_scroll = true \ No newline at end of file diff --git a/insertr-cli/cmd/enhance.go b/insertr-cli/cmd/enhance.go deleted file mode 100644 index 145d5c7..0000000 --- a/insertr-cli/cmd/enhance.go +++ /dev/null @@ -1,76 +0,0 @@ -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/parse.go b/insertr-cli/cmd/parse.go deleted file mode 100644 index eeb887a..0000000 --- a/insertr-cli/cmd/parse.go +++ /dev/null @@ -1,71 +0,0 @@ -package cmd - -import ( - "fmt" - "os" - "path/filepath" - - "github.com/insertr/cli/pkg/parser" - "github.com/spf13/cobra" -) - -var parseCmd = &cobra.Command{ - Use: "parse [input-dir]", - Short: "Parse HTML files and detect editable elements", - Long: `Parse HTML files in the specified directory and detect elements -with the 'insertr' class. This command analyzes the HTML structure -and reports what editable elements would be enhanced.`, - Args: cobra.ExactArgs(1), - Run: func(cmd *cobra.Command, args []string) { - inputDir := args[0] - - if _, err := os.Stat(inputDir); os.IsNotExist(err) { - fmt.Fprintf(os.Stderr, "Error: Directory %s does not exist\n", inputDir) - os.Exit(1) - } - - fmt.Printf("🔍 Parsing HTML files in: %s\n\n", inputDir) - - p := parser.New() - result, err := p.ParseDirectory(inputDir) - if err != nil { - fmt.Fprintf(os.Stderr, "Error parsing directory: %v\n", err) - os.Exit(1) - } - - printParseResults(result) - }, -} - -func printParseResults(result *parser.ParseResult) { - fmt.Printf("📊 Parse Results:\n") - fmt.Printf(" Files processed: %d\n", result.Stats.FilesProcessed) - fmt.Printf(" Elements found: %d\n", result.Stats.TotalElements) - fmt.Printf(" Existing IDs: %d\n", result.Stats.ExistingIDs) - fmt.Printf(" Generated IDs: %d\n", result.Stats.GeneratedIDs) - - if len(result.Stats.TypeBreakdown) > 0 { - fmt.Printf("\n📝 Content Types:\n") - for contentType, count := range result.Stats.TypeBreakdown { - fmt.Printf(" %s: %d\n", contentType, count) - } - } - - if len(result.Elements) > 0 { - fmt.Printf("\n🎯 Found Elements:\n") - for _, element := range result.Elements { - fmt.Printf(" %s <%s> id=%s type=%s\n", - filepath.Base(element.FilePath), - element.Tag, - element.ContentID, - element.Type) - } - } - - if len(result.Warnings) > 0 { - fmt.Printf("\n⚠️ Warnings:\n") - for _, warning := range result.Warnings { - fmt.Printf(" %s\n", warning) - } - } -} diff --git a/insertr-cli/cmd/root.go b/insertr-cli/cmd/root.go deleted file mode 100644 index 61b5b9f..0000000 --- a/insertr-cli/cmd/root.go +++ /dev/null @@ -1,31 +0,0 @@ -package cmd - -import ( - "fmt" - "os" - - "github.com/spf13/cobra" -) - -var rootCmd = &cobra.Command{ - Use: "insertr", - Short: "Insertr CLI - HTML enhancement for static sites", - Long: `Insertr CLI adds editing capabilities to static HTML sites by detecting -editable elements and injecting content management functionality. - -The tool parses HTML files, finds elements with the 'insertr' class, -and enhances them with editing capabilities while preserving -static site performance.`, - Version: "0.0.1", -} - -func Execute() { - if err := rootCmd.Execute(); err != nil { - fmt.Fprintln(os.Stderr, err) - os.Exit(1) - } -} - -func init() { - rootCmd.AddCommand(parseCmd) -} diff --git a/insertr-cli/cmd/servedev.go b/insertr-cli/cmd/servedev.go deleted file mode 100644 index 9f26e28..0000000 --- a/insertr-cli/cmd/servedev.go +++ /dev/null @@ -1,189 +0,0 @@ -package cmd - -import ( - "fmt" - "log" - "net/http" - "os" - "path/filepath" - "strings" - - "github.com/spf13/cobra" - - "github.com/insertr/cli/pkg/content" -) - -var servedevCmd = &cobra.Command{ - Use: "servedev", - Short: "Development server that parses and serves enhanced HTML files", - Long: `Servedev starts a development HTTP server that automatically parses HTML files -for insertr elements and serves the enhanced content. Perfect for development workflow -with live rebuilds via Air.`, - Run: runServedev, -} - -var ( - inputDir string - port int - useMockContent bool - devSiteID string -) - -func init() { - rootCmd.AddCommand(servedevCmd) - - servedevCmd.Flags().StringVarP(&inputDir, "input", "i", ".", "Input directory to serve") - servedevCmd.Flags().IntVarP(&port, "port", "p", 3000, "Port to serve on") - 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) { - // Resolve absolute path for input directory - absInputDir, err := filepath.Abs(inputDir) - if err != nil { - log.Fatalf("Error resolving input directory: %v", err) - } - - // Check if input directory exists - if _, err := os.Stat(absInputDir); os.IsNotExist(err) { - log.Fatalf("Input directory does not exist: %s", absInputDir) - } - - // 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 enhanced file server - fileServer := http.FileServer(&enhancedFileSystem{ - fs: http.Dir(absInputDir), - dir: absInputDir, - enhancer: content.NewEnhancer(client, devSiteID), - }) - - // 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 insertr library files - http.HandleFunc("/insertr/", func(w http.ResponseWriter, r *http.Request) { - assetPath := strings.TrimPrefix(r.URL.Path, "/insertr/") - serveLibraryAsset(w, r, assetPath) - }) - - // Handle all other requests with our enhanced file server - http.Handle("/", fileServer) - - // Start server - addr := fmt.Sprintf(":%d", port) - 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) -} - -// serveLibraryAsset serves the insertr library files from embedded assets -func serveLibraryAsset(w http.ResponseWriter, r *http.Request, assetPath string) { - w.Header().Set("Content-Type", "application/javascript") - - var script string - switch assetPath { - case "insertr.js": - script = content.GetLibraryScript(false) - case "insertr.min.js": - script = content.GetLibraryScript(true) - default: - http.NotFound(w, r) - return - } - - w.Write([]byte(script)) -} - -// 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) { - // For HTML files, enhance them on-the-fly - if strings.HasSuffix(name, ".html") { - fmt.Printf("📄 Enhancing HTML: %s\n", name) - return efs.serveEnhancedHTML(name) - } - - // 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/go.mod b/insertr-cli/go.mod deleted file mode 100644 index e9cfbff..0000000 --- a/insertr-cli/go.mod +++ /dev/null @@ -1,15 +0,0 @@ -module github.com/insertr/cli - -go 1.23.0 - -toolchain go1.24.6 - -require ( - github.com/spf13/cobra v1.8.0 - golang.org/x/net v0.43.0 -) - -require ( - github.com/inconshreveable/mousetrap v1.1.0 // indirect - github.com/spf13/pflag v1.0.5 // indirect -) diff --git a/insertr-cli/go.sum b/insertr-cli/go.sum deleted file mode 100644 index 3e3408e..0000000 --- a/insertr-cli/go.sum +++ /dev/null @@ -1,12 +0,0 @@ -github.com/cpuguy83/go-md2man/v2 v2.0.3/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= -github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= -github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= -github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= -github.com/spf13/cobra v1.8.0 h1:7aJaZx1B85qltLMc546zn58BxxfZdR/W22ej9CFoEf0= -github.com/spf13/cobra v1.8.0/go.mod h1:WXLWApfZ71AjXPya3WOlMsY9yMs7YeiHhFVlvLyhcho= -github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= -github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= -golang.org/x/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE= -golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg= -gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/insertr-cli/main.go b/insertr-cli/main.go deleted file mode 100644 index 77db8ba..0000000 --- a/insertr-cli/main.go +++ /dev/null @@ -1,9 +0,0 @@ -package main - -import ( - "github.com/insertr/cli/cmd" -) - -func main() { - cmd.Execute() -} diff --git a/insertr-cli/pkg/content/assets/insertr.js b/insertr-cli/pkg/content/assets/insertr.js deleted file mode 100644 index 81a4ea3..0000000 --- a/insertr-cli/pkg/content/assets/insertr.js +++ /dev/null @@ -1,4193 +0,0 @@ -var Insertr = (function () { - 'use strict'; - - /** - * InsertrCore - Core functionality for content management - */ - class InsertrCore { - constructor(options = {}) { - this.options = { - apiEndpoint: options.apiEndpoint || '/api/content', - siteId: options.siteId || 'default', - ...options - }; - } - - // Find all enhanced elements on the page with container expansion - findEnhancedElements() { - const directElements = document.querySelectorAll('.insertr'); - const expandedElements = []; - - directElements.forEach(element => { - if (this.isContainer(element) && !element.classList.contains('insertr-group')) { - // Container element (.insertr) - expand to viable children - const children = this.findViableChildren(element); - expandedElements.push(...children); - } else { - // Regular element or group (.insertr-group) - expandedElements.push(element); - } - }); - - return expandedElements; - } - - // Check if element is a container that should expand to children - isContainer(element) { - const containerTags = new Set([ - 'div', 'section', 'article', 'header', - 'footer', 'main', 'aside', 'nav' - ]); - - return containerTags.has(element.tagName.toLowerCase()); - } - - // Find viable children for editing (elements with only text content) - findViableChildren(containerElement) { - const viable = []; - - for (const child of containerElement.children) { - // Skip elements that already have .insertr class - if (child.classList.contains('insertr')) { - continue; - } - - // Skip self-closing elements - if (this.isSelfClosing(child)) { - continue; - } - - // Check if element has only text content (no nested HTML elements) - if (this.hasOnlyTextContent(child)) { - viable.push(child); - } - } - - return viable; - } - - // Check if element is viable for editing (allows simple formatting) - hasOnlyTextContent(element) { - // Allow elements with simple formatting tags - const allowedTags = new Set(['strong', 'b', 'em', 'i', 'a', 'span', 'code']); - - for (const child of element.children) { - const tagName = child.tagName.toLowerCase(); - - // If child is not an allowed formatting tag, reject - if (!allowedTags.has(tagName)) { - return false; - } - - // If formatting tag has nested complex elements, reject - if (child.children.length > 0) { - // Recursively check nested content isn't too complex - for (const nestedChild of child.children) { - const nestedTag = nestedChild.tagName.toLowerCase(); - if (!allowedTags.has(nestedTag)) { - return false; - } - } - } - } - - // Element has only text and/or simple formatting - this is viable - return element.textContent.trim().length > 0; - } - - // Check if element is self-closing - isSelfClosing(element) { - const selfClosingTags = new Set([ - 'img', 'input', 'br', 'hr', 'meta', 'link', - 'area', 'base', 'col', 'embed', 'source', 'track', 'wbr' - ]); - - return selfClosingTags.has(element.tagName.toLowerCase()); - } - - // Get element metadata - getElementMetadata(element) { - return { - contentId: element.getAttribute('data-content-id') || this.generateDeterministicId(element), - contentType: element.getAttribute('data-content-type') || this.detectContentType(element), - element: element - }; - } - - // Generate deterministic ID using same algorithm as CLI parser - generateTempId(element) { - return this.generateDeterministicId(element); - } - - // Generate deterministic content ID (matches CLI parser algorithm) - generateDeterministicId(element) { - const context = this.getSemanticContext(element); - const purpose = this.getPurpose(element); - const contentHash = this.getContentHash(element); - - return this.createBaseId(context, purpose, contentHash); - } - - // Get semantic context from parent elements (matches CLI algorithm) - getSemanticContext(element) { - let parent = element.parentElement; - - while (parent && parent.nodeType === Node.ELEMENT_NODE) { - const classList = Array.from(parent.classList); - - // Check for common semantic section classes - const semanticClasses = ['hero', 'services', 'nav', 'navbar', 'footer', 'about', 'contact', 'testimonial']; - for (const semanticClass of semanticClasses) { - if (classList.includes(semanticClass)) { - return semanticClass; - } - } - - // Check for semantic HTML elements - const tag = parent.tagName.toLowerCase(); - if (['nav', 'header', 'footer', 'main', 'aside'].includes(tag)) { - return tag; - } - - parent = parent.parentElement; - } - - return 'content'; - } - - // Get purpose/role of the element (matches CLI algorithm) - getPurpose(element) { - const tag = element.tagName.toLowerCase(); - const classList = Array.from(element.classList); - - // Check for specific CSS classes that indicate purpose - for (const className of classList) { - if (className.includes('title')) return 'title'; - if (className.includes('headline')) return 'headline'; - if (className.includes('description')) return 'description'; - if (className.includes('subtitle')) return 'subtitle'; - if (className.includes('cta')) return 'cta'; - if (className.includes('button')) return 'button'; - if (className.includes('logo')) return 'logo'; - if (className.includes('lead')) return 'lead'; - } - - // Infer purpose from HTML tag - switch (tag) { - case 'h1': - return 'title'; - case 'h2': - return 'subtitle'; - case 'h3': - case 'h4': - case 'h5': - case 'h6': - return 'heading'; - case 'p': - return 'text'; - case 'a': - return 'link'; - case 'button': - return 'button'; - default: - return 'content'; - } - } - - // Generate content hash (matches CLI algorithm) - getContentHash(element) { - const text = element.textContent.trim(); - - // Simple SHA-1 implementation for consistent hashing - return this.sha1(text).substring(0, 6); - } - - // Simple SHA-1 implementation (matches Go crypto/sha1) - sha1(str) { - // Convert string to UTF-8 bytes - const utf8Bytes = new TextEncoder().encode(str); - - // SHA-1 implementation - const h = [0x67452301, 0xEFCDAB89, 0x98BADCFE, 0x10325476, 0xC3D2E1F0]; - const messageLength = utf8Bytes.length; - - // Pre-processing: adding padding bits - const paddedMessage = new Uint8Array(Math.ceil((messageLength + 9) / 64) * 64); - paddedMessage.set(utf8Bytes); - paddedMessage[messageLength] = 0x80; - - // Append original length in bits as 64-bit big-endian integer - const bitLength = messageLength * 8; - const view = new DataView(paddedMessage.buffer); - view.setUint32(paddedMessage.length - 4, bitLength, false); // big-endian - - // Process message in 512-bit chunks - for (let chunk = 0; chunk < paddedMessage.length; chunk += 64) { - const w = new Array(80); - - // Break chunk into sixteen 32-bit words - for (let i = 0; i < 16; i++) { - w[i] = view.getUint32(chunk + i * 4, false); // big-endian - } - - // Extend the words - for (let i = 16; i < 80; i++) { - w[i] = this.leftRotate(w[i-3] ^ w[i-8] ^ w[i-14] ^ w[i-16], 1); - } - - // Initialize hash value for this chunk - let [a, b, c, d, e] = h; - - // Main loop - for (let i = 0; i < 80; i++) { - let f, k; - if (i < 20) { - f = (b & c) | ((~b) & d); - k = 0x5A827999; - } else if (i < 40) { - f = b ^ c ^ d; - k = 0x6ED9EBA1; - } else if (i < 60) { - f = (b & c) | (b & d) | (c & d); - k = 0x8F1BBCDC; - } else { - f = b ^ c ^ d; - k = 0xCA62C1D6; - } - - const temp = (this.leftRotate(a, 5) + f + e + k + w[i]) >>> 0; - e = d; - d = c; - c = this.leftRotate(b, 30); - b = a; - a = temp; - } - - // Add this chunk's hash to result - h[0] = (h[0] + a) >>> 0; - h[1] = (h[1] + b) >>> 0; - h[2] = (h[2] + c) >>> 0; - h[3] = (h[3] + d) >>> 0; - h[4] = (h[4] + e) >>> 0; - } - - // Produce the final hash value as a 160-bit hex string - return h.map(x => x.toString(16).padStart(8, '0')).join(''); - } - - // Left rotate function for SHA-1 - leftRotate(value, amount) { - return ((value << amount) | (value >>> (32 - amount))) >>> 0; - } - - // Create base ID from components (matches CLI algorithm) - createBaseId(context, purpose, contentHash) { - const parts = []; - - // Add context if meaningful - if (context !== 'content') { - parts.push(context); - } - - // Add purpose - parts.push(purpose); - - // Always add content hash for uniqueness - parts.push(contentHash); - - let baseId = parts.join('-'); - - // Clean up the ID - baseId = baseId.replace(/-+/g, '-'); - baseId = baseId.replace(/^-+|-+$/g, ''); - - // Ensure it's not empty - if (!baseId) { - baseId = `content-${contentHash}`; - } - - return baseId; - } - - // Detect content type for elements without data-content-type - detectContentType(element) { - const tag = element.tagName.toLowerCase(); - - if (element.classList.contains('insertr-group')) { - return 'markdown'; - } - - switch (tag) { - case 'h1': case 'h2': case 'h3': case 'h4': case 'h5': case 'h6': - return 'text'; - case 'p': - return 'textarea'; - case 'a': case 'button': - return 'link'; - case 'div': case 'section': - return 'markdown'; - default: - return 'text'; - } - } - - // Get all elements with their metadata, including group elements - getAllElements() { - const directElements = document.querySelectorAll('.insertr, .insertr-group'); - const processedElements = []; - - directElements.forEach(element => { - if (element.classList.contains('insertr-group')) { - // Group element - treat as single editable unit - processedElements.push(element); - } else if (this.isContainer(element)) { - // Container element - expand to children - const children = this.findViableChildren(element); - processedElements.push(...children); - } else { - // Regular element - processedElements.push(element); - } - }); - - return Array.from(processedElements).map(el => this.getElementMetadata(el)); - } - } - - /** - * marked v16.2.1 - a markdown parser - * Copyright (c) 2011-2025, Christopher Jeffrey. (MIT Licensed) - * https://github.com/markedjs/marked - */ - - /** - * DO NOT EDIT THIS FILE - * The code in this file is generated from files in ./src/ - */ - - function L(){return {async:!1,breaks:!1,extensions:null,gfm:!0,hooks:null,pedantic:!1,renderer:null,silent:!1,tokenizer:null,walkTokens:null}}var O=L();function H(l){O=l;}var E={exec:()=>null};function h(l,e=""){let t=typeof l=="string"?l:l.source,n={replace:(r,i)=>{let s=typeof i=="string"?i:i.source;return s=s.replace(m.caret,"$1"),t=t.replace(r,s),n},getRegex:()=>new RegExp(t,e)};return n}var m={codeRemoveIndent:/^(?: {1,4}| {0,3}\t)/gm,outputLinkReplace:/\\([\[\]])/g,indentCodeCompensation:/^(\s+)(?:```)/,beginningSpace:/^\s+/,endingHash:/#$/,startingSpaceChar:/^ /,endingSpaceChar:/ $/,nonSpaceChar:/[^ ]/,newLineCharGlobal:/\n/g,tabCharGlobal:/\t/g,multipleSpaceGlobal:/\s+/g,blankLine:/^[ \t]*$/,doubleBlankLine:/\n[ \t]*\n[ \t]*$/,blockquoteStart:/^ {0,3}>/,blockquoteSetextReplace:/\n {0,3}((?:=+|-+) *)(?=\n|$)/g,blockquoteSetextReplace2:/^ {0,3}>[ \t]?/gm,listReplaceTabs:/^\t+/,listReplaceNesting:/^ {1,4}(?=( {4})*[^ ])/g,listIsTask:/^\[[ xX]\] /,listReplaceTask:/^\[[ xX]\] +/,anyLine:/\n.*\n/,hrefBrackets:/^<(.*)>$/,tableDelimiter:/[:|]/,tableAlignChars:/^\||\| *$/g,tableRowBlankLine:/\n[ \t]*$/,tableAlignRight:/^ *-+: *$/,tableAlignCenter:/^ *:-+: *$/,tableAlignLeft:/^ *:-+ *$/,startATag:/^/i,startPreScriptTag:/^<(pre|code|kbd|script)(\s|>)/i,endPreScriptTag:/^<\/(pre|code|kbd|script)(\s|>)/i,startAngleBracket:/^$/,pedanticHrefTitle:/^([^'"]*[^\s])\s+(['"])(.*)\2/,unicodeAlphaNumeric:/[\p{L}\p{N}]/u,escapeTest:/[&<>"']/,escapeReplace:/[&<>"']/g,escapeTestNoEncode:/[<>"']|&(?!(#\d{1,7}|#[Xx][a-fA-F0-9]{1,6}|\w+);)/,escapeReplaceNoEncode:/[<>"']|&(?!(#\d{1,7}|#[Xx][a-fA-F0-9]{1,6}|\w+);)/g,unescapeTest:/&(#(?:\d+)|(?:#x[0-9A-Fa-f]+)|(?:\w+));?/ig,caret:/(^|[^\[])\^/g,percentDecode:/%25/g,findPipe:/\|/g,splitPipe:/ \|/,slashPipe:/\\\|/g,carriageReturn:/\r\n|\r/g,spaceLine:/^ +$/gm,notSpaceStart:/^\S*/,endingNewline:/\n$/,listItemRegex:l=>new RegExp(`^( {0,3}${l})((?:[ ][^\\n]*)?(?:\\n|$))`),nextBulletRegex:l=>new RegExp(`^ {0,${Math.min(3,l-1)}}(?:[*+-]|\\d{1,9}[.)])((?:[ ][^\\n]*)?(?:\\n|$))`),hrRegex:l=>new RegExp(`^ {0,${Math.min(3,l-1)}}((?:- *){3,}|(?:_ *){3,}|(?:\\* *){3,})(?:\\n+|$)`),fencesBeginRegex:l=>new RegExp(`^ {0,${Math.min(3,l-1)}}(?:\`\`\`|~~~)`),headingBeginRegex:l=>new RegExp(`^ {0,${Math.min(3,l-1)}}#`),htmlBeginRegex:l=>new RegExp(`^ {0,${Math.min(3,l-1)}}<(?:[a-z].*>|!--)`,"i")},xe=/^(?:[ \t]*(?:\n|$))+/,be=/^((?: {4}| {0,3}\t)[^\n]+(?:\n(?:[ \t]*(?:\n|$))*)?)+/,Re=/^ {0,3}(`{3,}(?=[^`\n]*(?:\n|$))|~{3,})([^\n]*)(?:\n|$)(?:|([\s\S]*?)(?:\n|$))(?: {0,3}\1[~`]* *(?=\n|$)|$)/,C=/^ {0,3}((?:-[\t ]*){3,}|(?:_[ \t]*){3,}|(?:\*[ \t]*){3,})(?:\n+|$)/,Oe=/^ {0,3}(#{1,6})(?=\s|$)(.*)(?:\n+|$)/,j=/(?:[*+-]|\d{1,9}[.)])/,se=/^(?!bull |blockCode|fences|blockquote|heading|html|table)((?:.|\n(?!\s*?\n|bull |blockCode|fences|blockquote|heading|html|table))+?)\n {0,3}(=+|-+) *(?:\n+|$)/,ie=h(se).replace(/bull/g,j).replace(/blockCode/g,/(?: {4}| {0,3}\t)/).replace(/fences/g,/ {0,3}(?:`{3,}|~{3,})/).replace(/blockquote/g,/ {0,3}>/).replace(/heading/g,/ {0,3}#{1,6}/).replace(/html/g,/ {0,3}<[^\n>]+>\n/).replace(/\|table/g,"").getRegex(),Te=h(se).replace(/bull/g,j).replace(/blockCode/g,/(?: {4}| {0,3}\t)/).replace(/fences/g,/ {0,3}(?:`{3,}|~{3,})/).replace(/blockquote/g,/ {0,3}>/).replace(/heading/g,/ {0,3}#{1,6}/).replace(/html/g,/ {0,3}<[^\n>]+>\n/).replace(/table/g,/ {0,3}\|?(?:[:\- ]*\|)+[\:\- ]*\n/).getRegex(),F=/^([^\n]+(?:\n(?!hr|heading|lheading|blockquote|fences|list|html|table| +\n)[^\n]+)*)/,we=/^[^\n]+/,Q=/(?!\s*\])(?:\\[\s\S]|[^\[\]\\])+/,ye=h(/^ {0,3}\[(label)\]: *(?:\n[ \t]*)?([^<\s][^\s]*|<.*?>)(?:(?: +(?:\n[ \t]*)?| *\n[ \t]*)(title))? *(?:\n+|$)/).replace("label",Q).replace("title",/(?:"(?:\\"?|[^"\\])*"|'[^'\n]*(?:\n[^'\n]+)*\n?'|\([^()]*\))/).getRegex(),Pe=h(/^( {0,3}bull)([ \t][^\n]+?)?(?:\n|$)/).replace(/bull/g,j).getRegex(),v="address|article|aside|base|basefont|blockquote|body|caption|center|col|colgroup|dd|details|dialog|dir|div|dl|dt|fieldset|figcaption|figure|footer|form|frame|frameset|h[1-6]|head|header|hr|html|iframe|legend|li|link|main|menu|menuitem|meta|nav|noframes|ol|optgroup|option|p|param|search|section|summary|table|tbody|td|tfoot|th|thead|title|tr|track|ul",U=/|$))/,Se=h("^ {0,3}(?:<(script|pre|style|textarea)[\\s>][\\s\\S]*?(?:[^\\n]*\\n+|$)|comment[^\\n]*(\\n+|$)|<\\?[\\s\\S]*?(?:\\?>\\n*|$)|\\n*|$)|\\n*|$)|)[\\s\\S]*?(?:(?:\\n[ ]*)+\\n|$)|<(?!script|pre|style|textarea)([a-z][\\w-]*)(?:attribute)*? */?>(?=[ \\t]*(?:\\n|$))[\\s\\S]*?(?:(?:\\n[ ]*)+\\n|$)|(?=[ \\t]*(?:\\n|$))[\\s\\S]*?(?:(?:\\n[ ]*)+\\n|$))","i").replace("comment",U).replace("tag",v).replace("attribute",/ +[a-zA-Z:_][\w.:-]*(?: *= *"[^"\n]*"| *= *'[^'\n]*'| *= *[^\s"'=<>`]+)?/).getRegex(),oe=h(F).replace("hr",C).replace("heading"," {0,3}#{1,6}(?:\\s|$)").replace("|lheading","").replace("|table","").replace("blockquote"," {0,3}>").replace("fences"," {0,3}(?:`{3,}(?=[^`\\n]*\\n)|~{3,})[^\\n]*\\n").replace("list"," {0,3}(?:[*+-]|1[.)]) ").replace("html",")|<(?:script|pre|style|textarea|!--)").replace("tag",v).getRegex(),$e=h(/^( {0,3}> ?(paragraph|[^\n]*)(?:\n|$))+/).replace("paragraph",oe).getRegex(),K={blockquote:$e,code:be,def:ye,fences:Re,heading:Oe,hr:C,html:Se,lheading:ie,list:Pe,newline:xe,paragraph:oe,table:E,text:we},re=h("^ *([^\\n ].*)\\n {0,3}((?:\\| *)?:?-+:? *(?:\\| *:?-+:? *)*(?:\\| *)?)(?:\\n((?:(?! *\\n|hr|heading|blockquote|code|fences|list|html).*(?:\\n|$))*)\\n*|$)").replace("hr",C).replace("heading"," {0,3}#{1,6}(?:\\s|$)").replace("blockquote"," {0,3}>").replace("code","(?: {4}| {0,3} )[^\\n]").replace("fences"," {0,3}(?:`{3,}(?=[^`\\n]*\\n)|~{3,})[^\\n]*\\n").replace("list"," {0,3}(?:[*+-]|1[.)]) ").replace("html",")|<(?:script|pre|style|textarea|!--)").replace("tag",v).getRegex(),_e={...K,lheading:Te,table:re,paragraph:h(F).replace("hr",C).replace("heading"," {0,3}#{1,6}(?:\\s|$)").replace("|lheading","").replace("table",re).replace("blockquote"," {0,3}>").replace("fences"," {0,3}(?:`{3,}(?=[^`\\n]*\\n)|~{3,})[^\\n]*\\n").replace("list"," {0,3}(?:[*+-]|1[.)]) ").replace("html",")|<(?:script|pre|style|textarea|!--)").replace("tag",v).getRegex()},Le={...K,html:h(`^ *(?:comment *(?:\\n|\\s*$)|<(tag)[\\s\\S]+? *(?:\\n{2,}|\\s*$)|\\s]*)*?/?> *(?:\\n{2,}|\\s*$))`).replace("comment",U).replace(/tag/g,"(?!(?:a|em|strong|small|s|cite|q|dfn|abbr|data|time|code|var|samp|kbd|sub|sup|i|b|u|mark|ruby|rt|rp|bdi|bdo|span|br|wbr|ins|del|img)\\b)\\w+(?!:|[^\\w\\s@]*@)\\b").getRegex(),def:/^ *\[([^\]]+)\]: *]+)>?(?: +(["(][^\n]+[")]))? *(?:\n+|$)/,heading:/^(#{1,6})(.*)(?:\n+|$)/,fences:E,lheading:/^(.+?)\n {0,3}(=+|-+) *(?:\n+|$)/,paragraph:h(F).replace("hr",C).replace("heading",` *#{1,6} *[^ -]`).replace("lheading",ie).replace("|table","").replace("blockquote"," {0,3}>").replace("|fences","").replace("|list","").replace("|html","").replace("|tag","").getRegex()},Me=/^\\([!"#$%&'()*+,\-./:;<=>?@\[\]\\^_`{|}~])/,ze=/^(`+)([^`]|[^`][\s\S]*?[^`])\1(?!`)/,ae=/^( {2,}|\\)\n(?!\s*$)/,Ae=/^(`+|[^`])(?:(?= {2,}\n)|[\s\S]*?(?:(?=[\\]*?>/g,pe=/^(?:\*+(?:((?!\*)punct)|[^\s*]))|^_+(?:((?!_)punct)|([^\s_]))/,qe=h(pe,"u").replace(/punct/g,D).getRegex(),ve=h(pe,"u").replace(/punct/g,ue).getRegex(),ce="^[^_*]*?__[^_*]*?\\*[^_*]*?(?=__)|[^*]+(?=[^*])|(?!\\*)punct(\\*+)(?=[\\s]|$)|notPunctSpace(\\*+)(?!\\*)(?=punctSpace|$)|(?!\\*)punctSpace(\\*+)(?=notPunctSpace)|[\\s](\\*+)(?!\\*)(?=punct)|(?!\\*)punct(\\*+)(?!\\*)(?=punct)|notPunctSpace(\\*+)(?=notPunctSpace)",De=h(ce,"gu").replace(/notPunctSpace/g,le).replace(/punctSpace/g,W).replace(/punct/g,D).getRegex(),Ze=h(ce,"gu").replace(/notPunctSpace/g,Ie).replace(/punctSpace/g,Ce).replace(/punct/g,ue).getRegex(),Ge=h("^[^_*]*?\\*\\*[^_*]*?_[^_*]*?(?=\\*\\*)|[^_]+(?=[^_])|(?!_)punct(_+)(?=[\\s]|$)|notPunctSpace(_+)(?!_)(?=punctSpace|$)|(?!_)punctSpace(_+)(?=notPunctSpace)|[\\s](_+)(?!_)(?=punct)|(?!_)punct(_+)(?!_)(?=punct)","gu").replace(/notPunctSpace/g,le).replace(/punctSpace/g,W).replace(/punct/g,D).getRegex(),He=h(/\\(punct)/,"gu").replace(/punct/g,D).getRegex(),Ne=h(/^<(scheme:[^\s\x00-\x1f<>]*|email)>/).replace("scheme",/[a-zA-Z][a-zA-Z0-9+.-]{1,31}/).replace("email",/[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+(@)[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)+(?![-_])/).getRegex(),je=h(U).replace("(?:-->|$)","-->").getRegex(),Fe=h("^comment|^|^<[a-zA-Z][\\w-]*(?:attribute)*?\\s*/?>|^<\\?[\\s\\S]*?\\?>|^|^").replace("comment",je).replace("attribute",/\s+[a-zA-Z:_][\w.:-]*(?:\s*=\s*"[^"]*"|\s*=\s*'[^']*'|\s*=\s*[^\s"'=<>`]+)?/).getRegex(),q=/(?:\[(?:\\[\s\S]|[^\[\]\\])*\]|\\[\s\S]|`[^`]*`|[^\[\]\\`])*?/,Qe=h(/^!?\[(label)\]\(\s*(href)(?:(?:[ \t]*(?:\n[ \t]*)?)(title))?\s*\)/).replace("label",q).replace("href",/<(?:\\.|[^\n<>\\])+>|[^ \t\n\x00-\x1f]*/).replace("title",/"(?:\\"?|[^"\\])*"|'(?:\\'?|[^'\\])*'|\((?:\\\)?|[^)\\])*\)/).getRegex(),he=h(/^!?\[(label)\]\[(ref)\]/).replace("label",q).replace("ref",Q).getRegex(),de=h(/^!?\[(ref)\](?:\[\])?/).replace("ref",Q).getRegex(),Ue=h("reflink|nolink(?!\\()","g").replace("reflink",he).replace("nolink",de).getRegex(),X={_backpedal:E,anyPunctuation:He,autolink:Ne,blockSkip:Be,br:ae,code:ze,del:E,emStrongLDelim:qe,emStrongRDelimAst:De,emStrongRDelimUnd:Ge,escape:Me,link:Qe,nolink:de,punctuation:Ee,reflink:he,reflinkSearch:Ue,tag:Fe,text:Ae,url:E},Ke={...X,link:h(/^!?\[(label)\]\((.*?)\)/).replace("label",q).getRegex(),reflink:h(/^!?\[(label)\]\s*\[([^\]]*)\]/).replace("label",q).getRegex()},N={...X,emStrongRDelimAst:Ze,emStrongLDelim:ve,url:h(/^((?:ftp|https?):\/\/|www\.)(?:[a-zA-Z0-9\-]+\.?)+[^\s<]*|^email/,"i").replace("email",/[A-Za-z0-9._+-]+(@)[a-zA-Z0-9-_]+(?:\.[a-zA-Z0-9-_]*[a-zA-Z0-9])+(?![-_])/).getRegex(),_backpedal:/(?:[^?!.,:;*_'"~()&]+|\([^)]*\)|&(?![a-zA-Z0-9]+;$)|[?!.,:;*_'"~)]+(?!$))+/,del:/^(~~?)(?=[^\s~])((?:\\[\s\S]|[^\\])*?(?:\\[\s\S]|[^\s~\\]))\1(?=[^~]|$)/,text:/^([`~]+|[^`~])(?:(?= {2,}\n)|(?=[a-zA-Z0-9.!#$%&'*+\/=?_`{\|}~-]+@)|[\s\S]*?(?:(?=[\\":">",'"':""","'":"'"},ke=l=>Xe[l];function w(l,e){if(e){if(m.escapeTest.test(l))return l.replace(m.escapeReplace,ke)}else if(m.escapeTestNoEncode.test(l))return l.replace(m.escapeReplaceNoEncode,ke);return l}function J(l){try{l=encodeURI(l).replace(m.percentDecode,"%");}catch{return null}return l}function V(l,e){let t=l.replace(m.findPipe,(i,s,o)=>{let a=!1,u=s;for(;--u>=0&&o[u]==="\\";)a=!a;return a?"|":" |"}),n=t.split(m.splitPipe),r=0;if(n[0].trim()||n.shift(),n.length>0&&!n.at(-1)?.trim()&&n.pop(),e)if(n.length>e)n.splice(e);else for(;n.length0?-2:-1}function fe(l,e,t,n,r){let i=e.href,s=e.title||null,o=l[1].replace(r.other.outputLinkReplace,"$1");n.state.inLink=!0;let a={type:l[0].charAt(0)==="!"?"image":"link",raw:t,href:i,title:s,text:o,tokens:n.inlineTokens(o)};return n.state.inLink=!1,a}function Je(l,e,t){let n=l.match(t.other.indentCodeCompensation);if(n===null)return e;let r=n[1];return e.split(` -`).map(i=>{let s=i.match(t.other.beginningSpace);if(s===null)return i;let[o]=s;return o.length>=r.length?i.slice(r.length):i}).join(` -`)}var y=class{options;rules;lexer;constructor(e){this.options=e||O;}space(e){let t=this.rules.block.newline.exec(e);if(t&&t[0].length>0)return {type:"space",raw:t[0]}}code(e){let t=this.rules.block.code.exec(e);if(t){let n=t[0].replace(this.rules.other.codeRemoveIndent,"");return {type:"code",raw:t[0],codeBlockStyle:"indented",text:this.options.pedantic?n:z(n,` -`)}}}fences(e){let t=this.rules.block.fences.exec(e);if(t){let n=t[0],r=Je(n,t[3]||"",this.rules);return {type:"code",raw:n,lang:t[2]?t[2].trim().replace(this.rules.inline.anyPunctuation,"$1"):t[2],text:r}}}heading(e){let t=this.rules.block.heading.exec(e);if(t){let n=t[2].trim();if(this.rules.other.endingHash.test(n)){let r=z(n,"#");(this.options.pedantic||!r||this.rules.other.endingSpaceChar.test(r))&&(n=r.trim());}return {type:"heading",raw:t[0],depth:t[1].length,text:n,tokens:this.lexer.inline(n)}}}hr(e){let t=this.rules.block.hr.exec(e);if(t)return {type:"hr",raw:z(t[0],` -`)}}blockquote(e){let t=this.rules.block.blockquote.exec(e);if(t){let n=z(t[0],` -`).split(` -`),r="",i="",s=[];for(;n.length>0;){let o=!1,a=[],u;for(u=0;u1,i={type:"list",raw:"",ordered:r,start:r?+n.slice(0,-1):"",loose:!1,items:[]};n=r?`\\d{1,9}\\${n.slice(-1)}`:`\\${n}`,this.options.pedantic&&(n=r?n:"[*+-]");let s=this.rules.other.listItemRegex(n),o=!1;for(;e;){let u=!1,p="",c="";if(!(t=s.exec(e))||this.rules.block.hr.test(e))break;p=t[0],e=e.substring(p.length);let f=t[2].split(` -`,1)[0].replace(this.rules.other.listReplaceTabs,Z=>" ".repeat(3*Z.length)),k=e.split(` -`,1)[0],x=!f.trim(),g=0;if(this.options.pedantic?(g=2,c=f.trimStart()):x?g=t[1].length+1:(g=t[2].search(this.rules.other.nonSpaceChar),g=g>4?1:g,c=f.slice(g),g+=t[1].length),x&&this.rules.other.blankLine.test(k)&&(p+=k+` -`,e=e.substring(k.length+1),u=!0),!u){let Z=this.rules.other.nextBulletRegex(g),ee=this.rules.other.hrRegex(g),te=this.rules.other.fencesBeginRegex(g),ne=this.rules.other.headingBeginRegex(g),me=this.rules.other.htmlBeginRegex(g);for(;e;){let G=e.split(` -`,1)[0],A;if(k=G,this.options.pedantic?(k=k.replace(this.rules.other.listReplaceNesting," "),A=k):A=k.replace(this.rules.other.tabCharGlobal," "),te.test(k)||ne.test(k)||me.test(k)||Z.test(k)||ee.test(k))break;if(A.search(this.rules.other.nonSpaceChar)>=g||!k.trim())c+=` -`+A.slice(g);else {if(x||f.replace(this.rules.other.tabCharGlobal," ").search(this.rules.other.nonSpaceChar)>=4||te.test(f)||ne.test(f)||ee.test(f))break;c+=` -`+k;}!x&&!k.trim()&&(x=!0),p+=G+` -`,e=e.substring(G.length+1),f=A.slice(g);}}i.loose||(o?i.loose=!0:this.rules.other.doubleBlankLine.test(p)&&(o=!0));let T=null,Y;this.options.gfm&&(T=this.rules.other.listIsTask.exec(c),T&&(Y=T[0]!=="[ ] ",c=c.replace(this.rules.other.listReplaceTask,""))),i.items.push({type:"list_item",raw:p,task:!!T,checked:Y,loose:!1,text:c,tokens:[]}),i.raw+=p;}let a=i.items.at(-1);if(a)a.raw=a.raw.trimEnd(),a.text=a.text.trimEnd();else return;i.raw=i.raw.trimEnd();for(let u=0;uf.type==="space"),c=p.length>0&&p.some(f=>this.rules.other.anyLine.test(f.raw));i.loose=c;}if(i.loose)for(let u=0;u({text:a,tokens:this.lexer.inline(a),header:!1,align:s.align[u]})));return s}}lheading(e){let t=this.rules.block.lheading.exec(e);if(t)return {type:"heading",raw:t[0],depth:t[2].charAt(0)==="="?1:2,text:t[1],tokens:this.lexer.inline(t[1])}}paragraph(e){let t=this.rules.block.paragraph.exec(e);if(t){let n=t[1].charAt(t[1].length-1)===` -`?t[1].slice(0,-1):t[1];return {type:"paragraph",raw:t[0],text:n,tokens:this.lexer.inline(n)}}}text(e){let t=this.rules.block.text.exec(e);if(t)return {type:"text",raw:t[0],text:t[0],tokens:this.lexer.inline(t[0])}}escape(e){let t=this.rules.inline.escape.exec(e);if(t)return {type:"escape",raw:t[0],text:t[1]}}tag(e){let t=this.rules.inline.tag.exec(e);if(t)return !this.lexer.state.inLink&&this.rules.other.startATag.test(t[0])?this.lexer.state.inLink=!0:this.lexer.state.inLink&&this.rules.other.endATag.test(t[0])&&(this.lexer.state.inLink=!1),!this.lexer.state.inRawBlock&&this.rules.other.startPreScriptTag.test(t[0])?this.lexer.state.inRawBlock=!0:this.lexer.state.inRawBlock&&this.rules.other.endPreScriptTag.test(t[0])&&(this.lexer.state.inRawBlock=!1),{type:"html",raw:t[0],inLink:this.lexer.state.inLink,inRawBlock:this.lexer.state.inRawBlock,block:!1,text:t[0]}}link(e){let t=this.rules.inline.link.exec(e);if(t){let n=t[2].trim();if(!this.options.pedantic&&this.rules.other.startAngleBracket.test(n)){if(!this.rules.other.endAngleBracket.test(n))return;let s=z(n.slice(0,-1),"\\");if((n.length-s.length)%2===0)return}else {let s=ge(t[2],"()");if(s===-2)return;if(s>-1){let a=(t[0].indexOf("!")===0?5:4)+t[1].length+s;t[2]=t[2].substring(0,s),t[0]=t[0].substring(0,a).trim(),t[3]="";}}let r=t[2],i="";if(this.options.pedantic){let s=this.rules.other.pedanticHrefTitle.exec(r);s&&(r=s[1],i=s[3]);}else i=t[3]?t[3].slice(1,-1):"";return r=r.trim(),this.rules.other.startAngleBracket.test(r)&&(this.options.pedantic&&!this.rules.other.endAngleBracket.test(n)?r=r.slice(1):r=r.slice(1,-1)),fe(t,{href:r&&r.replace(this.rules.inline.anyPunctuation,"$1"),title:i&&i.replace(this.rules.inline.anyPunctuation,"$1")},t[0],this.lexer,this.rules)}}reflink(e,t){let n;if((n=this.rules.inline.reflink.exec(e))||(n=this.rules.inline.nolink.exec(e))){let r=(n[2]||n[1]).replace(this.rules.other.multipleSpaceGlobal," "),i=t[r.toLowerCase()];if(!i){let s=n[0].charAt(0);return {type:"text",raw:s,text:s}}return fe(n,i,n[0],this.lexer,this.rules)}}emStrong(e,t,n=""){let r=this.rules.inline.emStrongLDelim.exec(e);if(!r||r[3]&&n.match(this.rules.other.unicodeAlphaNumeric))return;if(!(r[1]||r[2]||"")||!n||this.rules.inline.punctuation.exec(n)){let s=[...r[0]].length-1,o,a,u=s,p=0,c=r[0][0]==="*"?this.rules.inline.emStrongRDelimAst:this.rules.inline.emStrongRDelimUnd;for(c.lastIndex=0,t=t.slice(-1*e.length+s);(r=c.exec(t))!=null;){if(o=r[1]||r[2]||r[3]||r[4]||r[5]||r[6],!o)continue;if(a=[...o].length,r[3]||r[4]){u+=a;continue}else if((r[5]||r[6])&&s%3&&!((s+a)%3)){p+=a;continue}if(u-=a,u>0)continue;a=Math.min(a,a+u+p);let f=[...r[0]][0].length,k=e.slice(0,s+r.index+f+a);if(Math.min(s,a)%2){let g=k.slice(1,-1);return {type:"em",raw:k,text:g,tokens:this.lexer.inlineTokens(g)}}let x=k.slice(2,-2);return {type:"strong",raw:k,text:x,tokens:this.lexer.inlineTokens(x)}}}}codespan(e){let t=this.rules.inline.code.exec(e);if(t){let n=t[2].replace(this.rules.other.newLineCharGlobal," "),r=this.rules.other.nonSpaceChar.test(n),i=this.rules.other.startingSpaceChar.test(n)&&this.rules.other.endingSpaceChar.test(n);return r&&i&&(n=n.substring(1,n.length-1)),{type:"codespan",raw:t[0],text:n}}}br(e){let t=this.rules.inline.br.exec(e);if(t)return {type:"br",raw:t[0]}}del(e){let t=this.rules.inline.del.exec(e);if(t)return {type:"del",raw:t[0],text:t[2],tokens:this.lexer.inlineTokens(t[2])}}autolink(e){let t=this.rules.inline.autolink.exec(e);if(t){let n,r;return t[2]==="@"?(n=t[1],r="mailto:"+n):(n=t[1],r=n),{type:"link",raw:t[0],text:n,href:r,tokens:[{type:"text",raw:n,text:n}]}}}url(e){let t;if(t=this.rules.inline.url.exec(e)){let n,r;if(t[2]==="@")n=t[0],r="mailto:"+n;else {let i;do i=t[0],t[0]=this.rules.inline._backpedal.exec(t[0])?.[0]??"";while(i!==t[0]);n=t[0],t[1]==="www."?r="http://"+t[0]:r=t[0];}return {type:"link",raw:t[0],text:n,href:r,tokens:[{type:"text",raw:n,text:n}]}}}inlineText(e){let t=this.rules.inline.text.exec(e);if(t){let n=this.lexer.state.inRawBlock;return {type:"text",raw:t[0],text:t[0],escaped:n}}}};var b=class l{tokens;options;state;tokenizer;inlineQueue;constructor(e){this.tokens=[],this.tokens.links=Object.create(null),this.options=e||O,this.options.tokenizer=this.options.tokenizer||new y,this.tokenizer=this.options.tokenizer,this.tokenizer.options=this.options,this.tokenizer.lexer=this,this.inlineQueue=[],this.state={inLink:!1,inRawBlock:!1,top:!0};let t={other:m,block:I.normal,inline:M.normal};this.options.pedantic?(t.block=I.pedantic,t.inline=M.pedantic):this.options.gfm&&(t.block=I.gfm,this.options.breaks?t.inline=M.breaks:t.inline=M.gfm),this.tokenizer.rules=t;}static get rules(){return {block:I,inline:M}}static lex(e,t){return new l(t).lex(e)}static lexInline(e,t){return new l(t).inlineTokens(e)}lex(e){e=e.replace(m.carriageReturn,` -`),this.blockTokens(e,this.tokens);for(let t=0;t(r=s.call({lexer:this},e,t))?(e=e.substring(r.raw.length),t.push(r),!0):!1))continue;if(r=this.tokenizer.space(e)){e=e.substring(r.raw.length);let s=t.at(-1);r.raw.length===1&&s!==void 0?s.raw+=` -`:t.push(r);continue}if(r=this.tokenizer.code(e)){e=e.substring(r.raw.length);let s=t.at(-1);s?.type==="paragraph"||s?.type==="text"?(s.raw+=(s.raw.endsWith(` -`)?"":` -`)+r.raw,s.text+=` -`+r.text,this.inlineQueue.at(-1).src=s.text):t.push(r);continue}if(r=this.tokenizer.fences(e)){e=e.substring(r.raw.length),t.push(r);continue}if(r=this.tokenizer.heading(e)){e=e.substring(r.raw.length),t.push(r);continue}if(r=this.tokenizer.hr(e)){e=e.substring(r.raw.length),t.push(r);continue}if(r=this.tokenizer.blockquote(e)){e=e.substring(r.raw.length),t.push(r);continue}if(r=this.tokenizer.list(e)){e=e.substring(r.raw.length),t.push(r);continue}if(r=this.tokenizer.html(e)){e=e.substring(r.raw.length),t.push(r);continue}if(r=this.tokenizer.def(e)){e=e.substring(r.raw.length);let s=t.at(-1);s?.type==="paragraph"||s?.type==="text"?(s.raw+=(s.raw.endsWith(` -`)?"":` -`)+r.raw,s.text+=` -`+r.raw,this.inlineQueue.at(-1).src=s.text):this.tokens.links[r.tag]||(this.tokens.links[r.tag]={href:r.href,title:r.title},t.push(r));continue}if(r=this.tokenizer.table(e)){e=e.substring(r.raw.length),t.push(r);continue}if(r=this.tokenizer.lheading(e)){e=e.substring(r.raw.length),t.push(r);continue}let i=e;if(this.options.extensions?.startBlock){let s=1/0,o=e.slice(1),a;this.options.extensions.startBlock.forEach(u=>{a=u.call({lexer:this},o),typeof a=="number"&&a>=0&&(s=Math.min(s,a));}),s<1/0&&s>=0&&(i=e.substring(0,s+1));}if(this.state.top&&(r=this.tokenizer.paragraph(i))){let s=t.at(-1);n&&s?.type==="paragraph"?(s.raw+=(s.raw.endsWith(` -`)?"":` -`)+r.raw,s.text+=` -`+r.text,this.inlineQueue.pop(),this.inlineQueue.at(-1).src=s.text):t.push(r),n=i.length!==e.length,e=e.substring(r.raw.length);continue}if(r=this.tokenizer.text(e)){e=e.substring(r.raw.length);let s=t.at(-1);s?.type==="text"?(s.raw+=(s.raw.endsWith(` -`)?"":` -`)+r.raw,s.text+=` -`+r.text,this.inlineQueue.pop(),this.inlineQueue.at(-1).src=s.text):t.push(r);continue}if(e){let s="Infinite loop on byte: "+e.charCodeAt(0);if(this.options.silent){console.error(s);break}else throw new Error(s)}}return this.state.top=!0,t}inline(e,t=[]){return this.inlineQueue.push({src:e,tokens:t}),t}inlineTokens(e,t=[]){let n=e,r=null;if(this.tokens.links){let o=Object.keys(this.tokens.links);if(o.length>0)for(;(r=this.tokenizer.rules.inline.reflinkSearch.exec(n))!=null;)o.includes(r[0].slice(r[0].lastIndexOf("[")+1,-1))&&(n=n.slice(0,r.index)+"["+"a".repeat(r[0].length-2)+"]"+n.slice(this.tokenizer.rules.inline.reflinkSearch.lastIndex));}for(;(r=this.tokenizer.rules.inline.anyPunctuation.exec(n))!=null;)n=n.slice(0,r.index)+"++"+n.slice(this.tokenizer.rules.inline.anyPunctuation.lastIndex);for(;(r=this.tokenizer.rules.inline.blockSkip.exec(n))!=null;)n=n.slice(0,r.index)+"["+"a".repeat(r[0].length-2)+"]"+n.slice(this.tokenizer.rules.inline.blockSkip.lastIndex);let i=!1,s="";for(;e;){i||(s=""),i=!1;let o;if(this.options.extensions?.inline?.some(u=>(o=u.call({lexer:this},e,t))?(e=e.substring(o.raw.length),t.push(o),!0):!1))continue;if(o=this.tokenizer.escape(e)){e=e.substring(o.raw.length),t.push(o);continue}if(o=this.tokenizer.tag(e)){e=e.substring(o.raw.length),t.push(o);continue}if(o=this.tokenizer.link(e)){e=e.substring(o.raw.length),t.push(o);continue}if(o=this.tokenizer.reflink(e,this.tokens.links)){e=e.substring(o.raw.length);let u=t.at(-1);o.type==="text"&&u?.type==="text"?(u.raw+=o.raw,u.text+=o.text):t.push(o);continue}if(o=this.tokenizer.emStrong(e,n,s)){e=e.substring(o.raw.length),t.push(o);continue}if(o=this.tokenizer.codespan(e)){e=e.substring(o.raw.length),t.push(o);continue}if(o=this.tokenizer.br(e)){e=e.substring(o.raw.length),t.push(o);continue}if(o=this.tokenizer.del(e)){e=e.substring(o.raw.length),t.push(o);continue}if(o=this.tokenizer.autolink(e)){e=e.substring(o.raw.length),t.push(o);continue}if(!this.state.inLink&&(o=this.tokenizer.url(e))){e=e.substring(o.raw.length),t.push(o);continue}let a=e;if(this.options.extensions?.startInline){let u=1/0,p=e.slice(1),c;this.options.extensions.startInline.forEach(f=>{c=f.call({lexer:this},p),typeof c=="number"&&c>=0&&(u=Math.min(u,c));}),u<1/0&&u>=0&&(a=e.substring(0,u+1));}if(o=this.tokenizer.inlineText(a)){e=e.substring(o.raw.length),o.raw.slice(-1)!=="_"&&(s=o.raw.slice(-1)),i=!0;let u=t.at(-1);u?.type==="text"?(u.raw+=o.raw,u.text+=o.text):t.push(o);continue}if(e){let u="Infinite loop on byte: "+e.charCodeAt(0);if(this.options.silent){console.error(u);break}else throw new Error(u)}}return t}};var P=class{options;parser;constructor(e){this.options=e||O;}space(e){return ""}code({text:e,lang:t,escaped:n}){let r=(t||"").match(m.notSpaceStart)?.[0],i=e.replace(m.endingNewline,"")+` -`;return r?'
'+(n?i:w(i,!0))+`
-`:"
"+(n?i:w(i,!0))+`
-`}blockquote({tokens:e}){return `
-${this.parser.parse(e)}
-`}html({text:e}){return e}def(e){return ""}heading({tokens:e,depth:t}){return `${this.parser.parseInline(e)} -`}hr(e){return `
-`}list(e){let t=e.ordered,n=e.start,r="";for(let o=0;o -`+r+" -`}listitem(e){let t="";if(e.task){let n=this.checkbox({checked:!!e.checked});e.loose?e.tokens[0]?.type==="paragraph"?(e.tokens[0].text=n+" "+e.tokens[0].text,e.tokens[0].tokens&&e.tokens[0].tokens.length>0&&e.tokens[0].tokens[0].type==="text"&&(e.tokens[0].tokens[0].text=n+" "+w(e.tokens[0].tokens[0].text),e.tokens[0].tokens[0].escaped=!0)):e.tokens.unshift({type:"text",raw:n+" ",text:n+" ",escaped:!0}):t+=n+" ";}return t+=this.parser.parse(e.tokens,!!e.loose),`
  • ${t}
  • -`}checkbox({checked:e}){return "'}paragraph({tokens:e}){return `

    ${this.parser.parseInline(e)}

    -`}table(e){let t="",n="";for(let i=0;i${r}`),` - -`+t+` -`+r+`
    -`}tablerow({text:e}){return ` -${e} -`}tablecell(e){let t=this.parser.parseInline(e.tokens),n=e.header?"th":"td";return (e.align?`<${n} align="${e.align}">`:`<${n}>`)+t+` -`}strong({tokens:e}){return `${this.parser.parseInline(e)}`}em({tokens:e}){return `${this.parser.parseInline(e)}`}codespan({text:e}){return `${w(e,!0)}`}br(e){return "
    "}del({tokens:e}){return `${this.parser.parseInline(e)}`}link({href:e,title:t,tokens:n}){let r=this.parser.parseInline(n),i=J(e);if(i===null)return r;e=i;let s='
    ",s}image({href:e,title:t,text:n,tokens:r}){r&&(n=this.parser.parseInline(r,this.parser.textRenderer));let i=J(e);if(i===null)return w(n);e=i;let s=`${n}{let o=i[s].flat(1/0);n=n.concat(this.walkTokens(o,t));}):i.tokens&&(n=n.concat(this.walkTokens(i.tokens,t)));}}return n}use(...e){let t=this.defaults.extensions||{renderers:{},childTokens:{}};return e.forEach(n=>{let r={...n};if(r.async=this.defaults.async||r.async||!1,n.extensions&&(n.extensions.forEach(i=>{if(!i.name)throw new Error("extension name required");if("renderer"in i){let s=t.renderers[i.name];s?t.renderers[i.name]=function(...o){let a=i.renderer.apply(this,o);return a===!1&&(a=s.apply(this,o)),a}:t.renderers[i.name]=i.renderer;}if("tokenizer"in i){if(!i.level||i.level!=="block"&&i.level!=="inline")throw new Error("extension level must be 'block' or 'inline'");let s=t[i.level];s?s.unshift(i.tokenizer):t[i.level]=[i.tokenizer],i.start&&(i.level==="block"?t.startBlock?t.startBlock.push(i.start):t.startBlock=[i.start]:i.level==="inline"&&(t.startInline?t.startInline.push(i.start):t.startInline=[i.start]));}"childTokens"in i&&i.childTokens&&(t.childTokens[i.name]=i.childTokens);}),r.extensions=t),n.renderer){let i=this.defaults.renderer||new P(this.defaults);for(let s in n.renderer){if(!(s in i))throw new Error(`renderer '${s}' does not exist`);if(["options","parser"].includes(s))continue;let o=s,a=n.renderer[o],u=i[o];i[o]=(...p)=>{let c=a.apply(i,p);return c===!1&&(c=u.apply(i,p)),c||""};}r.renderer=i;}if(n.tokenizer){let i=this.defaults.tokenizer||new y(this.defaults);for(let s in n.tokenizer){if(!(s in i))throw new Error(`tokenizer '${s}' does not exist`);if(["options","rules","lexer"].includes(s))continue;let o=s,a=n.tokenizer[o],u=i[o];i[o]=(...p)=>{let c=a.apply(i,p);return c===!1&&(c=u.apply(i,p)),c};}r.tokenizer=i;}if(n.hooks){let i=this.defaults.hooks||new $;for(let s in n.hooks){if(!(s in i))throw new Error(`hook '${s}' does not exist`);if(["options","block"].includes(s))continue;let o=s,a=n.hooks[o],u=i[o];$.passThroughHooks.has(s)?i[o]=p=>{if(this.defaults.async)return Promise.resolve(a.call(i,p)).then(f=>u.call(i,f));let c=a.call(i,p);return u.call(i,c)}:i[o]=(...p)=>{let c=a.apply(i,p);return c===!1&&(c=u.apply(i,p)),c};}r.hooks=i;}if(n.walkTokens){let i=this.defaults.walkTokens,s=n.walkTokens;r.walkTokens=function(o){let a=[];return a.push(s.call(this,o)),i&&(a=a.concat(i.call(this,o))),a};}this.defaults={...this.defaults,...r};}),this}setOptions(e){return this.defaults={...this.defaults,...e},this}lexer(e,t){return b.lex(e,t??this.defaults)}parser(e,t){return R.parse(e,t??this.defaults)}parseMarkdown(e){return (n,r)=>{let i={...r},s={...this.defaults,...i},o=this.onError(!!s.silent,!!s.async);if(this.defaults.async===!0&&i.async===!1)return o(new Error("marked(): The async option was set to true by an extension. Remove async: false from the parse options object to return a Promise."));if(typeof n>"u"||n===null)return o(new Error("marked(): input parameter is undefined or null"));if(typeof n!="string")return o(new Error("marked(): input parameter is of type "+Object.prototype.toString.call(n)+", string expected"));s.hooks&&(s.hooks.options=s,s.hooks.block=e);let a=s.hooks?s.hooks.provideLexer():e?b.lex:b.lexInline,u=s.hooks?s.hooks.provideParser():e?R.parse:R.parseInline;if(s.async)return Promise.resolve(s.hooks?s.hooks.preprocess(n):n).then(p=>a(p,s)).then(p=>s.hooks?s.hooks.processAllTokens(p):p).then(p=>s.walkTokens?Promise.all(this.walkTokens(p,s.walkTokens)).then(()=>p):p).then(p=>u(p,s)).then(p=>s.hooks?s.hooks.postprocess(p):p).catch(o);try{s.hooks&&(n=s.hooks.preprocess(n));let p=a(n,s);s.hooks&&(p=s.hooks.processAllTokens(p)),s.walkTokens&&this.walkTokens(p,s.walkTokens);let c=u(p,s);return s.hooks&&(c=s.hooks.postprocess(c)),c}catch(p){return o(p)}}}onError(e,t){return n=>{if(n.message+=` -Please report this to https://github.com/markedjs/marked.`,e){let r="

    An error occurred:

    "+w(n.message+"",!0)+"
    ";return t?Promise.resolve(r):r}if(t)return Promise.reject(n);throw n}}};var _=new B;function d(l,e){return _.parse(l,e)}d.options=d.setOptions=function(l){return _.setOptions(l),d.defaults=_.defaults,H(d.defaults),d};d.getDefaults=L;d.defaults=O;d.use=function(...l){return _.use(...l),d.defaults=_.defaults,H(d.defaults),d};d.walkTokens=function(l,e){return _.walkTokens(l,e)};d.parseInline=_.parseInline;d.Parser=R;d.parser=R.parse;d.Renderer=P;d.TextRenderer=S;d.Lexer=b;d.lexer=b.lex;d.Tokenizer=y;d.Hooks=$;d.parse=d;d.options;d.setOptions;d.use;d.walkTokens;d.parseInline;R.parse;b.lex; - - function extend (destination) { - for (var i = 1; i < arguments.length; i++) { - var source = arguments[i]; - for (var key in source) { - if (source.hasOwnProperty(key)) destination[key] = source[key]; - } - } - return destination - } - - function repeat (character, count) { - return Array(count + 1).join(character) - } - - function trimLeadingNewlines (string) { - return string.replace(/^\n*/, '') - } - - function trimTrailingNewlines (string) { - // avoid match-at-end regexp bottleneck, see #370 - var indexEnd = string.length; - while (indexEnd > 0 && string[indexEnd - 1] === '\n') indexEnd--; - return string.substring(0, indexEnd) - } - - var blockElements = [ - 'ADDRESS', 'ARTICLE', 'ASIDE', 'AUDIO', 'BLOCKQUOTE', 'BODY', 'CANVAS', - 'CENTER', 'DD', 'DIR', 'DIV', 'DL', 'DT', 'FIELDSET', 'FIGCAPTION', 'FIGURE', - 'FOOTER', 'FORM', 'FRAMESET', 'H1', 'H2', 'H3', 'H4', 'H5', 'H6', 'HEADER', - 'HGROUP', 'HR', 'HTML', 'ISINDEX', 'LI', 'MAIN', 'MENU', 'NAV', 'NOFRAMES', - 'NOSCRIPT', 'OL', 'OUTPUT', 'P', 'PRE', 'SECTION', 'TABLE', 'TBODY', 'TD', - 'TFOOT', 'TH', 'THEAD', 'TR', 'UL' - ]; - - function isBlock (node) { - return is(node, blockElements) - } - - var voidElements = [ - 'AREA', 'BASE', 'BR', 'COL', 'COMMAND', 'EMBED', 'HR', 'IMG', 'INPUT', - 'KEYGEN', 'LINK', 'META', 'PARAM', 'SOURCE', 'TRACK', 'WBR' - ]; - - function isVoid (node) { - return is(node, voidElements) - } - - function hasVoid (node) { - return has(node, voidElements) - } - - var meaningfulWhenBlankElements = [ - 'A', 'TABLE', 'THEAD', 'TBODY', 'TFOOT', 'TH', 'TD', 'IFRAME', 'SCRIPT', - 'AUDIO', 'VIDEO' - ]; - - function isMeaningfulWhenBlank (node) { - return is(node, meaningfulWhenBlankElements) - } - - function hasMeaningfulWhenBlank (node) { - return has(node, meaningfulWhenBlankElements) - } - - function is (node, tagNames) { - return tagNames.indexOf(node.nodeName) >= 0 - } - - function has (node, tagNames) { - return ( - node.getElementsByTagName && - tagNames.some(function (tagName) { - return node.getElementsByTagName(tagName).length - }) - ) - } - - var rules = {}; - - rules.paragraph = { - filter: 'p', - - replacement: function (content) { - return '\n\n' + content + '\n\n' - } - }; - - rules.lineBreak = { - filter: 'br', - - replacement: function (content, node, options) { - return options.br + '\n' - } - }; - - rules.heading = { - filter: ['h1', 'h2', 'h3', 'h4', 'h5', 'h6'], - - replacement: function (content, node, options) { - var hLevel = Number(node.nodeName.charAt(1)); - - if (options.headingStyle === 'setext' && hLevel < 3) { - var underline = repeat((hLevel === 1 ? '=' : '-'), content.length); - return ( - '\n\n' + content + '\n' + underline + '\n\n' - ) - } else { - return '\n\n' + repeat('#', hLevel) + ' ' + content + '\n\n' - } - } - }; - - rules.blockquote = { - filter: 'blockquote', - - replacement: function (content) { - content = content.replace(/^\n+|\n+$/g, ''); - content = content.replace(/^/gm, '> '); - return '\n\n' + content + '\n\n' - } - }; - - rules.list = { - filter: ['ul', 'ol'], - - replacement: function (content, node) { - var parent = node.parentNode; - if (parent.nodeName === 'LI' && parent.lastElementChild === node) { - return '\n' + content - } else { - return '\n\n' + content + '\n\n' - } - } - }; - - rules.listItem = { - filter: 'li', - - replacement: function (content, node, options) { - var prefix = options.bulletListMarker + ' '; - var parent = node.parentNode; - if (parent.nodeName === 'OL') { - var start = parent.getAttribute('start'); - var index = Array.prototype.indexOf.call(parent.children, node); - prefix = (start ? Number(start) + index : index + 1) + '. '; - } - content = content - .replace(/^\n+/, '') // remove leading newlines - .replace(/\n+$/, '\n') // replace trailing newlines with just a single one - .replace(/\n/gm, '\n' + ' '.repeat(prefix.length)); // indent - return ( - prefix + content + (node.nextSibling && !/\n$/.test(content) ? '\n' : '') - ) - } - }; - - rules.indentedCodeBlock = { - filter: function (node, options) { - return ( - options.codeBlockStyle === 'indented' && - node.nodeName === 'PRE' && - node.firstChild && - node.firstChild.nodeName === 'CODE' - ) - }, - - replacement: function (content, node, options) { - return ( - '\n\n ' + - node.firstChild.textContent.replace(/\n/g, '\n ') + - '\n\n' - ) - } - }; - - rules.fencedCodeBlock = { - filter: function (node, options) { - return ( - options.codeBlockStyle === 'fenced' && - node.nodeName === 'PRE' && - node.firstChild && - node.firstChild.nodeName === 'CODE' - ) - }, - - replacement: function (content, node, options) { - var className = node.firstChild.getAttribute('class') || ''; - var language = (className.match(/language-(\S+)/) || [null, ''])[1]; - var code = node.firstChild.textContent; - - var fenceChar = options.fence.charAt(0); - var fenceSize = 3; - var fenceInCodeRegex = new RegExp('^' + fenceChar + '{3,}', 'gm'); - - var match; - while ((match = fenceInCodeRegex.exec(code))) { - if (match[0].length >= fenceSize) { - fenceSize = match[0].length + 1; - } - } - - var fence = repeat(fenceChar, fenceSize); - - return ( - '\n\n' + fence + language + '\n' + - code.replace(/\n$/, '') + - '\n' + fence + '\n\n' - ) - } - }; - - rules.horizontalRule = { - filter: 'hr', - - replacement: function (content, node, options) { - return '\n\n' + options.hr + '\n\n' - } - }; - - rules.inlineLink = { - filter: function (node, options) { - return ( - options.linkStyle === 'inlined' && - node.nodeName === 'A' && - node.getAttribute('href') - ) - }, - - replacement: function (content, node) { - var href = node.getAttribute('href'); - if (href) href = href.replace(/([()])/g, '\\$1'); - var title = cleanAttribute(node.getAttribute('title')); - if (title) title = ' "' + title.replace(/"/g, '\\"') + '"'; - return '[' + content + '](' + href + title + ')' - } - }; - - rules.referenceLink = { - filter: function (node, options) { - return ( - options.linkStyle === 'referenced' && - node.nodeName === 'A' && - node.getAttribute('href') - ) - }, - - replacement: function (content, node, options) { - var href = node.getAttribute('href'); - var title = cleanAttribute(node.getAttribute('title')); - if (title) title = ' "' + title + '"'; - var replacement; - var reference; - - switch (options.linkReferenceStyle) { - case 'collapsed': - replacement = '[' + content + '][]'; - reference = '[' + content + ']: ' + href + title; - break - case 'shortcut': - replacement = '[' + content + ']'; - reference = '[' + content + ']: ' + href + title; - break - default: - var id = this.references.length + 1; - replacement = '[' + content + '][' + id + ']'; - reference = '[' + id + ']: ' + href + title; - } - - this.references.push(reference); - return replacement - }, - - references: [], - - append: function (options) { - var references = ''; - if (this.references.length) { - references = '\n\n' + this.references.join('\n') + '\n\n'; - this.references = []; // Reset references - } - return references - } - }; - - rules.emphasis = { - filter: ['em', 'i'], - - replacement: function (content, node, options) { - if (!content.trim()) return '' - return options.emDelimiter + content + options.emDelimiter - } - }; - - rules.strong = { - filter: ['strong', 'b'], - - replacement: function (content, node, options) { - if (!content.trim()) return '' - return options.strongDelimiter + content + options.strongDelimiter - } - }; - - rules.code = { - filter: function (node) { - var hasSiblings = node.previousSibling || node.nextSibling; - var isCodeBlock = node.parentNode.nodeName === 'PRE' && !hasSiblings; - - return node.nodeName === 'CODE' && !isCodeBlock - }, - - replacement: function (content) { - if (!content) return '' - content = content.replace(/\r?\n|\r/g, ' '); - - var extraSpace = /^`|^ .*?[^ ].* $|`$/.test(content) ? ' ' : ''; - var delimiter = '`'; - var matches = content.match(/`+/gm) || []; - while (matches.indexOf(delimiter) !== -1) delimiter = delimiter + '`'; - - return delimiter + extraSpace + content + extraSpace + delimiter - } - }; - - rules.image = { - filter: 'img', - - replacement: function (content, node) { - var alt = cleanAttribute(node.getAttribute('alt')); - var src = node.getAttribute('src') || ''; - var title = cleanAttribute(node.getAttribute('title')); - var titlePart = title ? ' "' + title + '"' : ''; - return src ? '![' + alt + ']' + '(' + src + titlePart + ')' : '' - } - }; - - function cleanAttribute (attribute) { - return attribute ? attribute.replace(/(\n+\s*)+/g, '\n') : '' - } - - /** - * Manages a collection of rules used to convert HTML to Markdown - */ - - function Rules (options) { - this.options = options; - this._keep = []; - this._remove = []; - - this.blankRule = { - replacement: options.blankReplacement - }; - - this.keepReplacement = options.keepReplacement; - - this.defaultRule = { - replacement: options.defaultReplacement - }; - - this.array = []; - for (var key in options.rules) this.array.push(options.rules[key]); - } - - Rules.prototype = { - add: function (key, rule) { - this.array.unshift(rule); - }, - - keep: function (filter) { - this._keep.unshift({ - filter: filter, - replacement: this.keepReplacement - }); - }, - - remove: function (filter) { - this._remove.unshift({ - filter: filter, - replacement: function () { - return '' - } - }); - }, - - forNode: function (node) { - if (node.isBlank) return this.blankRule - var rule; - - if ((rule = findRule(this.array, node, this.options))) return rule - if ((rule = findRule(this._keep, node, this.options))) return rule - if ((rule = findRule(this._remove, node, this.options))) return rule - - return this.defaultRule - }, - - forEach: function (fn) { - for (var i = 0; i < this.array.length; i++) fn(this.array[i], i); - } - }; - - function findRule (rules, node, options) { - for (var i = 0; i < rules.length; i++) { - var rule = rules[i]; - if (filterValue(rule, node, options)) return rule - } - return void 0 - } - - function filterValue (rule, node, options) { - var filter = rule.filter; - if (typeof filter === 'string') { - if (filter === node.nodeName.toLowerCase()) return true - } else if (Array.isArray(filter)) { - if (filter.indexOf(node.nodeName.toLowerCase()) > -1) return true - } else if (typeof filter === 'function') { - if (filter.call(rule, node, options)) return true - } else { - throw new TypeError('`filter` needs to be a string, array, or function') - } - } - - /** - * The collapseWhitespace function is adapted from collapse-whitespace - * by Luc Thevenard. - * - * The MIT License (MIT) - * - * Copyright (c) 2014 Luc Thevenard - * - * Permission is hereby granted, free of charge, to any person obtaining a copy - * of this software and associated documentation files (the "Software"), to deal - * in the Software without restriction, including without limitation the rights - * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell - * copies of the Software, and to permit persons to whom the Software is - * furnished to do so, subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in - * all copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, - * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE - * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER - * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, - * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN - * THE SOFTWARE. - */ - - /** - * collapseWhitespace(options) removes extraneous whitespace from an the given element. - * - * @param {Object} options - */ - function collapseWhitespace (options) { - var element = options.element; - var isBlock = options.isBlock; - var isVoid = options.isVoid; - var isPre = options.isPre || function (node) { - return node.nodeName === 'PRE' - }; - - if (!element.firstChild || isPre(element)) return - - var prevText = null; - var keepLeadingWs = false; - - var prev = null; - var node = next(prev, element, isPre); - - while (node !== element) { - if (node.nodeType === 3 || node.nodeType === 4) { // Node.TEXT_NODE or Node.CDATA_SECTION_NODE - var text = node.data.replace(/[ \r\n\t]+/g, ' '); - - if ((!prevText || / $/.test(prevText.data)) && - !keepLeadingWs && text[0] === ' ') { - text = text.substr(1); - } - - // `text` might be empty at this point. - if (!text) { - node = remove(node); - continue - } - - node.data = text; - - prevText = node; - } else if (node.nodeType === 1) { // Node.ELEMENT_NODE - if (isBlock(node) || node.nodeName === 'BR') { - if (prevText) { - prevText.data = prevText.data.replace(/ $/, ''); - } - - prevText = null; - keepLeadingWs = false; - } else if (isVoid(node) || isPre(node)) { - // Avoid trimming space around non-block, non-BR void elements and inline PRE. - prevText = null; - keepLeadingWs = true; - } else if (prevText) { - // Drop protection if set previously. - keepLeadingWs = false; - } - } else { - node = remove(node); - continue - } - - var nextNode = next(prev, node, isPre); - prev = node; - node = nextNode; - } - - if (prevText) { - prevText.data = prevText.data.replace(/ $/, ''); - if (!prevText.data) { - remove(prevText); - } - } - } - - /** - * remove(node) removes the given node from the DOM and returns the - * next node in the sequence. - * - * @param {Node} node - * @return {Node} node - */ - function remove (node) { - var next = node.nextSibling || node.parentNode; - - node.parentNode.removeChild(node); - - return next - } - - /** - * next(prev, current, isPre) returns the next node in the sequence, given the - * current and previous nodes. - * - * @param {Node} prev - * @param {Node} current - * @param {Function} isPre - * @return {Node} - */ - function next (prev, current, isPre) { - if ((prev && prev.parentNode === current) || isPre(current)) { - return current.nextSibling || current.parentNode - } - - return current.firstChild || current.nextSibling || current.parentNode - } - - /* - * Set up window for Node.js - */ - - var root = (typeof window !== 'undefined' ? window : {}); - - /* - * Parsing HTML strings - */ - - function canParseHTMLNatively () { - var Parser = root.DOMParser; - var canParse = false; - - // Adapted from https://gist.github.com/1129031 - // Firefox/Opera/IE throw errors on unsupported types - try { - // WebKit returns null on unsupported types - if (new Parser().parseFromString('', 'text/html')) { - canParse = true; - } - } catch (e) {} - - return canParse - } - - function createHTMLParser () { - var Parser = function () {}; - - { - var domino = require('@mixmark-io/domino'); - Parser.prototype.parseFromString = function (string) { - return domino.createDocument(string) - }; - } - return Parser - } - - var HTMLParser = canParseHTMLNatively() ? root.DOMParser : createHTMLParser(); - - function RootNode (input, options) { - var root; - if (typeof input === 'string') { - var doc = htmlParser().parseFromString( - // DOM parsers arrange elements in the and . - // Wrapping in a custom element ensures elements are reliably arranged in - // a single element. - '' + input + '', - 'text/html' - ); - root = doc.getElementById('turndown-root'); - } else { - root = input.cloneNode(true); - } - collapseWhitespace({ - element: root, - isBlock: isBlock, - isVoid: isVoid, - isPre: options.preformattedCode ? isPreOrCode : null - }); - - return root - } - - var _htmlParser; - function htmlParser () { - _htmlParser = _htmlParser || new HTMLParser(); - return _htmlParser - } - - function isPreOrCode (node) { - return node.nodeName === 'PRE' || node.nodeName === 'CODE' - } - - function Node$1 (node, options) { - node.isBlock = isBlock(node); - node.isCode = node.nodeName === 'CODE' || node.parentNode.isCode; - node.isBlank = isBlank(node); - node.flankingWhitespace = flankingWhitespace(node, options); - return node - } - - function isBlank (node) { - return ( - !isVoid(node) && - !isMeaningfulWhenBlank(node) && - /^\s*$/i.test(node.textContent) && - !hasVoid(node) && - !hasMeaningfulWhenBlank(node) - ) - } - - function flankingWhitespace (node, options) { - if (node.isBlock || (options.preformattedCode && node.isCode)) { - return { leading: '', trailing: '' } - } - - var edges = edgeWhitespace(node.textContent); - - // abandon leading ASCII WS if left-flanked by ASCII WS - if (edges.leadingAscii && isFlankedByWhitespace('left', node, options)) { - edges.leading = edges.leadingNonAscii; - } - - // abandon trailing ASCII WS if right-flanked by ASCII WS - if (edges.trailingAscii && isFlankedByWhitespace('right', node, options)) { - edges.trailing = edges.trailingNonAscii; - } - - return { leading: edges.leading, trailing: edges.trailing } - } - - function edgeWhitespace (string) { - var m = string.match(/^(([ \t\r\n]*)(\s*))(?:(?=\S)[\s\S]*\S)?((\s*?)([ \t\r\n]*))$/); - return { - leading: m[1], // whole string for whitespace-only strings - leadingAscii: m[2], - leadingNonAscii: m[3], - trailing: m[4], // empty for whitespace-only strings - trailingNonAscii: m[5], - trailingAscii: m[6] - } - } - - function isFlankedByWhitespace (side, node, options) { - var sibling; - var regExp; - var isFlanked; - - if (side === 'left') { - sibling = node.previousSibling; - regExp = / $/; - } else { - sibling = node.nextSibling; - regExp = /^ /; - } - - if (sibling) { - if (sibling.nodeType === 3) { - isFlanked = regExp.test(sibling.nodeValue); - } else if (options.preformattedCode && sibling.nodeName === 'CODE') { - isFlanked = false; - } else if (sibling.nodeType === 1 && !isBlock(sibling)) { - isFlanked = regExp.test(sibling.textContent); - } - } - return isFlanked - } - - var reduce = Array.prototype.reduce; - var escapes = [ - [/\\/g, '\\\\'], - [/\*/g, '\\*'], - [/^-/g, '\\-'], - [/^\+ /g, '\\+ '], - [/^(=+)/g, '\\$1'], - [/^(#{1,6}) /g, '\\$1 '], - [/`/g, '\\`'], - [/^~~~/g, '\\~~~'], - [/\[/g, '\\['], - [/\]/g, '\\]'], - [/^>/g, '\\>'], - [/_/g, '\\_'], - [/^(\d+)\. /g, '$1\\. '] - ]; - - function TurndownService (options) { - if (!(this instanceof TurndownService)) return new TurndownService(options) - - var defaults = { - rules: rules, - headingStyle: 'setext', - hr: '* * *', - bulletListMarker: '*', - codeBlockStyle: 'indented', - fence: '```', - emDelimiter: '_', - strongDelimiter: '**', - linkStyle: 'inlined', - linkReferenceStyle: 'full', - br: ' ', - preformattedCode: false, - blankReplacement: function (content, node) { - return node.isBlock ? '\n\n' : '' - }, - keepReplacement: function (content, node) { - return node.isBlock ? '\n\n' + node.outerHTML + '\n\n' : node.outerHTML - }, - defaultReplacement: function (content, node) { - return node.isBlock ? '\n\n' + content + '\n\n' : content - } - }; - this.options = extend({}, defaults, options); - this.rules = new Rules(this.options); - } - - TurndownService.prototype = { - /** - * The entry point for converting a string or DOM node to Markdown - * @public - * @param {String|HTMLElement} input The string or DOM node to convert - * @returns A Markdown representation of the input - * @type String - */ - - turndown: function (input) { - if (!canConvert(input)) { - throw new TypeError( - input + ' is not a string, or an element/document/fragment node.' - ) - } - - if (input === '') return '' - - var output = process.call(this, new RootNode(input, this.options)); - return postProcess.call(this, output) - }, - - /** - * Add one or more plugins - * @public - * @param {Function|Array} plugin The plugin or array of plugins to add - * @returns The Turndown instance for chaining - * @type Object - */ - - use: function (plugin) { - if (Array.isArray(plugin)) { - for (var i = 0; i < plugin.length; i++) this.use(plugin[i]); - } else if (typeof plugin === 'function') { - plugin(this); - } else { - throw new TypeError('plugin must be a Function or an Array of Functions') - } - return this - }, - - /** - * Adds a rule - * @public - * @param {String} key The unique key of the rule - * @param {Object} rule The rule - * @returns The Turndown instance for chaining - * @type Object - */ - - addRule: function (key, rule) { - this.rules.add(key, rule); - return this - }, - - /** - * Keep a node (as HTML) that matches the filter - * @public - * @param {String|Array|Function} filter The unique key of the rule - * @returns The Turndown instance for chaining - * @type Object - */ - - keep: function (filter) { - this.rules.keep(filter); - return this - }, - - /** - * Remove a node that matches the filter - * @public - * @param {String|Array|Function} filter The unique key of the rule - * @returns The Turndown instance for chaining - * @type Object - */ - - remove: function (filter) { - this.rules.remove(filter); - return this - }, - - /** - * Escapes Markdown syntax - * @public - * @param {String} string The string to escape - * @returns A string with Markdown syntax escaped - * @type String - */ - - escape: function (string) { - return escapes.reduce(function (accumulator, escape) { - return accumulator.replace(escape[0], escape[1]) - }, string) - } - }; - - /** - * Reduces a DOM node down to its Markdown string equivalent - * @private - * @param {HTMLElement} parentNode The node to convert - * @returns A Markdown representation of the node - * @type String - */ - - function process (parentNode) { - var self = this; - return reduce.call(parentNode.childNodes, function (output, node) { - node = new Node$1(node, self.options); - - var replacement = ''; - if (node.nodeType === 3) { - replacement = node.isCode ? node.nodeValue : self.escape(node.nodeValue); - } else if (node.nodeType === 1) { - replacement = replacementForNode.call(self, node); - } - - return join(output, replacement) - }, '') - } - - /** - * Appends strings as each rule requires and trims the output - * @private - * @param {String} output The conversion output - * @returns A trimmed version of the ouput - * @type String - */ - - function postProcess (output) { - var self = this; - this.rules.forEach(function (rule) { - if (typeof rule.append === 'function') { - output = join(output, rule.append(self.options)); - } - }); - - return output.replace(/^[\t\r\n]+/, '').replace(/[\t\r\n\s]+$/, '') - } - - /** - * Converts an element node to its Markdown equivalent - * @private - * @param {HTMLElement} node The node to convert - * @returns A Markdown representation of the node - * @type String - */ - - function replacementForNode (node) { - var rule = this.rules.forNode(node); - var content = process.call(this, node); - var whitespace = node.flankingWhitespace; - if (whitespace.leading || whitespace.trailing) content = content.trim(); - return ( - whitespace.leading + - rule.replacement(content, node, this.options) + - whitespace.trailing - ) - } - - /** - * Joins replacement to the current output with appropriate number of new lines - * @private - * @param {String} output The current conversion output - * @param {String} replacement The string to append to the output - * @returns Joined output - * @type String - */ - - function join (output, replacement) { - var s1 = trimTrailingNewlines(output); - var s2 = trimLeadingNewlines(replacement); - var nls = Math.max(output.length - s1.length, replacement.length - s2.length); - var separator = '\n\n'.substring(0, nls); - - return s1 + separator + s2 - } - - /** - * Determines whether an input can be converted - * @private - * @param {String|HTMLElement} input Describe this parameter - * @returns Describe what it returns - * @type String|Object|Array|Boolean|Number - */ - - function canConvert (input) { - return ( - input != null && ( - typeof input === 'string' || - (input.nodeType && ( - input.nodeType === 1 || input.nodeType === 9 || input.nodeType === 11 - )) - ) - ) - } - - /** - * Markdown conversion utilities using Marked and Turndown - */ - - /** - * MarkdownConverter - Handles bidirectional HTML ↔ Markdown conversion - */ - class MarkdownConverter { - constructor() { - this.initializeMarked(); - this.initializeTurndown(); - } - - /** - * Configure marked for HTML output - */ - initializeMarked() { - d.setOptions({ - gfm: true, // GitHub Flavored Markdown - breaks: true, // Convert \n to
    - pedantic: false, // Don't be overly strict - sanitize: false, // Allow HTML (we control the input) - smartLists: true, // Smarter list behavior - smartypants: false // Don't convert quotes/dashes - }); - } - - /** - * Configure turndown for markdown output - */ - initializeTurndown() { - this.turndown = new TurndownService({ - headingStyle: 'atx', // # headers instead of underlines - hr: '---', // horizontal rule style - bulletListMarker: '-', // bullet list marker - codeBlockStyle: 'fenced', // ``` code blocks - fence: '```', // fence marker - emDelimiter: '*', // emphasis delimiter - strongDelimiter: '**', // strong delimiter - linkStyle: 'inlined', // [text](url) instead of reference style - linkReferenceStyle: 'full' // full reference links - }); - - // Add custom rules for better conversion - this.addTurndownRules(); - } - - /** - * Add custom turndown rules for better HTML → Markdown conversion - */ - addTurndownRules() { - // Handle paragraph spacing properly - ensure double newlines between paragraphs - this.turndown.addRule('paragraph', { - filter: 'p', - replacement: function (content) { - if (!content.trim()) return ''; - return content.trim() + '\n\n'; - } - }); - - // Handle bold text in markdown - this.turndown.addRule('bold', { - filter: ['strong', 'b'], - replacement: function (content) { - if (!content.trim()) return ''; - return '**' + content + '**'; - } - }); - - // Handle italic text in markdown - this.turndown.addRule('italic', { - filter: ['em', 'i'], - replacement: function (content) { - if (!content.trim()) return ''; - return '*' + content + '*'; - } - }); - } - - /** - * Convert HTML to Markdown - * @param {string} html - HTML string to convert - * @returns {string} - Markdown string - */ - htmlToMarkdown(html) { - if (!html || html.trim() === '') { - return ''; - } - - try { - const markdown = this.turndown.turndown(html); - // Clean up and normalize newlines for proper paragraph separation - return markdown - .replace(/\n{3,}/g, '\n\n') // Replace 3+ newlines with 2 - .replace(/^\n+|\n+$/g, '') // Remove leading/trailing newlines - .trim(); // Remove other whitespace - } catch (error) { - console.warn('HTML to Markdown conversion failed:', error); - // Fallback: extract text content - const tempDiv = document.createElement('div'); - tempDiv.innerHTML = html; - return tempDiv.textContent || tempDiv.innerText || ''; - } - } - - /** - * Convert Markdown to HTML - * @param {string} markdown - Markdown string to convert - * @returns {string} - HTML string - */ - markdownToHtml(markdown) { - if (!markdown || markdown.trim() === '') { - return ''; - } - - try { - const html = d(markdown); - return html; - } catch (error) { - console.warn('Markdown to HTML conversion failed:', error); - // Fallback: convert line breaks to paragraphs - return markdown - .split(/\n\s*\n/) - .filter(p => p.trim()) - .map(p => `

    ${p.trim()}

    `) - .join(''); - } - } - - /** - * Extract HTML content from a group of elements - * @param {HTMLElement[]} elements - Array of DOM elements - * @returns {string} - Combined HTML content - */ - extractGroupHTML(elements) { - const htmlParts = []; - - elements.forEach(element => { - // Wrap inner content in paragraph tags to preserve structure - const html = element.innerHTML.trim(); - if (html) { - // If element is already a paragraph, use its outer HTML - if (element.tagName.toLowerCase() === 'p') { - htmlParts.push(element.outerHTML); - } else { - // Wrap in paragraph tags - htmlParts.push(`

    ${html}

    `); - } - } - }); - - return htmlParts.join('\n'); - } - - /** - * Convert HTML content from group elements to markdown - * @param {HTMLElement[]} elements - Array of DOM elements - * @returns {string} - Markdown representation - */ - extractGroupMarkdown(elements) { - const html = this.extractGroupHTML(elements); - const markdown = this.htmlToMarkdown(html); - return markdown; - } - - /** - * Update group elements with markdown content - * @param {HTMLElement[]} elements - Array of DOM elements to update - * @param {string} markdown - Markdown content to render - */ - updateGroupElements(elements, markdown) { - const html = this.markdownToHtml(markdown); - - // Split HTML into paragraphs - const tempDiv = document.createElement('div'); - tempDiv.innerHTML = html; - - const paragraphs = Array.from(tempDiv.querySelectorAll('p, div, h1, h2, h3, h4, h5, h6')); - - // Handle case where we have more/fewer paragraphs than elements - const maxCount = Math.max(elements.length, paragraphs.length); - - for (let i = 0; i < maxCount; i++) { - if (i < elements.length && i < paragraphs.length) { - // Update existing element with corresponding paragraph - elements[i].innerHTML = paragraphs[i].innerHTML; - } else if (i < elements.length) { - // More elements than paragraphs - clear extra elements - elements[i].innerHTML = ''; - } else if (i < paragraphs.length) { - // More paragraphs than elements - create new element - const newElement = document.createElement('p'); - newElement.innerHTML = paragraphs[i].innerHTML; - - // Insert after the last existing element - const lastElement = elements[elements.length - 1]; - lastElement.parentNode.insertBefore(newElement, lastElement.nextSibling); - elements.push(newElement); // Add to our elements array for future updates - } - } - } - } - - // Export singleton instance - const markdownConverter = new MarkdownConverter(); - - /** - * Unified Markdown Editor - Handles both single and multiple element editing - */ - - class MarkdownEditor { - constructor() { - this.currentOverlay = null; - this.previewManager = new MarkdownPreviewManager(); - } - - /** - * Edit elements with markdown - unified interface for single or multiple elements - * @param {HTMLElement|HTMLElement[]} elements - Element(s) to edit - * @param {Function} onSave - Save callback - * @param {Function} onCancel - Cancel callback - */ - edit(elements, onSave, onCancel) { - // Normalize to array - const elementArray = Array.isArray(elements) ? elements : [elements]; - const context = new MarkdownContext(elementArray); - - // Close any existing editor - this.close(); - - // Create unified editor form - const form = this.createMarkdownForm(context); - const overlay = this.createOverlay(form); - - // Position relative to primary element - this.positionForm(context.primaryElement, overlay); - - // Setup unified event handlers - this.setupEventHandlers(form, overlay, context, { onSave, onCancel }); - - // Show editor - document.body.appendChild(overlay); - this.currentOverlay = overlay; - - // Focus textarea - const textarea = form.querySelector('textarea'); - if (textarea) { - setTimeout(() => textarea.focus(), 100); - } - - return overlay; - } - - /** - * Create markdown editing form - */ - createMarkdownForm(context) { - const config = this.getMarkdownConfig(context); - const currentContent = context.extractMarkdown(); - - const form = document.createElement('div'); - form.className = 'insertr-edit-form'; - - form.innerHTML = ` -
    ${config.label}
    -
    - -
    - Supports Markdown formatting (bold, italic, links, etc.) -
    -
    -
    - - -
    - `; - - return form; - } - - /** - * Get markdown configuration based on context - */ - getMarkdownConfig(context) { - const elementCount = context.elements.length; - - if (elementCount === 1) { - const element = context.elements[0]; - const tag = element.tagName.toLowerCase(); - - switch (tag) { - case 'h3': case 'h4': case 'h5': case 'h6': - return { - label: 'Title (Markdown)', - rows: 2, - placeholder: 'Enter title using markdown...' - }; - case 'p': - return { - label: 'Content (Markdown)', - rows: 4, - placeholder: 'Enter content using markdown...' - }; - case 'span': - return { - label: 'Text (Markdown)', - rows: 2, - placeholder: 'Enter text using markdown...' - }; - default: - return { - label: 'Content (Markdown)', - rows: 3, - placeholder: 'Enter content using markdown...' - }; - } - } else { - return { - label: `Group Content (${elementCount} elements)`, - rows: Math.max(8, elementCount * 2), - placeholder: 'Edit all content together using markdown...' - }; - } - } - - /** - * Setup unified event handlers - */ - setupEventHandlers(form, overlay, context, { onSave, onCancel }) { - const textarea = form.querySelector('textarea'); - const saveBtn = form.querySelector('.insertr-btn-save'); - const cancelBtn = form.querySelector('.insertr-btn-cancel'); - - // Initialize preview manager - this.previewManager.setActiveContext(context); - - // Setup debounced live preview - if (textarea) { - textarea.addEventListener('input', () => { - const markdown = textarea.value; - this.previewManager.schedulePreview(context, markdown); - }); - } - - // Save handler - if (saveBtn) { - saveBtn.addEventListener('click', () => { - const markdown = textarea.value; - - // Update elements with final content - context.applyMarkdown(markdown); - - // Clear preview styling - this.previewManager.clearPreview(); - - // Callback and close - onSave({ text: markdown }); - this.close(); - }); - } - - // Cancel handler - if (cancelBtn) { - cancelBtn.addEventListener('click', () => { - this.previewManager.clearPreview(); - onCancel(); - this.close(); - }); - } - - // ESC key handler - const keyHandler = (e) => { - if (e.key === 'Escape') { - this.previewManager.clearPreview(); - onCancel(); - this.close(); - document.removeEventListener('keydown', keyHandler); - } - }; - document.addEventListener('keydown', keyHandler); - - // Click outside handler - overlay.addEventListener('click', (e) => { - if (e.target === overlay) { - this.previewManager.clearPreview(); - onCancel(); - this.close(); - } - }); - } - - /** - * Create overlay with backdrop - */ - createOverlay(form) { - const overlay = document.createElement('div'); - overlay.className = 'insertr-form-overlay'; - overlay.appendChild(form); - return overlay; - } - - /** - * Position form relative to primary element - */ - positionForm(element, overlay) { - const rect = element.getBoundingClientRect(); - const form = overlay.querySelector('.insertr-edit-form'); - const viewportWidth = window.innerWidth; - - // Calculate optimal width - let formWidth; - if (viewportWidth < 768) { - formWidth = Math.min(viewportWidth - 40, 500); - } else { - const minComfortableWidth = 600; - const maxWidth = Math.min(viewportWidth * 0.9, 800); - formWidth = Math.max(minComfortableWidth, Math.min(rect.width * 1.5, maxWidth)); - } - - form.style.width = `${formWidth}px`; - - // Position below element - const top = rect.bottom + window.scrollY + 10; - const centerLeft = rect.left + window.scrollX + (rect.width / 2) - (formWidth / 2); - const minLeft = 20; - const maxLeft = window.innerWidth - formWidth - 20; - const left = Math.max(minLeft, Math.min(centerLeft, maxLeft)); - - overlay.style.position = 'absolute'; - overlay.style.top = `${top}px`; - overlay.style.left = `${left}px`; - overlay.style.zIndex = '10000'; - - // Ensure visibility - this.ensureModalVisible(element, overlay); - } - - /** - * Ensure modal is visible by scrolling if needed - */ - ensureModalVisible(element, overlay) { - requestAnimationFrame(() => { - const modal = overlay.querySelector('.insertr-edit-form'); - const modalRect = modal.getBoundingClientRect(); - const viewportHeight = window.innerHeight; - - if (modalRect.bottom > viewportHeight) { - const scrollAmount = modalRect.bottom - viewportHeight + 20; - window.scrollBy({ - top: scrollAmount, - behavior: 'smooth' - }); - } - }); - } - - /** - * Close current editor - */ - close() { - if (this.previewManager) { - this.previewManager.clearPreview(); - } - - if (this.currentOverlay) { - this.currentOverlay.remove(); - this.currentOverlay = null; - } - } - - /** - * Escape HTML to prevent XSS - */ - escapeHtml(text) { - if (typeof text !== 'string') return ''; - const div = document.createElement('div'); - div.textContent = text; - return div.innerHTML; - } - } - - /** - * Markdown Context - Represents single or multiple elements for editing - */ - class MarkdownContext { - constructor(elements) { - this.elements = elements; - this.primaryElement = elements[0]; // Used for positioning - this.originalContent = null; - } - - /** - * Extract markdown content from elements - */ - extractMarkdown() { - if (this.elements.length === 1) { - // Single element - convert its HTML to markdown - return markdownConverter.htmlToMarkdown(this.elements[0].innerHTML); - } else { - // Multiple elements - combine and convert to markdown - return markdownConverter.extractGroupMarkdown(this.elements); - } - } - - /** - * Apply markdown content to elements - */ - applyMarkdown(markdown) { - if (this.elements.length === 1) { - // Single element - convert markdown to HTML and apply - const html = markdownConverter.markdownToHtml(markdown); - this.elements[0].innerHTML = html; - } else { - // Multiple elements - use group update logic - markdownConverter.updateGroupElements(this.elements, markdown); - } - } - - /** - * Store original content for preview restoration - */ - storeOriginalContent() { - this.originalContent = this.elements.map(el => el.innerHTML); - } - - /** - * Restore original content (for preview cancellation) - */ - restoreOriginalContent() { - if (this.originalContent) { - this.elements.forEach((el, index) => { - if (this.originalContent[index] !== undefined) { - el.innerHTML = this.originalContent[index]; - } - }); - } - } - - /** - * Apply preview styling - */ - applyPreviewStyling() { - this.elements.forEach(el => { - el.classList.add('insertr-preview-active'); - }); - - // Also apply to primary element if it's a container - if (this.primaryElement.classList.contains('insertr-group')) { - this.primaryElement.classList.add('insertr-preview-active'); - } - } - - /** - * Remove preview styling - */ - removePreviewStyling() { - this.elements.forEach(el => { - el.classList.remove('insertr-preview-active'); - }); - - // Also remove from containers - if (this.primaryElement.classList.contains('insertr-group')) { - this.primaryElement.classList.remove('insertr-preview-active'); - } - } - } - - /** - * Unified Preview Manager for Markdown Content - */ - class MarkdownPreviewManager { - constructor() { - this.previewTimeout = null; - this.activeContext = null; - this.resizeObserver = null; - } - - setActiveContext(context) { - this.clearPreview(); - this.activeContext = context; - this.startResizeObserver(); - } - - schedulePreview(context, markdown) { - // Clear existing timeout - if (this.previewTimeout) { - clearTimeout(this.previewTimeout); - } - - // Schedule new preview with 500ms debounce - this.previewTimeout = setTimeout(() => { - this.updatePreview(context, markdown); - }, 500); - } - - updatePreview(context, markdown) { - // Store original content if first preview - if (!context.originalContent) { - context.storeOriginalContent(); - } - - // Apply preview content - context.applyMarkdown(markdown); - context.applyPreviewStyling(); - } - - clearPreview() { - if (this.activeContext) { - this.activeContext.restoreOriginalContent(); - this.activeContext.removePreviewStyling(); - this.activeContext = null; - } - - if (this.previewTimeout) { - clearTimeout(this.previewTimeout); - this.previewTimeout = null; - } - - this.stopResizeObserver(); - } - - startResizeObserver() { - this.stopResizeObserver(); - - if (this.activeContext) { - this.resizeObserver = new ResizeObserver(() => { - // Handle height changes for modal repositioning - if (this.onHeightChange) { - this.onHeightChange(this.activeContext.primaryElement); - } - }); - - this.activeContext.elements.forEach(el => { - this.resizeObserver.observe(el); - }); - } - } - - stopResizeObserver() { - if (this.resizeObserver) { - this.resizeObserver.disconnect(); - this.resizeObserver = null; - } - } - - setHeightChangeCallback(callback) { - this.onHeightChange = callback; - } - } - - /** - * LivePreviewManager - Handles debounced live preview updates for non-markdown elements - */ - class LivePreviewManager { - constructor() { - this.previewTimeouts = new Map(); - this.activeElement = null; - this.originalContent = null; - this.originalStyles = null; - this.resizeObserver = null; - this.onHeightChangeCallback = null; - } - - schedulePreview(element, newValue, elementType) { - const elementId = this.getElementId(element); - - // Clear existing timeout - if (this.previewTimeouts.has(elementId)) { - clearTimeout(this.previewTimeouts.get(elementId)); - } - - // Schedule new preview update with 500ms debounce - const timeoutId = setTimeout(() => { - this.updatePreview(element, newValue, elementType); - }, 500); - - this.previewTimeouts.set(elementId, timeoutId); - } - - - - updatePreview(element, newValue, elementType) { - // Store original content if first preview - if (!this.originalContent && this.activeElement === element) { - this.originalContent = this.extractOriginalContent(element, elementType); - } - - // Apply preview styling and content - this.applyPreviewContent(element, newValue, elementType); - - // ResizeObserver will automatically detect height changes - } - - - - extractOriginalContent(element, elementType) { - switch (elementType) { - case 'link': - return { - text: element.textContent, - url: element.href - }; - default: - return element.textContent; - } - } - - applyPreviewContent(element, newValue, elementType) { - // Add preview indicator - element.classList.add('insertr-preview-active'); - - // Update content based on element type - switch (elementType) { - case 'text': - case 'h1': case 'h2': case 'h3': case 'h4': case 'h5': case 'h6': - case 'span': case 'button': - if (newValue && newValue.trim()) { - element.textContent = newValue; - } - break; - - case 'textarea': - case 'p': - if (newValue && newValue.trim()) { - element.textContent = newValue; - } - break; - - case 'link': - if (typeof newValue === 'object') { - if (newValue.text !== undefined && newValue.text.trim()) { - element.textContent = newValue.text; - } - if (newValue.url !== undefined && newValue.url.trim()) { - element.href = newValue.url; - } - } else if (newValue && newValue.trim()) { - element.textContent = newValue; - } - break; - - - } - } - - clearPreview(element) { - if (!element) return; - - const elementId = this.getElementId(element); - - // Clear any pending preview - if (this.previewTimeouts.has(elementId)) { - clearTimeout(this.previewTimeouts.get(elementId)); - this.previewTimeouts.delete(elementId); - } - - // Stop ResizeObserver - this.stopResizeObserver(); - - // Restore original content - if (this.originalContent && element === this.activeElement) { - this.restoreOriginalContent(element); - } - - // Remove preview styling - element.classList.remove('insertr-preview-active'); - - this.activeElement = null; - this.originalContent = null; - } - - restoreOriginalContent(element) { - if (!this.originalContent) return; - - if (typeof this.originalContent === 'object') { - // Link element - element.textContent = this.originalContent.text; - if (this.originalContent.url) { - element.href = this.originalContent.url; - } - } else { - // Text element - element.textContent = this.originalContent; - } - } - - getElementId(element) { - // Create unique ID for element tracking - if (!element._insertrId) { - element._insertrId = 'insertr_' + Date.now() + '_' + Math.random().toString(36).substr(2, 9); - } - return element._insertrId; - } - - setActiveElement(element) { - this.activeElement = element; - this.originalContent = null; - this.startResizeObserver(element); - } - - setHeightChangeCallback(callback) { - this.onHeightChangeCallback = callback; - } - - startResizeObserver(element) { - // Clean up existing observer - this.stopResizeObserver(); - - // Create new ResizeObserver for this element - this.resizeObserver = new ResizeObserver(entries => { - // Use requestAnimationFrame to ensure smooth updates - requestAnimationFrame(() => { - if (this.onHeightChangeCallback && element === this.activeElement) { - this.onHeightChangeCallback(element); - } - }); - }); - - // Start observing the element - this.resizeObserver.observe(element); - } - - stopResizeObserver() { - if (this.resizeObserver) { - this.resizeObserver.disconnect(); - this.resizeObserver = null; - } - } - } - - /** - * InsertrFormRenderer - Professional modal editing forms with live preview - * Enhanced with debounced live preview and comfortable input sizing - */ - class InsertrFormRenderer { - constructor(apiClient = null) { - this.apiClient = apiClient; - this.currentOverlay = null; - this.previewManager = new LivePreviewManager(); - this.markdownEditor = new MarkdownEditor(); - this.setupStyles(); - } - - /** - * Create and show edit form for content element - * @param {Object} meta - Element metadata {element, contentId, contentType} - * @param {string} currentContent - Current content value - * @param {Function} onSave - Save callback - * @param {Function} onCancel - Cancel callback - */ - showEditForm(meta, currentContent, onSave, onCancel) { - // Close any existing form - this.closeForm(); - - const { element, contentId, contentType } = meta; - const config = this.getFieldConfig(element, contentType); - - // Route to unified markdown editor for markdown content - if (config.type === 'markdown') { - return this.markdownEditor.edit(element, onSave, onCancel); - } - - // Route to unified markdown editor for group elements - if (element.classList.contains('insertr-group')) { - const children = this.getGroupChildren(element); - return this.markdownEditor.edit(children, onSave, onCancel); - } - - // Handle non-markdown elements (text, links, etc.) with legacy system - return this.showLegacyEditForm(meta, currentContent, onSave, onCancel); - } - - - - - - /** - * Show legacy edit form for non-markdown elements (text, links, etc.) - */ - showLegacyEditForm(meta, currentContent, onSave, onCancel) { - const { element, contentId, contentType } = meta; - const config = this.getFieldConfig(element, contentType); - - // Initialize preview manager for this element - this.previewManager.setActiveElement(element); - - // Set up height change callback - this.previewManager.setHeightChangeCallback((changedElement) => { - this.repositionModal(changedElement, overlay); - }); - - // Create form - const form = this.createEditForm(contentId, config, currentContent); - - // Create overlay with backdrop - const overlay = this.createOverlay(form); - - // Position form with enhanced sizing - this.positionForm(element, overlay); - - // Setup event handlers with live preview - this.setupFormHandlers(form, overlay, element, config, { onSave, onCancel }); - - // Show form - document.body.appendChild(overlay); - this.currentOverlay = overlay; - - // Focus first input - const firstInput = form.querySelector('input, textarea'); - if (firstInput) { - setTimeout(() => firstInput.focus(), 100); - } - - return overlay; - } - - /** - * Get viable children from group element - */ - getGroupChildren(groupElement) { - const children = []; - for (const child of groupElement.children) { - // Skip elements that don't have text content - if (child.textContent.trim().length > 0) { - children.push(child); - } - } - return children; - } - - /** - * Close current form - */ - closeForm() { - // Close markdown editor if active - this.markdownEditor.close(); - - // Clear any active legacy previews - if (this.previewManager.activeElement) { - this.previewManager.clearPreview(this.previewManager.activeElement); - } - - if (this.currentOverlay) { - this.currentOverlay.remove(); - this.currentOverlay = null; - } - } - - /** - * Generate field configuration based on element - */ - getFieldConfig(element, contentType) { - const tagName = element.tagName.toLowerCase(); - const classList = Array.from(element.classList); - - // Default configurations based on element type - using markdown for rich content - const configs = { - h1: { type: 'text', label: 'Headline', maxLength: 60, placeholder: 'Enter headline...' }, - h2: { type: 'text', label: 'Subheading', maxLength: 80, placeholder: 'Enter subheading...' }, - h3: { type: 'markdown', label: 'Section Title', rows: 2, placeholder: 'Enter title (markdown supported)...' }, - h4: { type: 'markdown', label: 'Title', rows: 2, placeholder: 'Enter title (markdown supported)...' }, - h5: { type: 'markdown', label: 'Title', rows: 2, placeholder: 'Enter title (markdown supported)...' }, - h6: { type: 'markdown', label: 'Title', rows: 2, placeholder: 'Enter title (markdown supported)...' }, - p: { type: 'markdown', label: 'Content', rows: 4, placeholder: 'Enter content using markdown...' }, - a: { type: 'link', label: 'Link', placeholder: 'Enter link text...', includeUrl: true }, - span: { type: 'markdown', label: 'Text', rows: 2, placeholder: 'Enter text (markdown supported)...' }, - button: { type: 'text', label: 'Button Text', placeholder: 'Enter button text...' }, - }; - - let config = configs[tagName] || { type: 'text', label: 'Text', placeholder: 'Enter text...' }; - - // CSS class enhancements - if (classList.includes('lead')) { - config = { ...config, label: 'Lead Paragraph', rows: 4, placeholder: 'Enter lead paragraph...' }; - } - - // Override with contentType from CLI if specified - if (contentType === 'markdown') { - config = { ...config, type: 'markdown', label: 'Markdown Content', rows: 8 }; - } - - return config; - } - - /** - * Create form HTML structure - */ - createEditForm(contentId, config, currentContent) { - const form = document.createElement('div'); - form.className = 'insertr-edit-form'; - - let formHTML = `
    ${config.label}
    `; - - if (config.type === 'markdown') { - formHTML += this.createMarkdownField(config, currentContent); - } else if (config.type === 'link' && config.includeUrl) { - formHTML += this.createLinkField(config, currentContent); - } else if (config.type === 'textarea') { - formHTML += this.createTextareaField(config, currentContent); - } else { - formHTML += this.createTextField(config, currentContent); - } - - // Form buttons - formHTML += ` -
    - - - -
    - `; - - form.innerHTML = formHTML; - return form; - } - - /** - * Create markdown field with preview - */ - createMarkdownField(config, currentContent) { - return ` -
    - -
    - Supports Markdown formatting (bold, italic, links, etc.) -
    -
    - `; - } - - /** - * Create link field (text + URL) - */ - createLinkField(config, currentContent) { - const linkText = typeof currentContent === 'object' ? currentContent.text || '' : currentContent; - const linkUrl = typeof currentContent === 'object' ? currentContent.url || '' : ''; - - return ` -
    - - -
    -
    - - -
    - `; - } - - /** - * Create textarea field - */ - createTextareaField(config, currentContent) { - const content = typeof currentContent === 'object' ? currentContent.text || '' : currentContent; - return ` -
    - -
    - `; - } - - /** - * Create text input field - */ - createTextField(config, currentContent) { - const content = typeof currentContent === 'object' ? currentContent.text || '' : currentContent; - return ` -
    - -
    - `; - } - - /** - * Create overlay with backdrop - */ - createOverlay(form) { - const overlay = document.createElement('div'); - overlay.className = 'insertr-form-overlay'; - overlay.appendChild(form); - return overlay; - } - - /** - * Get element ID for preview tracking - */ - getElementId(element) { - return element.id || element.getAttribute('data-content-id') || - `element-${element.tagName}-${Date.now()}`; - } - - /** - * Show version history modal - */ - async showVersionHistory(contentId, element, onRestore) { - try { - // Get version history from API (we'll need to pass this in) - const apiClient = this.getApiClient(); - const versions = await apiClient.getContentVersions(contentId); - - // Create version history modal - const historyModal = this.createVersionHistoryModal(contentId, versions, onRestore); - document.body.appendChild(historyModal); - - // Focus and setup handlers - this.setupVersionHistoryHandlers(historyModal, contentId); - - } catch (error) { - console.error('Failed to load version history:', error); - this.showVersionHistoryError('Failed to load version history. Please try again.'); - } - } - - /** - * Create version history modal - */ - createVersionHistoryModal(contentId, versions, onRestore) { - const modal = document.createElement('div'); - modal.className = 'insertr-version-modal'; - - let versionsHTML = ''; - if (versions && versions.length > 0) { - versionsHTML = versions.map((version, index) => ` -
    -
    - ${index === 0 ? 'Previous Version' : `Version ${versions.length - index}`} - ${this.formatDate(version.created_at)} - ${version.created_by ? `by ${version.created_by}` : ''} -
    -
    ${this.escapeHtml(this.truncateContent(version.value, 100))}
    -
    - - -
    -
    - `).join(''); - } else { - versionsHTML = '
    No previous versions found
    '; - } - - modal.innerHTML = ` -
    -
    -
    -

    Version History

    - -
    -
    - ${versionsHTML} -
    -
    -
    - `; - - return modal; - } - - /** - * Setup version history modal handlers - */ - setupVersionHistoryHandlers(modal, contentId) { - const closeBtn = modal.querySelector('.insertr-btn-close'); - const backdrop = modal.querySelector('.insertr-version-backdrop'); - - // Close handlers - if (closeBtn) { - closeBtn.addEventListener('click', () => modal.remove()); - } - - backdrop.addEventListener('click', (e) => { - if (e.target === backdrop) { - modal.remove(); - } - }); - - // Restore handlers - const restoreButtons = modal.querySelectorAll('.insertr-btn-restore'); - restoreButtons.forEach(btn => { - btn.addEventListener('click', async () => { - const versionId = btn.getAttribute('data-version-id'); - if (await this.confirmRestore()) { - await this.restoreVersion(contentId, versionId); - modal.remove(); - // Refresh the current form or close it - this.closeForm(); - } - }); - }); - - // View diff handlers - const viewButtons = modal.querySelectorAll('.insertr-btn-view-diff'); - viewButtons.forEach(btn => { - btn.addEventListener('click', () => { - const versionId = btn.getAttribute('data-version-id'); - this.showVersionDetails(versionId); - }); - }); - } - - /** - * Helper methods for version history - */ - formatDate(dateString) { - const date = new Date(dateString); - const now = new Date(); - const diff = now - date; - - // Less than 24 hours ago - if (diff < 24 * 60 * 60 * 1000) { - const hours = Math.floor(diff / (60 * 60 * 1000)); - if (hours < 1) { - const minutes = Math.floor(diff / (60 * 1000)); - return `${minutes}m ago`; - } - return `${hours}h ago`; - } - - // Less than 7 days ago - if (diff < 7 * 24 * 60 * 60 * 1000) { - const days = Math.floor(diff / (24 * 60 * 60 * 1000)); - return `${days}d ago`; - } - - // Older - show actual date - return date.toLocaleDateString(); - } - - truncateContent(content, maxLength) { - if (content.length <= maxLength) return content; - return content.substring(0, maxLength) + '...'; - } - - async confirmRestore() { - return confirm('Are you sure you want to restore this version? This will replace the current content.'); - } - - async restoreVersion(contentId, versionId) { - try { - const apiClient = this.getApiClient(); - await apiClient.rollbackContent(contentId, versionId); - return true; - } catch (error) { - console.error('Failed to restore version:', error); - alert('Failed to restore version. Please try again.'); - return false; - } - } - - showVersionDetails(versionId) { - // TODO: Implement detailed version view with diff - alert(`Version details not implemented yet (Version ID: ${versionId})`); - } - - showVersionHistoryError(message) { - alert(message); - } - - // Helper to get API client - getApiClient() { - return this.apiClient || window.insertrAPIClient || null; - } - - /** - * Reposition modal based on current element size and ensure visibility - */ - repositionModal(element, overlay) { - // Wait for next frame to ensure DOM is updated - requestAnimationFrame(() => { - const rect = element.getBoundingClientRect(); - overlay.querySelector('.insertr-edit-form'); - - // Calculate new position below the current element boundaries - const newTop = rect.bottom + window.scrollY + 10; - - // Update modal position - overlay.style.top = `${newTop}px`; - - // After repositioning, ensure modal is still visible - this.ensureModalVisible(element, overlay); - }); - } - - /** - * Ensure modal is fully visible by scrolling viewport if necessary - */ - ensureModalVisible(element, overlay) { - // Wait for next frame to ensure DOM is updated - requestAnimationFrame(() => { - const modal = overlay.querySelector('.insertr-edit-form'); - const modalRect = modal.getBoundingClientRect(); - const viewportHeight = window.innerHeight; - - // Calculate if modal extends below viewport - const modalBottom = modalRect.bottom; - const viewportBottom = viewportHeight; - - if (modalBottom > viewportBottom) { - // Calculate scroll amount needed with some padding - const scrollAmount = modalBottom - viewportBottom + 20; - - window.scrollBy({ - top: scrollAmount, - behavior: 'smooth' - }); - } - }); - } - - /** - * Setup form event handlers - */ - setupFormHandlers(form, overlay, element, config, { onSave, onCancel }) { - const saveBtn = form.querySelector('.insertr-btn-save'); - const cancelBtn = form.querySelector('.insertr-btn-cancel'); - const elementType = this.getElementType(element, config); - - // Setup live preview for input changes - this.setupLivePreview(form, element, elementType); - - if (saveBtn) { - saveBtn.addEventListener('click', () => { - // Clear preview before saving (makes changes permanent) - this.previewManager.clearPreview(element); - const formData = this.extractFormData(form); - onSave(formData); - this.closeForm(); - }); - } - - if (cancelBtn) { - cancelBtn.addEventListener('click', () => { - // Clear preview to restore original content - this.previewManager.clearPreview(element); - onCancel(); - this.closeForm(); - }); - } - - // Version History button - const historyBtn = form.querySelector('.insertr-btn-history'); - if (historyBtn) { - historyBtn.addEventListener('click', () => { - const contentId = historyBtn.getAttribute('data-content-id'); - this.showVersionHistory(contentId, element, onSave); - }); - } - - // ESC key to cancel - const keyHandler = (e) => { - if (e.key === 'Escape') { - this.previewManager.clearPreview(element); - onCancel(); - this.closeForm(); - document.removeEventListener('keydown', keyHandler); - } - }; - document.addEventListener('keydown', keyHandler); - - // Click outside to cancel - overlay.addEventListener('click', (e) => { - if (e.target === overlay) { - this.previewManager.clearPreview(element); - onCancel(); - this.closeForm(); - } - }); - } - - setupLivePreview(form, element, elementType) { - // Get all input elements that should trigger preview updates - const inputs = form.querySelectorAll('input, textarea'); - - inputs.forEach(input => { - input.addEventListener('input', () => { - const newValue = this.extractInputValue(form, elementType); - this.previewManager.schedulePreview(element, newValue, elementType); - }); - }); - } - - extractInputValue(form, elementType) { - // Extract current form values for preview - const textInput = form.querySelector('input[name="text"]'); - const urlInput = form.querySelector('input[name="url"]'); - const contentInput = form.querySelector('input[name="content"], textarea[name="content"]'); - - if (textInput && urlInput) { - // Link field - return { - text: textInput.value, - url: urlInput.value - }; - } else if (contentInput) { - // Text or textarea field - return contentInput.value; - } - - return ''; - } - - getElementType(element, config) { - // Determine element type for preview handling - if (config.type === 'link') return 'link'; - if (config.type === 'markdown') return 'markdown'; - if (config.type === 'textarea') return 'textarea'; - - const tagName = element.tagName.toLowerCase(); - return tagName === 'p' ? 'p' : 'text'; - } - - /** - * Extract form data - */ - extractFormData(form) { - const data = {}; - - // Handle different field types - const textInput = form.querySelector('input[name="text"]'); - const urlInput = form.querySelector('input[name="url"]'); - const contentInput = form.querySelector('input[name="content"], textarea[name="content"]'); - - if (textInput && urlInput) { - // Link field - data.text = textInput.value; - data.url = urlInput.value; - } else if (contentInput) { - // Text or textarea field - data.text = contentInput.value; - } - - return data; - } - - /** - * Escape HTML to prevent XSS - */ - escapeHtml(text) { - if (typeof text !== 'string') return ''; - const div = document.createElement('div'); - div.textContent = text; - return div.innerHTML; - } - - /** - * Setup form styles - */ - setupStyles() { - const styles = ` - .insertr-form-overlay { - position: absolute; - z-index: 10000; - } - - .insertr-edit-form { - background: white; - border: 2px solid #007cba; - border-radius: 8px; - padding: 1rem; - box-shadow: 0 8px 25px rgba(0,0,0,0.15); - width: 100%; - box-sizing: border-box; - font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; - } - - .insertr-form-header { - font-weight: 600; - color: #1f2937; - margin-bottom: 1rem; - padding-bottom: 0.5rem; - border-bottom: 1px solid #e5e7eb; - font-size: 0.875rem; - text-transform: uppercase; - letter-spacing: 0.5px; - } - - .insertr-form-group { - margin-bottom: 1rem; - } - - .insertr-form-group:last-child { - margin-bottom: 0; - } - - .insertr-form-label { - display: block; - font-weight: 600; - color: #374151; - margin-bottom: 0.5rem; - font-size: 0.875rem; - } - - .insertr-form-input, - .insertr-form-textarea { - width: 100%; - padding: 0.75rem; - border: 1px solid #d1d5db; - border-radius: 6px; - font-family: inherit; - font-size: 1rem; - transition: border-color 0.2s, box-shadow 0.2s; - box-sizing: border-box; - } - - .insertr-form-input:focus, - .insertr-form-textarea:focus { - outline: none; - border-color: #007cba; - box-shadow: 0 0 0 3px rgba(0, 124, 186, 0.1); - } - - .insertr-form-textarea { - min-height: 120px; - resize: vertical; - font-family: 'SF Mono', Monaco, 'Cascadia Code', monospace; - } - - .insertr-markdown-editor { - min-height: 200px; - font-family: 'SF Mono', Monaco, 'Cascadia Code', monospace; - font-size: 0.9rem; - line-height: 1.5; - background-color: #f8fafc; - } - - .insertr-form-actions { - display: flex; - gap: 0.5rem; - justify-content: flex-end; - margin-top: 1rem; - padding-top: 1rem; - border-top: 1px solid #e5e7eb; - } - - .insertr-btn-save { - background: #10b981; - color: white; - border: none; - padding: 0.5rem 1rem; - border-radius: 6px; - font-weight: 500; - cursor: pointer; - transition: background-color 0.2s; - font-size: 0.875rem; - } - - .insertr-btn-save:hover { - background: #059669; - } - - .insertr-btn-cancel { - background: #6b7280; - color: white; - border: none; - padding: 0.5rem 1rem; - border-radius: 6px; - font-weight: 500; - cursor: pointer; - transition: background-color 0.2s; - font-size: 0.875rem; - } - - .insertr-btn-cancel:hover { - background: #4b5563; - } - - .insertr-form-help { - font-size: 0.75rem; - color: #6b7280; - margin-top: 0.25rem; - } - - /* Live Preview Styles */ - .insertr-preview-active { - position: relative; - background: rgba(0, 124, 186, 0.05) !important; - outline: 2px solid #007cba !important; - outline-offset: 2px; - transition: all 0.3s ease; - } - - .insertr-preview-active::after { - content: "Preview"; - position: absolute; - top: -25px; - left: 0; - background: #007cba; - color: white; - padding: 2px 8px; - border-radius: 3px; - font-size: 0.75rem; - font-weight: 500; - z-index: 10001; - white-space: nowrap; - font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; - } - - /* Enhanced modal sizing for comfortable editing */ - .insertr-edit-form { - min-width: 600px; /* Ensures ~70 character width */ - max-width: 800px; - } - - @media (max-width: 768px) { - .insertr-edit-form { - min-width: 90vw; - max-width: 90vw; - } - - .insertr-preview-active::after { - top: -20px; - font-size: 0.7rem; - padding: 1px 6px; - } - } - - /* Enhanced input styling for comfortable editing */ - .insertr-form-input { - font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, monospace; - letter-spacing: 0.02em; - } - `; - - const styleSheet = document.createElement('style'); - styleSheet.type = 'text/css'; - styleSheet.innerHTML = styles; - document.head.appendChild(styleSheet); - } - } - - /** - * InsertrEditor - Visual editing functionality - */ - class InsertrEditor { - constructor(core, auth, apiClient, options = {}) { - this.core = core; - this.auth = auth; - this.apiClient = apiClient; - this.options = options; - this.isActive = false; - this.formRenderer = new InsertrFormRenderer(apiClient); - } - - start() { - if (this.isActive) return; - - console.log('🚀 Starting Insertr Editor'); - this.isActive = true; - - // Add editor styles - this.addEditorStyles(); - - // Initialize all enhanced elements - const elements = this.core.getAllElements(); - console.log(`📝 Found ${elements.length} editable elements`); - - elements.forEach(meta => this.initializeElement(meta)); - } - - initializeElement(meta) { - const { element, contentId, contentType } = meta; - - // Add visual indicators - element.style.cursor = 'pointer'; - element.style.position = 'relative'; - - // Add interaction handlers - this.addHoverEffects(element); - this.addClickHandler(element, meta); - } - - addHoverEffects(element) { - element.addEventListener('mouseenter', () => { - element.classList.add('insertr-editing-hover'); - }); - - element.addEventListener('mouseleave', () => { - element.classList.remove('insertr-editing-hover'); - }); - } - - addClickHandler(element, meta) { - element.addEventListener('click', (e) => { - // Only allow editing if authenticated and in edit mode - if (!this.auth.isAuthenticated() || !this.auth.isEditMode()) { - return; // Let normal click behavior happen - } - - e.preventDefault(); - this.openEditor(meta); - }); - } - - openEditor(meta) { - const { element } = meta; - const currentContent = this.extractCurrentContent(element); - - // Show professional form instead of prompt - this.formRenderer.showEditForm( - meta, - currentContent, - (formData) => this.handleSave(meta, formData), - () => this.handleCancel(meta) - ); - } - - extractCurrentContent(element) { - // For links, extract both text and URL - if (element.tagName.toLowerCase() === 'a') { - return { - text: element.textContent.trim(), - url: element.getAttribute('href') || '' - }; - } - - // For other elements, just return text content - return element.textContent.trim(); - } - - async handleSave(meta, formData) { - console.log('💾 Saving content:', meta.contentId, formData); - - try { - // Extract content value based on type - let contentValue; - if (meta.element.tagName.toLowerCase() === 'a') { - // For links, save the text content (URL is handled separately if needed) - contentValue = formData.text || formData; - } else { - contentValue = formData.text || formData; - } - - // Try to update existing content first - const updateSuccess = await this.apiClient.updateContent(meta.contentId, contentValue); - - if (!updateSuccess) { - // If update fails, try to create new content - const contentType = this.determineContentType(meta.element); - const createSuccess = await this.apiClient.createContent(meta.contentId, contentValue, contentType); - - if (!createSuccess) { - console.error('❌ Failed to save content to server:', meta.contentId); - // Still update the UI optimistically - } - } - - // Update element content regardless of API success (optimistic update) - this.updateElementContent(meta.element, formData); - - // Close form - this.formRenderer.closeForm(); - - console.log(`✅ Content saved:`, meta.contentId, contentValue); - - } catch (error) { - console.error('❌ Error saving content:', error); - - // Still update the UI even if API fails - this.updateElementContent(meta.element, formData); - this.formRenderer.closeForm(); - } - } - - determineContentType(element) { - const tagName = element.tagName.toLowerCase(); - - if (tagName === 'a' || tagName === 'button') { - return 'link'; - } - - if (tagName === 'p' || tagName === 'div') { - return 'markdown'; - } - - // Default to text for headings and other elements - return 'text'; - } - - handleCancel(meta) { - console.log('❌ Edit cancelled:', meta.contentId); - } - - updateElementContent(element, formData) { - // Skip updating markdown elements and groups - they're handled by the unified markdown editor - if (element.classList.contains('insertr-group') || this.isMarkdownElement(element)) { - console.log('🔄 Skipping element update - handled by unified markdown editor'); - return; - } - - if (element.tagName.toLowerCase() === 'a') { - // Update link element - if (formData.text !== undefined) { - element.textContent = formData.text; - } - if (formData.url !== undefined) { - element.setAttribute('href', formData.url); - } - } else { - // Update text content for non-markdown elements - element.textContent = formData.text || ''; - } - } - - isMarkdownElement(element) { - // Check if element uses markdown based on form config - const markdownTags = new Set(['p', 'h3', 'h4', 'h5', 'h6', 'span']); - return markdownTags.has(element.tagName.toLowerCase()); - } - addEditorStyles() { - const styles = ` - .insertr-editing-hover { - outline: 2px dashed #007cba !important; - outline-offset: 2px !important; - background-color: rgba(0, 124, 186, 0.05) !important; - } - - .insertr: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; - } - - /* Version History Modal Styles */ - .insertr-version-modal { - position: fixed; - top: 0; - left: 0; - right: 0; - bottom: 0; - z-index: 10001; - } - - .insertr-version-backdrop { - position: absolute; - top: 0; - left: 0; - right: 0; - bottom: 0; - background-color: rgba(0, 0, 0, 0.5); - display: flex; - align-items: center; - justify-content: center; - padding: 20px; - } - - .insertr-version-content-modal { - background: white; - border-radius: 8px; - max-width: 600px; - width: 100%; - max-height: 80vh; - box-shadow: 0 10px 25px rgba(0, 0, 0, 0.2); - display: flex; - flex-direction: column; - } - - .insertr-version-header { - padding: 20px 20px 0; - border-bottom: 1px solid #eee; - display: flex; - justify-content: space-between; - align-items: center; - flex-shrink: 0; - } - - .insertr-version-header h3 { - margin: 0 0 20px; - color: #333; - font-size: 18px; - } - - .insertr-btn-close { - background: none; - border: none; - font-size: 24px; - cursor: pointer; - color: #666; - padding: 0; - width: 30px; - height: 30px; - display: flex; - align-items: center; - justify-content: center; - } - - .insertr-btn-close:hover { - color: #333; - } - - .insertr-version-list { - overflow-y: auto; - padding: 20px; - flex: 1; - } - - .insertr-version-item { - border: 1px solid #e1e5e9; - border-radius: 6px; - padding: 16px; - margin-bottom: 12px; - background: #f8f9fa; - } - - .insertr-version-meta { - display: flex; - align-items: center; - gap: 12px; - margin-bottom: 8px; - font-size: 13px; - } - - .insertr-version-label { - font-weight: 600; - color: #0969da; - } - - .insertr-version-date { - color: #656d76; - } - - .insertr-version-user { - color: #656d76; - } - - .insertr-version-content { - margin-bottom: 12px; - padding: 8px; - background: white; - border-radius: 4px; - font-family: monospace; - font-size: 14px; - color: #24292f; - white-space: pre-wrap; - } - - .insertr-version-actions { - display: flex; - gap: 8px; - } - - .insertr-btn-restore { - background: #0969da; - color: white; - border: none; - padding: 6px 12px; - border-radius: 4px; - cursor: pointer; - font-size: 12px; - font-weight: 500; - } - - .insertr-btn-restore:hover { - background: #0860ca; - } - - .insertr-btn-view-diff { - background: #f6f8fa; - color: #24292f; - border: 1px solid #d1d9e0; - padding: 6px 12px; - border-radius: 4px; - cursor: pointer; - font-size: 12px; - font-weight: 500; - } - - .insertr-btn-view-diff:hover { - background: #f3f4f6; - } - - .insertr-version-empty { - text-align: center; - color: #656d76; - font-style: italic; - padding: 40px 20px; - } - - /* History Button in Form */ - .insertr-btn-history { - background: #6f42c1; - color: white; - border: none; - padding: 8px 16px; - border-radius: 4px; - cursor: pointer; - font-size: 14px; - font-weight: 500; - margin-left: auto; - } - - .insertr-btn-history:hover { - background: #5a359a; - } - `; - - const styleSheet = document.createElement('style'); - styleSheet.type = 'text/css'; - styleSheet.innerHTML = styles; - document.head.appendChild(styleSheet); - } - } - - /** - * InsertrAuth - Authentication and state management - * Handles user authentication, edit mode, and visual state changes - */ - class InsertrAuth { - constructor(options = {}) { - this.options = { - mockAuth: options.mockAuth !== false, // Enable mock auth by default - hideGatesAfterAuth: options.hideGatesAfterAuth === true, // Keep gates visible by default - ...options - }; - - // Authentication state - this.state = { - isAuthenticated: false, - editMode: false, - currentUser: null, - activeEditor: null, - isInitialized: false, - isAuthenticating: false - }; - - this.statusIndicator = null; - } - - /** - * Initialize gate system (called on page load) - */ - init() { - console.log('🔧 Insertr: Scanning for editor gates'); - - this.setupEditorGates(); - } - - /** - * Initialize full editing system (called after successful OAuth) - */ - initializeFullSystem() { - if (this.state.isInitialized) { - return; // Already initialized - } - - console.log('🔐 Initializing Insertr Editing System'); - - this.createAuthControls(); - this.setupAuthenticationControls(); - this.createStatusIndicator(); - this.updateBodyClasses(); - - // Auto-enable edit mode after OAuth - this.state.editMode = true; - this.state.isInitialized = true; - - // Start the editor system - if (window.Insertr && window.Insertr.startEditor) { - window.Insertr.startEditor(); - } - - this.updateButtonStates(); - this.updateStatusIndicator(); - - console.log('📱 Editing system active - Controls in bottom-right corner'); - console.log('✏️ Edit mode enabled - Click elements to edit'); - } - - /** - * Setup editor gate click handlers for any .insertr-gate elements - */ - setupEditorGates() { - const gates = document.querySelectorAll('.insertr-gate'); - - if (gates.length === 0) { - console.log('ℹ️ No .insertr-gate elements found - editor access disabled'); - return; - } - - console.log(`🚪 Found ${gates.length} editor gate(s)`); - - // Add gate styles - this.addGateStyles(); - - gates.forEach((gate, index) => { - // Store original text for later restoration - if (!gate.hasAttribute('data-original-text')) { - gate.setAttribute('data-original-text', gate.textContent); - } - - gate.addEventListener('click', (e) => { - e.preventDefault(); - this.handleGateClick(gate, index); - }); - - // Add subtle styling to indicate it's clickable - gate.style.cursor = 'pointer'; - }); - } - - /** - * Handle click on an editor gate element - */ - async handleGateClick(gateElement, gateIndex) { - // Prevent multiple simultaneous authentication attempts - if (this.state.isAuthenticating) { - console.log('⏳ Authentication already in progress...'); - return; - } - - console.log(`🚀 Editor gate activated (gate ${gateIndex + 1})`); - this.state.isAuthenticating = true; - - // Store original text and show loading state - const originalText = gateElement.textContent; - gateElement.setAttribute('data-original-text', originalText); - gateElement.textContent = '⏳ Signing in...'; - gateElement.style.pointerEvents = 'none'; - - try { - // Perform OAuth authentication - await this.performOAuthFlow(); - - // Initialize full editing system - this.initializeFullSystem(); - - // Conditionally hide gates based on options - if (this.options.hideGatesAfterAuth) { - this.hideAllGates(); - } else { - this.updateGateState(); - } - - } catch (error) { - console.error('❌ Authentication failed:', error); - - // Restore clicked gate to original state - const originalText = gateElement.getAttribute('data-original-text'); - if (originalText) { - gateElement.textContent = originalText; - } - gateElement.style.pointerEvents = ''; - } finally { - this.state.isAuthenticating = false; - } - } - - /** - * Perform OAuth authentication flow - */ - async performOAuthFlow() { - // In development, simulate OAuth flow - if (this.options.mockAuth) { - console.log('🔐 Mock OAuth: Simulating authentication...'); - - // Simulate network delay - await new Promise(resolve => setTimeout(resolve, 1000)); - - // Set authenticated state - this.state.isAuthenticated = true; - this.state.currentUser = { - name: 'Site Owner', - email: 'owner@example.com', - role: 'admin' - }; - - console.log('✅ Mock OAuth: Authentication successful'); - return; - } - - // TODO: In production, implement real OAuth flow - // This would redirect to OAuth provider, handle callback, etc. - throw new Error('Production OAuth not implemented yet'); - } - - /** - * Hide all editor gates after successful authentication (optional) - */ - hideAllGates() { - document.body.classList.add('insertr-hide-gates'); - console.log('🚪 Editor gates hidden (hideGatesAfterAuth enabled)'); - } - - /** - * Update gate state after authentication (restore normal appearance) - */ - updateGateState() { - const gates = document.querySelectorAll('.insertr-gate'); - gates.forEach(gate => { - // Restore original text if it was saved - const originalText = gate.getAttribute('data-original-text'); - if (originalText) { - gate.textContent = originalText; - } - - // Restore interactive state - gate.style.pointerEvents = ''; - gate.style.opacity = ''; - }); - - console.log('🚪 Editor gates restored to original state'); - } - - /** - * Create authentication control buttons (bottom-right positioned) - */ - createAuthControls() { - // Check if controls already exist - if (document.getElementById('insertr-auth-controls')) { - return; - } - - const controlsHtml = ` -
    - - -
    - `; - - // Add controls to page - document.body.insertAdjacentHTML('beforeend', controlsHtml); - - // Add styles for controls - this.addControlStyles(); - } - - /** - * Setup event listeners for authentication controls - */ - setupAuthenticationControls() { - const authToggle = document.getElementById('insertr-auth-toggle'); - const editToggle = document.getElementById('insertr-edit-toggle'); - - if (authToggle) { - authToggle.addEventListener('click', () => this.toggleAuthentication()); - } - - if (editToggle) { - editToggle.addEventListener('click', () => this.toggleEditMode()); - } - } - - /** - * Toggle authentication state - */ - toggleAuthentication() { - this.state.isAuthenticated = !this.state.isAuthenticated; - this.state.currentUser = this.state.isAuthenticated ? { - name: 'Demo User', - email: 'demo@example.com', - role: 'editor' - } : null; - - // Reset edit mode when logging out - if (!this.state.isAuthenticated) { - this.state.editMode = false; - } - - this.updateBodyClasses(); - this.updateButtonStates(); - this.updateStatusIndicator(); - - console.log(this.state.isAuthenticated - ? '✅ Authenticated as Demo User' - : '❌ Logged out'); - } - - /** - * Toggle edit mode (only when authenticated) - */ - toggleEditMode() { - if (!this.state.isAuthenticated) { - console.warn('❌ Cannot enable edit mode - not authenticated'); - return; - } - - this.state.editMode = !this.state.editMode; - - // Cancel any active editing when turning off edit mode - if (!this.state.editMode && this.state.activeEditor) { - // This would be handled by the main editor - this.state.activeEditor = null; - } - - this.updateBodyClasses(); - this.updateButtonStates(); - this.updateStatusIndicator(); - - console.log(this.state.editMode - ? '✏️ Edit mode ON - Click elements to edit' - : '👀 Edit mode OFF - Read-only view'); - } - - /** - * Update body CSS classes based on authentication state - */ - updateBodyClasses() { - document.body.classList.toggle('insertr-authenticated', this.state.isAuthenticated); - document.body.classList.toggle('insertr-edit-mode', this.state.editMode); - } - - /** - * Update button text and visibility - */ - updateButtonStates() { - const authBtn = document.getElementById('insertr-auth-toggle'); - const editBtn = document.getElementById('insertr-edit-toggle'); - - if (authBtn) { - authBtn.textContent = this.state.isAuthenticated ? 'Logout' : 'Login as Client'; - authBtn.className = `insertr-auth-btn ${this.state.isAuthenticated ? 'insertr-authenticated' : ''}`; - } - - if (editBtn) { - editBtn.style.display = this.state.isAuthenticated ? 'inline-block' : 'none'; - editBtn.textContent = `Edit Mode: ${this.state.editMode ? 'On' : 'Off'}`; - editBtn.className = `insertr-auth-btn ${this.state.editMode ? 'insertr-edit-active' : ''}`; - } - } - - /** - * Create status indicator - */ - createStatusIndicator() { - // Check if already exists - if (document.getElementById('insertr-status')) { - return; - } - - const statusHtml = ` -
    -
    - Visitor Mode - -
    -
    - `; - - document.body.insertAdjacentHTML('beforeend', statusHtml); - this.statusIndicator = document.getElementById('insertr-status'); - this.updateStatusIndicator(); - } - - /** - * Update status indicator text and style - */ - updateStatusIndicator() { - const statusText = document.querySelector('.insertr-status-text'); - const statusDot = document.querySelector('.insertr-status-dot'); - - if (!statusText || !statusDot) return; - - if (!this.state.isAuthenticated) { - statusText.textContent = 'Visitor Mode'; - statusDot.className = 'insertr-status-dot insertr-status-visitor'; - } else if (this.state.editMode) { - statusText.textContent = 'Editing'; - statusDot.className = 'insertr-status-dot insertr-status-editing'; - } else { - statusText.textContent = 'Authenticated'; - statusDot.className = 'insertr-status-dot insertr-status-authenticated'; - } - } - - /** - * Check if user is authenticated - */ - isAuthenticated() { - return this.state.isAuthenticated; - } - - /** - * Check if edit mode is enabled - */ - isEditMode() { - return this.state.editMode; - } - - /** - * Get current user info - */ - getCurrentUser() { - return this.state.currentUser; - } - - /** - * Add minimal styles for editor gates - */ - addGateStyles() { - const styles = ` - .insertr-gate { - transition: opacity 0.2s ease; - user-select: none; - } - - .insertr-gate:hover { - opacity: 0.7; - } - - /* Optional: Hide gates when authenticated (only if hideGatesAfterAuth option is true) */ - body.insertr-hide-gates .insertr-gate { - display: none !important; - } - `; - - const styleSheet = document.createElement('style'); - styleSheet.type = 'text/css'; - styleSheet.innerHTML = styles; - document.head.appendChild(styleSheet); - } - - /** - * Add styles for authentication controls - */ - addControlStyles() { - const styles = ` - .insertr-auth-controls { - position: fixed; - bottom: 20px; - right: 20px; - z-index: 9999; - display: flex; - flex-direction: column; - gap: 8px; - font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; - } - - .insertr-auth-btn { - background: #4f46e5; - color: white; - border: none; - padding: 8px 16px; - border-radius: 6px; - font-size: 14px; - font-weight: 500; - cursor: pointer; - transition: all 0.2s; - box-shadow: 0 2px 4px rgba(0,0,0,0.1); - } - - .insertr-auth-btn:hover { - background: #4338ca; - transform: translateY(-1px); - box-shadow: 0 4px 8px rgba(0,0,0,0.15); - } - - .insertr-auth-btn.insertr-authenticated { - background: #059669; - } - - .insertr-auth-btn.insertr-authenticated:hover { - background: #047857; - } - - .insertr-auth-btn.insertr-edit-active { - background: #dc2626; - } - - .insertr-auth-btn.insertr-edit-active:hover { - background: #b91c1c; - } - - .insertr-status { - position: fixed; - bottom: 20px; - left: 20px; - z-index: 9999; - background: white; - border: 1px solid #e5e7eb; - border-radius: 8px; - padding: 8px 12px; - box-shadow: 0 4px 12px rgba(0,0,0,0.1); - font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; - max-width: 200px; - } - - .insertr-status-content { - display: flex; - align-items: center; - gap: 8px; - } - - .insertr-status-text { - font-size: 12px; - font-weight: 500; - color: #374151; - } - - .insertr-status-dot { - width: 8px; - height: 8px; - border-radius: 50%; - background: #9ca3af; - } - - .insertr-status-dot.insertr-status-visitor { - background: #9ca3af; - } - - .insertr-status-dot.insertr-status-authenticated { - background: #059669; - } - - .insertr-status-dot.insertr-status-editing { - background: #dc2626; - animation: insertr-pulse 2s infinite; - } - - @keyframes insertr-pulse { - 0%, 100% { opacity: 1; } - 50% { opacity: 0.5; } - } - - /* Hide editing interface when not in edit mode */ - body:not(.insertr-edit-mode) .insertr:hover::after { - display: none !important; - } - - /* Only show editing features when in edit mode */ - .insertr-authenticated.insertr-edit-mode .insertr { - cursor: pointer; - } - - .insertr-authenticated.insertr-edit-mode .insertr:hover { - outline: 2px dashed #007cba !important; - outline-offset: 2px !important; - background-color: rgba(0, 124, 186, 0.05) !important; - } - `; - - const styleSheet = document.createElement('style'); - styleSheet.type = 'text/css'; - styleSheet.innerHTML = styles; - document.head.appendChild(styleSheet); - } - - /** - * OAuth integration placeholder - * In production, this would handle real OAuth flows - */ - async authenticateWithOAuth(provider = 'google') { - // Mock OAuth flow for now - console.log(`🔐 Mock OAuth login with ${provider}`); - - // Simulate OAuth callback - setTimeout(() => { - this.state.isAuthenticated = true; - this.state.currentUser = { - name: 'OAuth User', - email: 'user@example.com', - provider: provider, - role: 'editor' - }; - - this.updateBodyClasses(); - this.updateButtonStates(); - this.updateStatusIndicator(); - - console.log('✅ OAuth authentication successful'); - }, 1000); - } - } - - /** - * ApiClient - Handle communication with content API - */ - class ApiClient { - constructor(options = {}) { - // Smart server detection based on environment - const isDevelopment = window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1'; - const defaultEndpoint = isDevelopment - ? 'http://localhost:8080/api/content' // Development: separate API server - : '/api/content'; // Production: same-origin API - - this.baseUrl = options.apiEndpoint || defaultEndpoint; - this.siteId = options.siteId || 'demo'; - - // Log API configuration in development - if (isDevelopment && !options.apiEndpoint) { - console.log(`🔌 API Client: Using development server at ${this.baseUrl}`); - } - } - - async getContent(contentId) { - try { - const response = await fetch(`${this.baseUrl}/${contentId}?site_id=${this.siteId}`); - return response.ok ? await response.json() : null; - } catch (error) { - console.warn('Failed to fetch content:', contentId, error); - return null; - } - } - - async updateContent(contentId, content) { - try { - const response = await fetch(`${this.baseUrl}/${contentId}?site_id=${this.siteId}`, { - method: 'PUT', - headers: { - 'Content-Type': 'application/json', - 'X-User-ID': this.getCurrentUser() - }, - body: JSON.stringify({ value: content }) - }); - - if (response.ok) { - console.log(`✅ Content updated: ${contentId}`); - return true; - } else { - console.warn(`⚠️ Update failed (${response.status}): ${contentId}`); - return false; - } - } catch (error) { - // Provide helpful error message for common development issues - if (error.name === 'TypeError' && error.message.includes('fetch')) { - console.warn(`🔌 API Server not reachable at ${this.baseUrl}`); - console.warn('💡 Start full-stack development: just dev'); - } else { - console.error('Failed to update content:', contentId, error); - } - return false; - } - } - - async createContent(contentId, content, type) { - try { - const response = await fetch(`${this.baseUrl}?site_id=${this.siteId}`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'X-User-ID': this.getCurrentUser() - }, - body: JSON.stringify({ - id: contentId, - value: content, - type: type - }) - }); - - if (response.ok) { - console.log(`✅ Content created: ${contentId} (${type})`); - return true; - } else { - console.warn(`⚠️ Create failed (${response.status}): ${contentId}`); - return false; - } - } catch (error) { - if (error.name === 'TypeError' && error.message.includes('fetch')) { - console.warn(`🔌 API Server not reachable at ${this.baseUrl}`); - console.warn('💡 Start full-stack development: just dev'); - } else { - console.error('Failed to create content:', contentId, error); - } - return false; - } - } - - async getContentVersions(contentId) { - try { - const response = await fetch(`${this.baseUrl}/${contentId}/versions?site_id=${this.siteId}`); - if (response.ok) { - const result = await response.json(); - return result.versions || []; - } else { - console.warn(`⚠️ Failed to fetch versions (${response.status}): ${contentId}`); - return []; - } - } catch (error) { - console.error('Failed to fetch version history:', contentId, error); - return []; - } - } - - async rollbackContent(contentId, versionId) { - try { - const response = await fetch(`${this.baseUrl}/${contentId}/rollback?site_id=${this.siteId}`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'X-User-ID': this.getCurrentUser() - }, - body: JSON.stringify({ - version_id: versionId - }) - }); - - if (response.ok) { - console.log(`✅ Content rolled back: ${contentId} to version ${versionId}`); - return await response.json(); - } else { - console.warn(`⚠️ Rollback failed (${response.status}): ${contentId}`); - return false; - } - } catch (error) { - console.error('Failed to rollback content:', contentId, error); - return false; - } - } - - // Helper to get current user (for user attribution) - getCurrentUser() { - // This could be enhanced to get from authentication system - return 'anonymous'; - } - } - - /** - * Insertr - The Tailwind of CMS - * Main library entry point - */ - - - // Create global Insertr instance - window.Insertr = { - // Core functionality - core: null, - editor: null, - auth: null, - apiClient: null, - - // Initialize the library - init(options = {}) { - console.log('🔧 Insertr v1.0.0 initializing... (Hot Reload Ready)'); - - this.core = new InsertrCore(options); - this.auth = new InsertrAuth(options); - this.apiClient = new ApiClient(options); - this.editor = new InsertrEditor(this.core, this.auth, this.apiClient, options); - - // Auto-initialize if DOM is ready - if (document.readyState === 'loading') { - document.addEventListener('DOMContentLoaded', () => this.start()); - } else { - this.start(); - } - - return this; - }, - - // Start the system - only creates the minimal trigger - start() { - if (this.auth) { - this.auth.init(); // Creates footer trigger only - } - // Note: Editor is NOT started here, only when trigger is clicked - }, - - // Start the full editor system (called when trigger is activated) - startEditor() { - if (this.editor && !this.editor.isActive) { - this.editor.start(); - } - }, - - // Public API methods - login() { - return this.auth ? this.auth.toggleAuthentication() : null; - }, - - logout() { - if (this.auth && this.auth.isAuthenticated()) { - this.auth.toggleAuthentication(); - } - }, - - toggleEditMode() { - return this.auth ? this.auth.toggleEditMode() : null; - }, - - isAuthenticated() { - return this.auth ? this.auth.isAuthenticated() : false; - }, - - isEditMode() { - return this.auth ? this.auth.isEditMode() : false; - }, - - // TODO: Version info based on package.json? - }; - - // Auto-initialize in development mode with proper DOM ready handling - function autoInitialize() { - if (document.querySelector('.insertr')) { - window.Insertr.init(); - } - } - - // Run auto-initialization when DOM is ready - if (document.readyState === 'loading') { - document.addEventListener('DOMContentLoaded', autoInitialize); - } else { - // DOM is already ready - autoInitialize(); - } - - var index = window.Insertr; - - return index; - -})(); diff --git a/insertr-cli/pkg/content/assets/insertr.min.js b/insertr-cli/pkg/content/assets/insertr.min.js deleted file mode 100644 index d3a43ec..0000000 --- a/insertr-cli/pkg/content/assets/insertr.min.js +++ /dev/null @@ -1 +0,0 @@ -var Insertr=function(){"use strict";class e{constructor(e={}){this.options={apiEndpoint:e.apiEndpoint||"/api/content",siteId:e.siteId||"default",...e}}findEnhancedElements(){const e=document.querySelectorAll(".insertr"),t=[];return e.forEach(e=>{if(this.isContainer(e)&&!e.classList.contains("insertr-group")){const n=this.findViableChildren(e);t.push(...n)}else t.push(e)}),t}isContainer(e){return new Set(["div","section","article","header","footer","main","aside","nav"]).has(e.tagName.toLowerCase())}findViableChildren(e){const t=[];for(const n of e.children)n.classList.contains("insertr")||this.isSelfClosing(n)||this.hasOnlyTextContent(n)&&t.push(n);return t}hasOnlyTextContent(e){const t=new Set(["strong","b","em","i","a","span","code"]);for(const n of e.children){const e=n.tagName.toLowerCase();if(!t.has(e))return!1;if(n.children.length>0)for(const e of n.children){const n=e.tagName.toLowerCase();if(!t.has(n))return!1}}return e.textContent.trim().length>0}isSelfClosing(e){return new Set(["img","input","br","hr","meta","link","area","base","col","embed","source","track","wbr"]).has(e.tagName.toLowerCase())}getElementMetadata(e){return{contentId:e.getAttribute("data-content-id")||this.generateDeterministicId(e),contentType:e.getAttribute("data-content-type")||this.detectContentType(e),element:e}}generateTempId(e){return this.generateDeterministicId(e)}generateDeterministicId(e){const t=this.getSemanticContext(e),n=this.getPurpose(e),r=this.getContentHash(e);return this.createBaseId(t,n,r)}getSemanticContext(e){let t=e.parentElement;for(;t&&t.nodeType===Node.ELEMENT_NODE;){const e=Array.from(t.classList),n=["hero","services","nav","navbar","footer","about","contact","testimonial"];for(const t of n)if(e.includes(t))return t;const r=t.tagName.toLowerCase();if(["nav","header","footer","main","aside"].includes(r))return r;t=t.parentElement}return"content"}getPurpose(e){const t=e.tagName.toLowerCase(),n=Array.from(e.classList);for(const e of n){if(e.includes("title"))return"title";if(e.includes("headline"))return"headline";if(e.includes("description"))return"description";if(e.includes("subtitle"))return"subtitle";if(e.includes("cta"))return"cta";if(e.includes("button"))return"button";if(e.includes("logo"))return"logo";if(e.includes("lead"))return"lead"}switch(t){case"h1":return"title";case"h2":return"subtitle";case"h3":case"h4":case"h5":case"h6":return"heading";case"p":return"text";case"a":return"link";case"button":return"button";default:return"content"}}getContentHash(e){const t=e.textContent.trim();return this.sha1(t).substring(0,6)}sha1(e){const t=(new TextEncoder).encode(e),n=[1732584193,4023233417,2562383102,271733878,3285377520],r=t.length,i=new Uint8Array(64*Math.ceil((r+9)/64));i.set(t),i[r]=128;const s=8*r,o=new DataView(i.buffer);o.setUint32(i.length-4,s,!1);for(let e=0;e>>0;l=a,a=s,s=this.leftRotate(i,30),i=r,r=c}n[0]=n[0]+r>>>0,n[1]=n[1]+i>>>0,n[2]=n[2]+s>>>0,n[3]=n[3]+a>>>0,n[4]=n[4]+l>>>0}return n.map(e=>e.toString(16).padStart(8,"0")).join("")}leftRotate(e,t){return(e<>>32-t)>>>0}createBaseId(e,t,n){const r=[];"content"!==e&&r.push(e),r.push(t),r.push(n);let i=r.join("-");return i=i.replace(/-+/g,"-"),i=i.replace(/^-+|-+$/g,""),i||(i=`content-${n}`),i}detectContentType(e){const t=e.tagName.toLowerCase();if(e.classList.contains("insertr-group"))return"markdown";switch(t){case"h1":case"h2":case"h3":case"h4":case"h5":case"h6":default:return"text";case"p":return"textarea";case"a":case"button":return"link";case"div":case"section":return"markdown"}}getAllElements(){const e=document.querySelectorAll(".insertr, .insertr-group"),t=[];return e.forEach(e=>{if(e.classList.contains("insertr-group"))t.push(e);else if(this.isContainer(e)){const n=this.findViableChildren(e);t.push(...n)}else t.push(e)}),Array.from(t).map(e=>this.getElementMetadata(e))}}function t(){return{async:!1,breaks:!1,extensions:null,gfm:!0,hooks:null,pedantic:!1,renderer:null,silent:!1,tokenizer:null,walkTokens:null}}var n={async:!1,breaks:!1,extensions:null,gfm:!0,hooks:null,pedantic:!1,renderer:null,silent:!1,tokenizer:null,walkTokens:null};function r(e){n=e}var i={exec:()=>null};function s(e,t=""){let n="string"==typeof e?e:e.source,r={replace:(e,t)=>{let i="string"==typeof t?t:t.source;return i=i.replace(o.caret,"$1"),n=n.replace(e,i),r},getRegex:()=>new RegExp(n,t)};return r}var o={codeRemoveIndent:/^(?: {1,4}| {0,3}\t)/gm,outputLinkReplace:/\\([\[\]])/g,indentCodeCompensation:/^(\s+)(?:```)/,beginningSpace:/^\s+/,endingHash:/#$/,startingSpaceChar:/^ /,endingSpaceChar:/ $/,nonSpaceChar:/[^ ]/,newLineCharGlobal:/\n/g,tabCharGlobal:/\t/g,multipleSpaceGlobal:/\s+/g,blankLine:/^[ \t]*$/,doubleBlankLine:/\n[ \t]*\n[ \t]*$/,blockquoteStart:/^ {0,3}>/,blockquoteSetextReplace:/\n {0,3}((?:=+|-+) *)(?=\n|$)/g,blockquoteSetextReplace2:/^ {0,3}>[ \t]?/gm,listReplaceTabs:/^\t+/,listReplaceNesting:/^ {1,4}(?=( {4})*[^ ])/g,listIsTask:/^\[[ xX]\] /,listReplaceTask:/^\[[ xX]\] +/,anyLine:/\n.*\n/,hrefBrackets:/^<(.*)>$/,tableDelimiter:/[:|]/,tableAlignChars:/^\||\| *$/g,tableRowBlankLine:/\n[ \t]*$/,tableAlignRight:/^ *-+: *$/,tableAlignCenter:/^ *:-+: *$/,tableAlignLeft:/^ *:-+ *$/,startATag:/^
    /i,startPreScriptTag:/^<(pre|code|kbd|script)(\s|>)/i,endPreScriptTag:/^<\/(pre|code|kbd|script)(\s|>)/i,startAngleBracket:/^$/,pedanticHrefTitle:/^([^'"]*[^\s])\s+(['"])(.*)\2/,unicodeAlphaNumeric:/[\p{L}\p{N}]/u,escapeTest:/[&<>"']/,escapeReplace:/[&<>"']/g,escapeTestNoEncode:/[<>"']|&(?!(#\d{1,7}|#[Xx][a-fA-F0-9]{1,6}|\w+);)/,escapeReplaceNoEncode:/[<>"']|&(?!(#\d{1,7}|#[Xx][a-fA-F0-9]{1,6}|\w+);)/g,unescapeTest:/&(#(?:\d+)|(?:#x[0-9A-Fa-f]+)|(?:\w+));?/gi,caret:/(^|[^\[])\^/g,percentDecode:/%25/g,findPipe:/\|/g,splitPipe:/ \|/,slashPipe:/\\\|/g,carriageReturn:/\r\n|\r/g,spaceLine:/^ +$/gm,notSpaceStart:/^\S*/,endingNewline:/\n$/,listItemRegex:e=>new RegExp(`^( {0,3}${e})((?:[\t ][^\\n]*)?(?:\\n|$))`),nextBulletRegex:e=>new RegExp(`^ {0,${Math.min(3,e-1)}}(?:[*+-]|\\d{1,9}[.)])((?:[ \t][^\\n]*)?(?:\\n|$))`),hrRegex:e=>new RegExp(`^ {0,${Math.min(3,e-1)}}((?:- *){3,}|(?:_ *){3,}|(?:\\* *){3,})(?:\\n+|$)`),fencesBeginRegex:e=>new RegExp(`^ {0,${Math.min(3,e-1)}}(?:\`\`\`|~~~)`),headingBeginRegex:e=>new RegExp(`^ {0,${Math.min(3,e-1)}}#`),htmlBeginRegex:e=>new RegExp(`^ {0,${Math.min(3,e-1)}}<(?:[a-z].*>|!--)`,"i")},a=/^ {0,3}((?:-[\t ]*){3,}|(?:_[ \t]*){3,}|(?:\*[ \t]*){3,})(?:\n+|$)/,l=/(?:[*+-]|\d{1,9}[.)])/,c=/^(?!bull |blockCode|fences|blockquote|heading|html|table)((?:.|\n(?!\s*?\n|bull |blockCode|fences|blockquote|heading|html|table))+?)\n {0,3}(=+|-+) *(?:\n+|$)/,h=s(c).replace(/bull/g,l).replace(/blockCode/g,/(?: {4}| {0,3}\t)/).replace(/fences/g,/ {0,3}(?:`{3,}|~{3,})/).replace(/blockquote/g,/ {0,3}>/).replace(/heading/g,/ {0,3}#{1,6}/).replace(/html/g,/ {0,3}<[^\n>]+>\n/).replace(/\|table/g,"").getRegex(),u=s(c).replace(/bull/g,l).replace(/blockCode/g,/(?: {4}| {0,3}\t)/).replace(/fences/g,/ {0,3}(?:`{3,}|~{3,})/).replace(/blockquote/g,/ {0,3}>/).replace(/heading/g,/ {0,3}#{1,6}/).replace(/html/g,/ {0,3}<[^\n>]+>\n/).replace(/table/g,/ {0,3}\|?(?:[:\- ]*\|)+[\:\- ]*\n/).getRegex(),d=/^([^\n]+(?:\n(?!hr|heading|lheading|blockquote|fences|list|html|table| +\n)[^\n]+)*)/,p=/(?!\s*\])(?:\\[\s\S]|[^\[\]\\])+/,g=s(/^ {0,3}\[(label)\]: *(?:\n[ \t]*)?([^<\s][^\s]*|<.*?>)(?:(?: +(?:\n[ \t]*)?| *\n[ \t]*)(title))? *(?:\n+|$)/).replace("label",p).replace("title",/(?:"(?:\\"?|[^"\\])*"|'[^'\n]*(?:\n[^'\n]+)*\n?'|\([^()]*\))/).getRegex(),m=s(/^( {0,3}bull)([ \t][^\n]+?)?(?:\n|$)/).replace(/bull/g,l).getRegex(),f="address|article|aside|base|basefont|blockquote|body|caption|center|col|colgroup|dd|details|dialog|dir|div|dl|dt|fieldset|figcaption|figure|footer|form|frame|frameset|h[1-6]|head|header|hr|html|iframe|legend|li|link|main|menu|menuitem|meta|nav|noframes|ol|optgroup|option|p|param|search|section|summary|table|tbody|td|tfoot|th|thead|title|tr|track|ul",b=/|$))/,k=s("^ {0,3}(?:<(script|pre|style|textarea)[\\s>][\\s\\S]*?(?:[^\\n]*\\n+|$)|comment[^\\n]*(\\n+|$)|<\\?[\\s\\S]*?(?:\\?>\\n*|$)|\\n*|$)|\\n*|$)|)[\\s\\S]*?(?:(?:\\n[ \t]*)+\\n|$)|<(?!script|pre|style|textarea)([a-z][\\w-]*)(?:attribute)*? */?>(?=[ \\t]*(?:\\n|$))[\\s\\S]*?(?:(?:\\n[ \t]*)+\\n|$)|(?=[ \\t]*(?:\\n|$))[\\s\\S]*?(?:(?:\\n[ \t]*)+\\n|$))","i").replace("comment",b).replace("tag",f).replace("attribute",/ +[a-zA-Z:_][\w.:-]*(?: *= *"[^"\n]*"| *= *'[^'\n]*'| *= *[^\s"'=<>`]+)?/).getRegex(),x=s(d).replace("hr",a).replace("heading"," {0,3}#{1,6}(?:\\s|$)").replace("|lheading","").replace("|table","").replace("blockquote"," {0,3}>").replace("fences"," {0,3}(?:`{3,}(?=[^`\\n]*\\n)|~{3,})[^\\n]*\\n").replace("list"," {0,3}(?:[*+-]|1[.)]) ").replace("html",")|<(?:script|pre|style|textarea|!--)").replace("tag",f).getRegex(),w={blockquote:s(/^( {0,3}> ?(paragraph|[^\n]*)(?:\n|$))+/).replace("paragraph",x).getRegex(),code:/^((?: {4}| {0,3}\t)[^\n]+(?:\n(?:[ \t]*(?:\n|$))*)?)+/,def:g,fences:/^ {0,3}(`{3,}(?=[^`\n]*(?:\n|$))|~{3,})([^\n]*)(?:\n|$)(?:|([\s\S]*?)(?:\n|$))(?: {0,3}\1[~`]* *(?=\n|$)|$)/,heading:/^ {0,3}(#{1,6})(?=\s|$)(.*)(?:\n+|$)/,hr:a,html:k,lheading:h,list:m,newline:/^(?:[ \t]*(?:\n|$))+/,paragraph:x,table:i,text:/^[^\n]+/},y=s("^ *([^\\n ].*)\\n {0,3}((?:\\| *)?:?-+:? *(?:\\| *:?-+:? *)*(?:\\| *)?)(?:\\n((?:(?! *\\n|hr|heading|blockquote|code|fences|list|html).*(?:\\n|$))*)\\n*|$)").replace("hr",a).replace("heading"," {0,3}#{1,6}(?:\\s|$)").replace("blockquote"," {0,3}>").replace("code","(?: {4}| {0,3}\t)[^\\n]").replace("fences"," {0,3}(?:`{3,}(?=[^`\\n]*\\n)|~{3,})[^\\n]*\\n").replace("list"," {0,3}(?:[*+-]|1[.)]) ").replace("html",")|<(?:script|pre|style|textarea|!--)").replace("tag",f).getRegex(),v={...w,lheading:u,table:y,paragraph:s(d).replace("hr",a).replace("heading"," {0,3}#{1,6}(?:\\s|$)").replace("|lheading","").replace("table",y).replace("blockquote"," {0,3}>").replace("fences"," {0,3}(?:`{3,}(?=[^`\\n]*\\n)|~{3,})[^\\n]*\\n").replace("list"," {0,3}(?:[*+-]|1[.)]) ").replace("html",")|<(?:script|pre|style|textarea|!--)").replace("tag",f).getRegex()},C={...w,html:s("^ *(?:comment *(?:\\n|\\s*$)|<(tag)[\\s\\S]+? *(?:\\n{2,}|\\s*$)|\\s]*)*?/?> *(?:\\n{2,}|\\s*$))").replace("comment",b).replace(/tag/g,"(?!(?:a|em|strong|small|s|cite|q|dfn|abbr|data|time|code|var|samp|kbd|sub|sup|i|b|u|mark|ruby|rt|rp|bdi|bdo|span|br|wbr|ins|del|img)\\b)\\w+(?!:|[^\\w\\s@]*@)\\b").getRegex(),def:/^ *\[([^\]]+)\]: *]+)>?(?: +(["(][^\n]+[")]))? *(?:\n+|$)/,heading:/^(#{1,6})(.*)(?:\n+|$)/,fences:i,lheading:/^(.+?)\n {0,3}(=+|-+) *(?:\n+|$)/,paragraph:s(d).replace("hr",a).replace("heading"," *#{1,6} *[^\n]").replace("lheading",h).replace("|table","").replace("blockquote"," {0,3}>").replace("|fences","").replace("|list","").replace("|html","").replace("|tag","").getRegex()},E=/^( {2,}|\\)\n(?!\s*$)/,S=/[\p{P}\p{S}]/u,A=/[\s\p{P}\p{S}]/u,T=/[^\s\p{P}\p{S}]/u,$=s(/^((?![*_])punctSpace)/,"u").replace(/punctSpace/g,A).getRegex(),R=/(?!~)[\p{P}\p{S}]/u,L=/^(?:\*+(?:((?!\*)punct)|[^\s*]))|^_+(?:((?!_)punct)|([^\s_]))/,M=s(L,"u").replace(/punct/g,S).getRegex(),z=s(L,"u").replace(/punct/g,R).getRegex(),I="^[^_*]*?__[^_*]*?\\*[^_*]*?(?=__)|[^*]+(?=[^*])|(?!\\*)punct(\\*+)(?=[\\s]|$)|notPunctSpace(\\*+)(?!\\*)(?=punctSpace|$)|(?!\\*)punctSpace(\\*+)(?=notPunctSpace)|[\\s](\\*+)(?!\\*)(?=punct)|(?!\\*)punct(\\*+)(?!\\*)(?=punct)|notPunctSpace(\\*+)(?=notPunctSpace)",O=s(I,"gu").replace(/notPunctSpace/g,T).replace(/punctSpace/g,A).replace(/punct/g,S).getRegex(),P=s(I,"gu").replace(/notPunctSpace/g,/(?:[^\s\p{P}\p{S}]|~)/u).replace(/punctSpace/g,/(?!~)[\s\p{P}\p{S}]/u).replace(/punct/g,R).getRegex(),H=s("^[^_*]*?\\*\\*[^_*]*?_[^_*]*?(?=\\*\\*)|[^_]+(?=[^_])|(?!_)punct(_+)(?=[\\s]|$)|notPunctSpace(_+)(?!_)(?=punctSpace|$)|(?!_)punctSpace(_+)(?=notPunctSpace)|[\\s](_+)(?!_)(?=punct)|(?!_)punct(_+)(?!_)(?=punct)","gu").replace(/notPunctSpace/g,T).replace(/punctSpace/g,A).replace(/punct/g,S).getRegex(),N=s(/\\(punct)/,"gu").replace(/punct/g,S).getRegex(),B=s(/^<(scheme:[^\s\x00-\x1f<>]*|email)>/).replace("scheme",/[a-zA-Z][a-zA-Z0-9+.-]{1,31}/).replace("email",/[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+(@)[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)+(?![-_])/).getRegex(),F=s(b).replace("(?:--\x3e|$)","--\x3e").getRegex(),D=s("^comment|^|^<[a-zA-Z][\\w-]*(?:attribute)*?\\s*/?>|^<\\?[\\s\\S]*?\\?>|^|^").replace("comment",F).replace("attribute",/\s+[a-zA-Z:_][\w.:-]*(?:\s*=\s*"[^"]*"|\s*=\s*'[^']*'|\s*=\s*[^\s"'=<>`]+)?/).getRegex(),_=/(?:\[(?:\\[\s\S]|[^\[\]\\])*\]|\\[\s\S]|`[^`]*`|[^\[\]\\`])*?/,q=s(/^!?\[(label)\]\(\s*(href)(?:(?:[ \t]*(?:\n[ \t]*)?)(title))?\s*\)/).replace("label",_).replace("href",/<(?:\\.|[^\n<>\\])+>|[^ \t\n\x00-\x1f]*/).replace("title",/"(?:\\"?|[^"\\])*"|'(?:\\'?|[^'\\])*'|\((?:\\\)?|[^)\\])*\)/).getRegex(),U=s(/^!?\[(label)\]\[(ref)\]/).replace("label",_).replace("ref",p).getRegex(),V=s(/^!?\[(ref)\](?:\[\])?/).replace("ref",p).getRegex(),G={_backpedal:i,anyPunctuation:N,autolink:B,blockSkip:/\[[^\[\]]*?\]\((?:\\[\s\S]|[^\\\(\)]|\((?:\\[\s\S]|[^\\\(\)])*\))*\)|`[^`]*?`|<(?! )[^<>]*?>/g,br:E,code:/^(`+)([^`]|[^`][\s\S]*?[^`])\1(?!`)/,del:i,emStrongLDelim:M,emStrongRDelimAst:O,emStrongRDelimUnd:H,escape:/^\\([!"#$%&'()*+,\-./:;<=>?@\[\]\\^_`{|}~])/,link:q,nolink:V,punctuation:$,reflink:U,reflinkSearch:s("reflink|nolink(?!\\()","g").replace("reflink",U).replace("nolink",V).getRegex(),tag:D,text:/^(`+|[^`])(?:(?= {2,}\n)|[\s\S]*?(?:(?=[\\":">",'"':""","'":"'"},K=e=>Y[e];function J(e,t){if(t){if(o.escapeTest.test(e))return e.replace(o.escapeReplace,K)}else if(o.escapeTestNoEncode.test(e))return e.replace(o.escapeReplaceNoEncode,K);return e}function ee(e){try{e=encodeURI(e).replace(o.percentDecode,"%")}catch{return null}return e}function te(e,t){let n=e.replace(o.findPipe,(e,t,n)=>{let r=!1,i=t;for(;--i>=0&&"\\"===n[i];)r=!r;return r?"|":" |"}).split(o.splitPipe),r=0;if(n[0].trim()||n.shift(),n.length>0&&!n.at(-1)?.trim()&&n.pop(),t)if(n.length>t)n.splice(t);else for(;n.length0)return{type:"space",raw:t[0]}}code(e){let t=this.rules.block.code.exec(e);if(t){let e=t[0].replace(this.rules.other.codeRemoveIndent,"");return{type:"code",raw:t[0],codeBlockStyle:"indented",text:this.options.pedantic?e:ne(e,"\n")}}}fences(e){let t=this.rules.block.fences.exec(e);if(t){let e=t[0],n=function(e,t,n){let r=e.match(n.other.indentCodeCompensation);if(null===r)return t;let i=r[1];return t.split("\n").map(e=>{let t=e.match(n.other.beginningSpace);if(null===t)return e;let[r]=t;return r.length>=i.length?e.slice(i.length):e}).join("\n")}(e,t[3]||"",this.rules);return{type:"code",raw:e,lang:t[2]?t[2].trim().replace(this.rules.inline.anyPunctuation,"$1"):t[2],text:n}}}heading(e){let t=this.rules.block.heading.exec(e);if(t){let e=t[2].trim();if(this.rules.other.endingHash.test(e)){let t=ne(e,"#");(this.options.pedantic||!t||this.rules.other.endingSpaceChar.test(t))&&(e=t.trim())}return{type:"heading",raw:t[0],depth:t[1].length,text:e,tokens:this.lexer.inline(e)}}}hr(e){let t=this.rules.block.hr.exec(e);if(t)return{type:"hr",raw:ne(t[0],"\n")}}blockquote(e){let t=this.rules.block.blockquote.exec(e);if(t){let e=ne(t[0],"\n").split("\n"),n="",r="",i=[];for(;e.length>0;){let t,s=!1,o=[];for(t=0;t1,i={type:"list",raw:"",ordered:r,start:r?+n.slice(0,-1):"",loose:!1,items:[]};n=r?`\\d{1,9}\\${n.slice(-1)}`:`\\${n}`,this.options.pedantic&&(n=r?n:"[*+-]");let s=this.rules.other.listItemRegex(n),o=!1;for(;e;){let n=!1,r="",a="";if(!(t=s.exec(e))||this.rules.block.hr.test(e))break;r=t[0],e=e.substring(r.length);let l=t[2].split("\n",1)[0].replace(this.rules.other.listReplaceTabs,e=>" ".repeat(3*e.length)),c=e.split("\n",1)[0],h=!l.trim(),u=0;if(this.options.pedantic?(u=2,a=l.trimStart()):h?u=t[1].length+1:(u=t[2].search(this.rules.other.nonSpaceChar),u=u>4?1:u,a=l.slice(u),u+=t[1].length),h&&this.rules.other.blankLine.test(c)&&(r+=c+"\n",e=e.substring(c.length+1),n=!0),!n){let t=this.rules.other.nextBulletRegex(u),n=this.rules.other.hrRegex(u),i=this.rules.other.fencesBeginRegex(u),s=this.rules.other.headingBeginRegex(u),o=this.rules.other.htmlBeginRegex(u);for(;e;){let d,p=e.split("\n",1)[0];if(c=p,this.options.pedantic?(c=c.replace(this.rules.other.listReplaceNesting," "),d=c):d=c.replace(this.rules.other.tabCharGlobal," "),i.test(c)||s.test(c)||o.test(c)||t.test(c)||n.test(c))break;if(d.search(this.rules.other.nonSpaceChar)>=u||!c.trim())a+="\n"+d.slice(u);else{if(h||l.replace(this.rules.other.tabCharGlobal," ").search(this.rules.other.nonSpaceChar)>=4||i.test(l)||s.test(l)||n.test(l))break;a+="\n"+c}!h&&!c.trim()&&(h=!0),r+=p+"\n",e=e.substring(p.length+1),l=d.slice(u)}}i.loose||(o?i.loose=!0:this.rules.other.doubleBlankLine.test(r)&&(o=!0));let d,p=null;this.options.gfm&&(p=this.rules.other.listIsTask.exec(a),p&&(d="[ ] "!==p[0],a=a.replace(this.rules.other.listReplaceTask,""))),i.items.push({type:"list_item",raw:r,task:!!p,checked:d,loose:!1,text:a,tokens:[]}),i.raw+=r}let a=i.items.at(-1);if(!a)return;a.raw=a.raw.trimEnd(),a.text=a.text.trimEnd(),i.raw=i.raw.trimEnd();for(let e=0;e"space"===e.type),n=t.length>0&&t.some(e=>this.rules.other.anyLine.test(e.raw));i.loose=n}if(i.loose)for(let e=0;e({text:e,tokens:this.lexer.inline(e),header:!1,align:s.align[t]})));return s}}lheading(e){let t=this.rules.block.lheading.exec(e);if(t)return{type:"heading",raw:t[0],depth:"="===t[2].charAt(0)?1:2,text:t[1],tokens:this.lexer.inline(t[1])}}paragraph(e){let t=this.rules.block.paragraph.exec(e);if(t){let e="\n"===t[1].charAt(t[1].length-1)?t[1].slice(0,-1):t[1];return{type:"paragraph",raw:t[0],text:e,tokens:this.lexer.inline(e)}}}text(e){let t=this.rules.block.text.exec(e);if(t)return{type:"text",raw:t[0],text:t[0],tokens:this.lexer.inline(t[0])}}escape(e){let t=this.rules.inline.escape.exec(e);if(t)return{type:"escape",raw:t[0],text:t[1]}}tag(e){let t=this.rules.inline.tag.exec(e);if(t)return!this.lexer.state.inLink&&this.rules.other.startATag.test(t[0])?this.lexer.state.inLink=!0:this.lexer.state.inLink&&this.rules.other.endATag.test(t[0])&&(this.lexer.state.inLink=!1),!this.lexer.state.inRawBlock&&this.rules.other.startPreScriptTag.test(t[0])?this.lexer.state.inRawBlock=!0:this.lexer.state.inRawBlock&&this.rules.other.endPreScriptTag.test(t[0])&&(this.lexer.state.inRawBlock=!1),{type:"html",raw:t[0],inLink:this.lexer.state.inLink,inRawBlock:this.lexer.state.inRawBlock,block:!1,text:t[0]}}link(e){let t=this.rules.inline.link.exec(e);if(t){let e=t[2].trim();if(!this.options.pedantic&&this.rules.other.startAngleBracket.test(e)){if(!this.rules.other.endAngleBracket.test(e))return;let t=ne(e.slice(0,-1),"\\");if((e.length-t.length)%2==0)return}else{let e=function(e,t){if(-1===e.indexOf(t[1]))return-1;let n=0;for(let r=0;r0?-2:-1}(t[2],"()");if(-2===e)return;if(e>-1){let n=(0===t[0].indexOf("!")?5:4)+t[1].length+e;t[2]=t[2].substring(0,e),t[0]=t[0].substring(0,n).trim(),t[3]=""}}let n=t[2],r="";if(this.options.pedantic){let e=this.rules.other.pedanticHrefTitle.exec(n);e&&(n=e[1],r=e[3])}else r=t[3]?t[3].slice(1,-1):"";return n=n.trim(),this.rules.other.startAngleBracket.test(n)&&(n=this.options.pedantic&&!this.rules.other.endAngleBracket.test(e)?n.slice(1):n.slice(1,-1)),re(t,{href:n&&n.replace(this.rules.inline.anyPunctuation,"$1"),title:r&&r.replace(this.rules.inline.anyPunctuation,"$1")},t[0],this.lexer,this.rules)}}reflink(e,t){let n;if((n=this.rules.inline.reflink.exec(e))||(n=this.rules.inline.nolink.exec(e))){let e=t[(n[2]||n[1]).replace(this.rules.other.multipleSpaceGlobal," ").toLowerCase()];if(!e){let e=n[0].charAt(0);return{type:"text",raw:e,text:e}}return re(n,e,n[0],this.lexer,this.rules)}}emStrong(e,t,n=""){let r=this.rules.inline.emStrongLDelim.exec(e);if(!(!r||r[3]&&n.match(this.rules.other.unicodeAlphaNumeric))&&(!r[1]&&!r[2]||!n||this.rules.inline.punctuation.exec(n))){let n,i,s=[...r[0]].length-1,o=s,a=0,l="*"===r[0][0]?this.rules.inline.emStrongRDelimAst:this.rules.inline.emStrongRDelimUnd;for(l.lastIndex=0,t=t.slice(-1*e.length+s);null!=(r=l.exec(t));){if(n=r[1]||r[2]||r[3]||r[4]||r[5]||r[6],!n)continue;if(i=[...n].length,r[3]||r[4]){o+=i;continue}if((r[5]||r[6])&&s%3&&!((s+i)%3)){a+=i;continue}if(o-=i,o>0)continue;i=Math.min(i,i+o+a);let t=[...r[0]][0].length,l=e.slice(0,s+r.index+t+i);if(Math.min(s,i)%2){let e=l.slice(1,-1);return{type:"em",raw:l,text:e,tokens:this.lexer.inlineTokens(e)}}let c=l.slice(2,-2);return{type:"strong",raw:l,text:c,tokens:this.lexer.inlineTokens(c)}}}}codespan(e){let t=this.rules.inline.code.exec(e);if(t){let e=t[2].replace(this.rules.other.newLineCharGlobal," "),n=this.rules.other.nonSpaceChar.test(e),r=this.rules.other.startingSpaceChar.test(e)&&this.rules.other.endingSpaceChar.test(e);return n&&r&&(e=e.substring(1,e.length-1)),{type:"codespan",raw:t[0],text:e}}}br(e){let t=this.rules.inline.br.exec(e);if(t)return{type:"br",raw:t[0]}}del(e){let t=this.rules.inline.del.exec(e);if(t)return{type:"del",raw:t[0],text:t[2],tokens:this.lexer.inlineTokens(t[2])}}autolink(e){let t=this.rules.inline.autolink.exec(e);if(t){let e,n;return"@"===t[2]?(e=t[1],n="mailto:"+e):(e=t[1],n=e),{type:"link",raw:t[0],text:e,href:n,tokens:[{type:"text",raw:e,text:e}]}}}url(e){let t;if(t=this.rules.inline.url.exec(e)){let e,n;if("@"===t[2])e=t[0],n="mailto:"+e;else{let r;do{r=t[0],t[0]=this.rules.inline._backpedal.exec(t[0])?.[0]??""}while(r!==t[0]);e=t[0],n="www."===t[1]?"http://"+t[0]:t[0]}return{type:"link",raw:t[0],text:e,href:n,tokens:[{type:"text",raw:e,text:e}]}}}inlineText(e){let t=this.rules.inline.text.exec(e);if(t){let e=this.lexer.state.inRawBlock;return{type:"text",raw:t[0],text:t[0],escaped:e}}}},se=class e{tokens;options;state;tokenizer;inlineQueue;constructor(e){this.tokens=[],this.tokens.links=Object.create(null),this.options=e||n,this.options.tokenizer=this.options.tokenizer||new ie,this.tokenizer=this.options.tokenizer,this.tokenizer.options=this.options,this.tokenizer.lexer=this,this.inlineQueue=[],this.state={inLink:!1,inRawBlock:!1,top:!0};let t={other:o,block:W.normal,inline:X.normal};this.options.pedantic?(t.block=W.pedantic,t.inline=X.pedantic):this.options.gfm&&(t.block=W.gfm,this.options.breaks?t.inline=X.breaks:t.inline=X.gfm),this.tokenizer.rules=t}static get rules(){return{block:W,inline:X}}static lex(t,n){return new e(n).lex(t)}static lexInline(t,n){return new e(n).inlineTokens(t)}lex(e){e=e.replace(o.carriageReturn,"\n"),this.blockTokens(e,this.tokens);for(let e=0;e!!(r=n.call({lexer:this},e,t))&&(e=e.substring(r.raw.length),t.push(r),!0)))continue;if(r=this.tokenizer.space(e)){e=e.substring(r.raw.length);let n=t.at(-1);1===r.raw.length&&void 0!==n?n.raw+="\n":t.push(r);continue}if(r=this.tokenizer.code(e)){e=e.substring(r.raw.length);let n=t.at(-1);"paragraph"===n?.type||"text"===n?.type?(n.raw+=(n.raw.endsWith("\n")?"":"\n")+r.raw,n.text+="\n"+r.text,this.inlineQueue.at(-1).src=n.text):t.push(r);continue}if(r=this.tokenizer.fences(e)){e=e.substring(r.raw.length),t.push(r);continue}if(r=this.tokenizer.heading(e)){e=e.substring(r.raw.length),t.push(r);continue}if(r=this.tokenizer.hr(e)){e=e.substring(r.raw.length),t.push(r);continue}if(r=this.tokenizer.blockquote(e)){e=e.substring(r.raw.length),t.push(r);continue}if(r=this.tokenizer.list(e)){e=e.substring(r.raw.length),t.push(r);continue}if(r=this.tokenizer.html(e)){e=e.substring(r.raw.length),t.push(r);continue}if(r=this.tokenizer.def(e)){e=e.substring(r.raw.length);let n=t.at(-1);"paragraph"===n?.type||"text"===n?.type?(n.raw+=(n.raw.endsWith("\n")?"":"\n")+r.raw,n.text+="\n"+r.raw,this.inlineQueue.at(-1).src=n.text):this.tokens.links[r.tag]||(this.tokens.links[r.tag]={href:r.href,title:r.title},t.push(r));continue}if(r=this.tokenizer.table(e)){e=e.substring(r.raw.length),t.push(r);continue}if(r=this.tokenizer.lheading(e)){e=e.substring(r.raw.length),t.push(r);continue}let i=e;if(this.options.extensions?.startBlock){let t,n=1/0,r=e.slice(1);this.options.extensions.startBlock.forEach(e=>{t=e.call({lexer:this},r),"number"==typeof t&&t>=0&&(n=Math.min(n,t))}),n<1/0&&n>=0&&(i=e.substring(0,n+1))}if(this.state.top&&(r=this.tokenizer.paragraph(i))){let s=t.at(-1);n&&"paragraph"===s?.type?(s.raw+=(s.raw.endsWith("\n")?"":"\n")+r.raw,s.text+="\n"+r.text,this.inlineQueue.pop(),this.inlineQueue.at(-1).src=s.text):t.push(r),n=i.length!==e.length,e=e.substring(r.raw.length);continue}if(r=this.tokenizer.text(e)){e=e.substring(r.raw.length);let n=t.at(-1);"text"===n?.type?(n.raw+=(n.raw.endsWith("\n")?"":"\n")+r.raw,n.text+="\n"+r.text,this.inlineQueue.pop(),this.inlineQueue.at(-1).src=n.text):t.push(r);continue}if(e){let t="Infinite loop on byte: "+e.charCodeAt(0);if(this.options.silent){console.error(t);break}throw new Error(t)}}return this.state.top=!0,t}inline(e,t=[]){return this.inlineQueue.push({src:e,tokens:t}),t}inlineTokens(e,t=[]){let n=e,r=null;if(this.tokens.links){let e=Object.keys(this.tokens.links);if(e.length>0)for(;null!=(r=this.tokenizer.rules.inline.reflinkSearch.exec(n));)e.includes(r[0].slice(r[0].lastIndexOf("[")+1,-1))&&(n=n.slice(0,r.index)+"["+"a".repeat(r[0].length-2)+"]"+n.slice(this.tokenizer.rules.inline.reflinkSearch.lastIndex))}for(;null!=(r=this.tokenizer.rules.inline.anyPunctuation.exec(n));)n=n.slice(0,r.index)+"++"+n.slice(this.tokenizer.rules.inline.anyPunctuation.lastIndex);for(;null!=(r=this.tokenizer.rules.inline.blockSkip.exec(n));)n=n.slice(0,r.index)+"["+"a".repeat(r[0].length-2)+"]"+n.slice(this.tokenizer.rules.inline.blockSkip.lastIndex);let i=!1,s="";for(;e;){let r;if(i||(s=""),i=!1,this.options.extensions?.inline?.some(n=>!!(r=n.call({lexer:this},e,t))&&(e=e.substring(r.raw.length),t.push(r),!0)))continue;if(r=this.tokenizer.escape(e)){e=e.substring(r.raw.length),t.push(r);continue}if(r=this.tokenizer.tag(e)){e=e.substring(r.raw.length),t.push(r);continue}if(r=this.tokenizer.link(e)){e=e.substring(r.raw.length),t.push(r);continue}if(r=this.tokenizer.reflink(e,this.tokens.links)){e=e.substring(r.raw.length);let n=t.at(-1);"text"===r.type&&"text"===n?.type?(n.raw+=r.raw,n.text+=r.text):t.push(r);continue}if(r=this.tokenizer.emStrong(e,n,s)){e=e.substring(r.raw.length),t.push(r);continue}if(r=this.tokenizer.codespan(e)){e=e.substring(r.raw.length),t.push(r);continue}if(r=this.tokenizer.br(e)){e=e.substring(r.raw.length),t.push(r);continue}if(r=this.tokenizer.del(e)){e=e.substring(r.raw.length),t.push(r);continue}if(r=this.tokenizer.autolink(e)){e=e.substring(r.raw.length),t.push(r);continue}if(!this.state.inLink&&(r=this.tokenizer.url(e))){e=e.substring(r.raw.length),t.push(r);continue}let o=e;if(this.options.extensions?.startInline){let t,n=1/0,r=e.slice(1);this.options.extensions.startInline.forEach(e=>{t=e.call({lexer:this},r),"number"==typeof t&&t>=0&&(n=Math.min(n,t))}),n<1/0&&n>=0&&(o=e.substring(0,n+1))}if(r=this.tokenizer.inlineText(o)){e=e.substring(r.raw.length),"_"!==r.raw.slice(-1)&&(s=r.raw.slice(-1)),i=!0;let n=t.at(-1);"text"===n?.type?(n.raw+=r.raw,n.text+=r.text):t.push(r);continue}if(e){let t="Infinite loop on byte: "+e.charCodeAt(0);if(this.options.silent){console.error(t);break}throw new Error(t)}}return t}},oe=class{options;parser;constructor(e){this.options=e||n}space(e){return""}code({text:e,lang:t,escaped:n}){let r=(t||"").match(o.notSpaceStart)?.[0],i=e.replace(o.endingNewline,"")+"\n";return r?'
    '+(n?i:J(i,!0))+"
    \n":"
    "+(n?i:J(i,!0))+"
    \n"}blockquote({tokens:e}){return`
    \n${this.parser.parse(e)}
    \n`}html({text:e}){return e}def(e){return""}heading({tokens:e,depth:t}){return`${this.parser.parseInline(e)}\n`}hr(e){return"
    \n"}list(e){let t=e.ordered,n=e.start,r="";for(let t=0;t\n"+r+"\n"}listitem(e){let t="";if(e.task){let n=this.checkbox({checked:!!e.checked});e.loose?"paragraph"===e.tokens[0]?.type?(e.tokens[0].text=n+" "+e.tokens[0].text,e.tokens[0].tokens&&e.tokens[0].tokens.length>0&&"text"===e.tokens[0].tokens[0].type&&(e.tokens[0].tokens[0].text=n+" "+J(e.tokens[0].tokens[0].text),e.tokens[0].tokens[0].escaped=!0)):e.tokens.unshift({type:"text",raw:n+" ",text:n+" ",escaped:!0}):t+=n+" "}return t+=this.parser.parse(e.tokens,!!e.loose),`
  • ${t}
  • \n`}checkbox({checked:e}){return"'}paragraph({tokens:e}){return`

    ${this.parser.parseInline(e)}

    \n`}table(e){let t="",n="";for(let t=0;t${r}`),"\n\n"+t+"\n"+r+"
    \n"}tablerow({text:e}){return`\n${e}\n`}tablecell(e){let t=this.parser.parseInline(e.tokens),n=e.header?"th":"td";return(e.align?`<${n} align="${e.align}">`:`<${n}>`)+t+`\n`}strong({tokens:e}){return`${this.parser.parseInline(e)}`}em({tokens:e}){return`${this.parser.parseInline(e)}`}codespan({text:e}){return`${J(e,!0)}`}br(e){return"
    "}del({tokens:e}){return`${this.parser.parseInline(e)}`}link({href:e,title:t,tokens:n}){let r=this.parser.parseInline(n),i=ee(e);if(null===i)return r;let s='
    ",s}image({href:e,title:t,text:n,tokens:r}){r&&(n=this.parser.parseInline(r,this.parser.textRenderer));let i=ee(e);if(null===i)return J(n);let s=`${n}{let i=e[r].flat(1/0);n=n.concat(this.walkTokens(i,t))}):e.tokens&&(n=n.concat(this.walkTokens(e.tokens,t)))}}return n}use(...e){let t=this.defaults.extensions||{renderers:{},childTokens:{}};return e.forEach(e=>{let n={...e};if(n.async=this.defaults.async||n.async||!1,e.extensions&&(e.extensions.forEach(e=>{if(!e.name)throw new Error("extension name required");if("renderer"in e){let n=t.renderers[e.name];t.renderers[e.name]=n?function(...t){let r=e.renderer.apply(this,t);return!1===r&&(r=n.apply(this,t)),r}:e.renderer}if("tokenizer"in e){if(!e.level||"block"!==e.level&&"inline"!==e.level)throw new Error("extension level must be 'block' or 'inline'");let n=t[e.level];n?n.unshift(e.tokenizer):t[e.level]=[e.tokenizer],e.start&&("block"===e.level?t.startBlock?t.startBlock.push(e.start):t.startBlock=[e.start]:"inline"===e.level&&(t.startInline?t.startInline.push(e.start):t.startInline=[e.start]))}"childTokens"in e&&e.childTokens&&(t.childTokens[e.name]=e.childTokens)}),n.extensions=t),e.renderer){let t=this.defaults.renderer||new oe(this.defaults);for(let n in e.renderer){if(!(n in t))throw new Error(`renderer '${n}' does not exist`);if(["options","parser"].includes(n))continue;let r=n,i=e.renderer[r],s=t[r];t[r]=(...e)=>{let n=i.apply(t,e);return!1===n&&(n=s.apply(t,e)),n||""}}n.renderer=t}if(e.tokenizer){let t=this.defaults.tokenizer||new ie(this.defaults);for(let n in e.tokenizer){if(!(n in t))throw new Error(`tokenizer '${n}' does not exist`);if(["options","rules","lexer"].includes(n))continue;let r=n,i=e.tokenizer[r],s=t[r];t[r]=(...e)=>{let n=i.apply(t,e);return!1===n&&(n=s.apply(t,e)),n}}n.tokenizer=t}if(e.hooks){let t=this.defaults.hooks||new ce;for(let n in e.hooks){if(!(n in t))throw new Error(`hook '${n}' does not exist`);if(["options","block"].includes(n))continue;let r=n,i=e.hooks[r],s=t[r];ce.passThroughHooks.has(n)?t[r]=e=>{if(this.defaults.async)return Promise.resolve(i.call(t,e)).then(e=>s.call(t,e));let n=i.call(t,e);return s.call(t,n)}:t[r]=(...e)=>{let n=i.apply(t,e);return!1===n&&(n=s.apply(t,e)),n}}n.hooks=t}if(e.walkTokens){let t=this.defaults.walkTokens,r=e.walkTokens;n.walkTokens=function(e){let n=[];return n.push(r.call(this,e)),t&&(n=n.concat(t.call(this,e))),n}}this.defaults={...this.defaults,...n}}),this}setOptions(e){return this.defaults={...this.defaults,...e},this}lexer(e,t){return se.lex(e,t??this.defaults)}parser(e,t){return le.parse(e,t??this.defaults)}parseMarkdown(e){return(t,n)=>{let r={...n},i={...this.defaults,...r},s=this.onError(!!i.silent,!!i.async);if(!0===this.defaults.async&&!1===r.async)return s(new Error("marked(): The async option was set to true by an extension. Remove async: false from the parse options object to return a Promise."));if(typeof t>"u"||null===t)return s(new Error("marked(): input parameter is undefined or null"));if("string"!=typeof t)return s(new Error("marked(): input parameter is of type "+Object.prototype.toString.call(t)+", string expected"));i.hooks&&(i.hooks.options=i,i.hooks.block=e);let o=i.hooks?i.hooks.provideLexer():e?se.lex:se.lexInline,a=i.hooks?i.hooks.provideParser():e?le.parse:le.parseInline;if(i.async)return Promise.resolve(i.hooks?i.hooks.preprocess(t):t).then(e=>o(e,i)).then(e=>i.hooks?i.hooks.processAllTokens(e):e).then(e=>i.walkTokens?Promise.all(this.walkTokens(e,i.walkTokens)).then(()=>e):e).then(e=>a(e,i)).then(e=>i.hooks?i.hooks.postprocess(e):e).catch(s);try{i.hooks&&(t=i.hooks.preprocess(t));let e=o(t,i);i.hooks&&(e=i.hooks.processAllTokens(e)),i.walkTokens&&this.walkTokens(e,i.walkTokens);let n=a(e,i);return i.hooks&&(n=i.hooks.postprocess(n)),n}catch(e){return s(e)}}}onError(e,t){return n=>{if(n.message+="\nPlease report this to https://github.com/markedjs/marked.",e){let e="

    An error occurred:

    "+J(n.message+"",!0)+"
    ";return t?Promise.resolve(e):e}if(t)return Promise.reject(n);throw n}}};function ue(e,t){return he.parse(e,t)}function de(e,t){return Array(t+1).join(e)}ue.options=ue.setOptions=function(e){return he.setOptions(e),ue.defaults=he.defaults,r(ue.defaults),ue},ue.getDefaults=t,ue.defaults=n,ue.use=function(...e){return he.use(...e),ue.defaults=he.defaults,r(ue.defaults),ue},ue.walkTokens=function(e,t){return he.walkTokens(e,t)},ue.parseInline=he.parseInline,ue.Parser=le,ue.parser=le.parse,ue.Renderer=oe,ue.TextRenderer=ae,ue.Lexer=se,ue.lexer=se.lex,ue.Tokenizer=ie,ue.Hooks=ce,ue.parse=ue,ue.options,ue.setOptions,ue.use,ue.walkTokens,ue.parseInline,le.parse,se.lex;var pe=["ADDRESS","ARTICLE","ASIDE","AUDIO","BLOCKQUOTE","BODY","CANVAS","CENTER","DD","DIR","DIV","DL","DT","FIELDSET","FIGCAPTION","FIGURE","FOOTER","FORM","FRAMESET","H1","H2","H3","H4","H5","H6","HEADER","HGROUP","HR","HTML","ISINDEX","LI","MAIN","MENU","NAV","NOFRAMES","NOSCRIPT","OL","OUTPUT","P","PRE","SECTION","TABLE","TBODY","TD","TFOOT","TH","THEAD","TR","UL"];function ge(e){return ke(e,pe)}var me=["AREA","BASE","BR","COL","COMMAND","EMBED","HR","IMG","INPUT","KEYGEN","LINK","META","PARAM","SOURCE","TRACK","WBR"];function fe(e){return ke(e,me)}var be=["A","TABLE","THEAD","TBODY","TFOOT","TH","TD","IFRAME","SCRIPT","AUDIO","VIDEO"];function ke(e,t){return t.indexOf(e.nodeName)>=0}function xe(e,t){return e.getElementsByTagName&&t.some(function(t){return e.getElementsByTagName(t).length})}var we={};function ye(e){return e?e.replace(/(\n+\s*)+/g,"\n"):""}function ve(e){for(var t in this.options=e,this._keep=[],this._remove=[],this.blankRule={replacement:e.blankReplacement},this.keepReplacement=e.keepReplacement,this.defaultRule={replacement:e.defaultReplacement},this.array=[],e.rules)this.array.push(e.rules[t])}function Ce(e,t,n){for(var r=0;r-1)return!0}else{if("function"!=typeof r)throw new TypeError("`filter` needs to be a string, array, or function");if(r.call(e,t,n))return!0}}function Se(e){var t=e.nextSibling||e.parentNode;return e.parentNode.removeChild(e),t}function Ae(e,t,n){return e&&e.parentNode===t||n(t)?t.nextSibling||t.parentNode:t.firstChild||t.nextSibling||t.parentNode}we.paragraph={filter:"p",replacement:function(e){return"\n\n"+e+"\n\n"}},we.lineBreak={filter:"br",replacement:function(e,t,n){return n.br+"\n"}},we.heading={filter:["h1","h2","h3","h4","h5","h6"],replacement:function(e,t,n){var r=Number(t.nodeName.charAt(1));return"setext"===n.headingStyle&&r<3?"\n\n"+e+"\n"+de(1===r?"=":"-",e.length)+"\n\n":"\n\n"+de("#",r)+" "+e+"\n\n"}},we.blockquote={filter:"blockquote",replacement:function(e){return"\n\n"+(e=(e=e.replace(/^\n+|\n+$/g,"")).replace(/^/gm,"> "))+"\n\n"}},we.list={filter:["ul","ol"],replacement:function(e,t){var n=t.parentNode;return"LI"===n.nodeName&&n.lastElementChild===t?"\n"+e:"\n\n"+e+"\n\n"}},we.listItem={filter:"li",replacement:function(e,t,n){var r=n.bulletListMarker+" ",i=t.parentNode;if("OL"===i.nodeName){var s=i.getAttribute("start"),o=Array.prototype.indexOf.call(i.children,t);r=(s?Number(s)+o:o+1)+". "}return r+(e=e.replace(/^\n+/,"").replace(/\n+$/,"\n").replace(/\n/gm,"\n"+" ".repeat(r.length)))+(t.nextSibling&&!/\n$/.test(e)?"\n":"")}},we.indentedCodeBlock={filter:function(e,t){return"indented"===t.codeBlockStyle&&"PRE"===e.nodeName&&e.firstChild&&"CODE"===e.firstChild.nodeName},replacement:function(e,t,n){return"\n\n "+t.firstChild.textContent.replace(/\n/g,"\n ")+"\n\n"}},we.fencedCodeBlock={filter:function(e,t){return"fenced"===t.codeBlockStyle&&"PRE"===e.nodeName&&e.firstChild&&"CODE"===e.firstChild.nodeName},replacement:function(e,t,n){for(var r,i=((t.firstChild.getAttribute("class")||"").match(/language-(\S+)/)||[null,""])[1],s=t.firstChild.textContent,o=n.fence.charAt(0),a=3,l=new RegExp("^"+o+"{3,}","gm");r=l.exec(s);)r[0].length>=a&&(a=r[0].length+1);var c=de(o,a);return"\n\n"+c+i+"\n"+s.replace(/\n$/,"")+"\n"+c+"\n\n"}},we.horizontalRule={filter:"hr",replacement:function(e,t,n){return"\n\n"+n.hr+"\n\n"}},we.inlineLink={filter:function(e,t){return"inlined"===t.linkStyle&&"A"===e.nodeName&&e.getAttribute("href")},replacement:function(e,t){var n=t.getAttribute("href");n&&(n=n.replace(/([()])/g,"\\$1"));var r=ye(t.getAttribute("title"));return r&&(r=' "'+r.replace(/"/g,'\\"')+'"'),"["+e+"]("+n+r+")"}},we.referenceLink={filter:function(e,t){return"referenced"===t.linkStyle&&"A"===e.nodeName&&e.getAttribute("href")},replacement:function(e,t,n){var r,i,s=t.getAttribute("href"),o=ye(t.getAttribute("title"));switch(o&&(o=' "'+o+'"'),n.linkReferenceStyle){case"collapsed":r="["+e+"][]",i="["+e+"]: "+s+o;break;case"shortcut":r="["+e+"]",i="["+e+"]: "+s+o;break;default:var a=this.references.length+1;r="["+e+"]["+a+"]",i="["+a+"]: "+s+o}return this.references.push(i),r},references:[],append:function(e){var t="";return this.references.length&&(t="\n\n"+this.references.join("\n")+"\n\n",this.references=[]),t}},we.emphasis={filter:["em","i"],replacement:function(e,t,n){return e.trim()?n.emDelimiter+e+n.emDelimiter:""}},we.strong={filter:["strong","b"],replacement:function(e,t,n){return e.trim()?n.strongDelimiter+e+n.strongDelimiter:""}},we.code={filter:function(e){var t=e.previousSibling||e.nextSibling,n="PRE"===e.parentNode.nodeName&&!t;return"CODE"===e.nodeName&&!n},replacement:function(e){if(!e)return"";e=e.replace(/\r?\n|\r/g," ");for(var t=/^`|^ .*?[^ ].* $|`$/.test(e)?" ":"",n="`",r=e.match(/`+/gm)||[];-1!==r.indexOf(n);)n+="`";return n+t+e+t+n}},we.image={filter:"img",replacement:function(e,t){var n=ye(t.getAttribute("alt")),r=t.getAttribute("src")||"",i=ye(t.getAttribute("title"));return r?"!["+n+"]("+r+(i?' "'+i+'"':"")+")":""}},ve.prototype={add:function(e,t){this.array.unshift(t)},keep:function(e){this._keep.unshift({filter:e,replacement:this.keepReplacement})},remove:function(e){this._remove.unshift({filter:e,replacement:function(){return""}})},forNode:function(e){return e.isBlank?this.blankRule:(t=Ce(this.array,e,this.options))||(t=Ce(this._keep,e,this.options))||(t=Ce(this._remove,e,this.options))?t:this.defaultRule;var t},forEach:function(e){for(var t=0;t'+e+"","text/html").getElementById("turndown-root"):n=e.cloneNode(!0);return function(e){var t=e.element,n=e.isBlock,r=e.isVoid,i=e.isPre||function(e){return"PRE"===e.nodeName};if(t.firstChild&&!i(t)){for(var s=null,o=!1,a=null,l=Ae(a,t,i);l!==t;){if(3===l.nodeType||4===l.nodeType){var c=l.data.replace(/[ \r\n\t]+/g," ");if(s&&!/ $/.test(s.data)||o||" "!==c[0]||(c=c.substr(1)),!c){l=Se(l);continue}l.data=c,s=l}else{if(1!==l.nodeType){l=Se(l);continue}n(l)||"BR"===l.nodeName?(s&&(s.data=s.data.replace(/ $/,"")),s=null,o=!1):r(l)||i(l)?(s=null,o=!0):s&&(o=!1)}var h=Ae(a,l,i);a=l,l=h}s&&(s.data=s.data.replace(/ $/,""),s.data||Se(s))}}({element:n,isBlock:ge,isVoid:fe,isPre:t.preformattedCode?Ie:null}),n}function Ie(e){return"PRE"===e.nodeName||"CODE"===e.nodeName}function Oe(e,t){return e.isBlock=ge(e),e.isCode="CODE"===e.nodeName||e.parentNode.isCode,e.isBlank=function(e){return!fe(e)&&!function(e){return ke(e,be)}(e)&&/^\s*$/i.test(e.textContent)&&!function(e){return xe(e,me)}(e)&&!function(e){return xe(e,be)}(e)}(e),e.flankingWhitespace=function(e,t){if(e.isBlock||t.preformattedCode&&e.isCode)return{leading:"",trailing:""};var n=function(e){var t=e.match(/^(([ \t\r\n]*)(\s*))(?:(?=\S)[\s\S]*\S)?((\s*?)([ \t\r\n]*))$/);return{leading:t[1],leadingAscii:t[2],leadingNonAscii:t[3],trailing:t[4],trailingNonAscii:t[5],trailingAscii:t[6]}}(e.textContent);n.leadingAscii&&Pe("left",e,t)&&(n.leading=n.leadingNonAscii);n.trailingAscii&&Pe("right",e,t)&&(n.trailing=n.trailingNonAscii);return{leading:n.leading,trailing:n.trailing}}(e,t),e}function Pe(e,t,n){var r,i,s;return"left"===e?(r=t.previousSibling,i=/ $/):(r=t.nextSibling,i=/^ /),r&&(3===r.nodeType?s=i.test(r.nodeValue):n.preformattedCode&&"CODE"===r.nodeName?s=!1:1!==r.nodeType||ge(r)||(s=i.test(r.textContent))),s}var He=Array.prototype.reduce,Ne=[[/\\/g,"\\\\"],[/\*/g,"\\*"],[/^-/g,"\\-"],[/^\+ /g,"\\+ "],[/^(=+)/g,"\\$1"],[/^(#{1,6}) /g,"\\$1 "],[/`/g,"\\`"],[/^~~~/g,"\\~~~"],[/\[/g,"\\["],[/\]/g,"\\]"],[/^>/g,"\\>"],[/_/g,"\\_"],[/^(\d+)\. /g,"$1\\. "]];function Be(e){if(!(this instanceof Be))return new Be(e);var t={rules:we,headingStyle:"setext",hr:"* * *",bulletListMarker:"*",codeBlockStyle:"indented",fence:"```",emDelimiter:"_",strongDelimiter:"**",linkStyle:"inlined",linkReferenceStyle:"full",br:" ",preformattedCode:!1,blankReplacement:function(e,t){return t.isBlock?"\n\n":""},keepReplacement:function(e,t){return t.isBlock?"\n\n"+t.outerHTML+"\n\n":t.outerHTML},defaultReplacement:function(e,t){return t.isBlock?"\n\n"+e+"\n\n":e}};this.options=function(e){for(var t=1;t0&&"\n"===e[t-1];)t--;return e.substring(0,t)}(e),r=t.replace(/^\n*/,""),i=Math.max(e.length-n.length,t.length-r.length);return n+"\n\n".substring(0,i)+r}Be.prototype={turndown:function(e){if(!function(e){return null!=e&&("string"==typeof e||e.nodeType&&(1===e.nodeType||9===e.nodeType||11===e.nodeType))}(e))throw new TypeError(e+" is not a string, or an element/document/fragment node.");if(""===e)return"";var t=Fe.call(this,new ze(e,this.options));return De.call(this,t)},use:function(e){if(Array.isArray(e))for(var t=0;te.trim()).map(e=>`

    ${e.trim()}

    `).join("")}}extractGroupHTML(e){const t=[];return e.forEach(e=>{const n=e.innerHTML.trim();n&&("p"===e.tagName.toLowerCase()?t.push(e.outerHTML):t.push(`

    ${n}

    `))}),t.join("\n")}extractGroupMarkdown(e){const t=this.extractGroupHTML(e);return this.htmlToMarkdown(t)}updateGroupElements(e,t){const n=this.markdownToHtml(t),r=document.createElement("div");r.innerHTML=n;const i=Array.from(r.querySelectorAll("p, div, h1, h2, h3, h4, h5, h6")),s=Math.max(e.length,i.length);for(let t=0;ta.focus(),100),o}createMarkdownForm(e){const t=this.getMarkdownConfig(e),n=e.extractMarkdown(),r=document.createElement("div");return r.className="insertr-edit-form",r.innerHTML=`\n
    ${t.label}
    \n
    \n \n
    \n Supports Markdown formatting (bold, italic, links, etc.)\n
    \n
    \n
    \n \n \n
    \n `,r}getMarkdownConfig(e){const t=e.elements.length;if(1!==t)return{label:`Group Content (${t} elements)`,rows:Math.max(8,2*t),placeholder:"Edit all content together using markdown..."};switch(e.elements[0].tagName.toLowerCase()){case"h3":case"h4":case"h5":case"h6":return{label:"Title (Markdown)",rows:2,placeholder:"Enter title using markdown..."};case"p":return{label:"Content (Markdown)",rows:4,placeholder:"Enter content using markdown..."};case"span":return{label:"Text (Markdown)",rows:2,placeholder:"Enter text using markdown..."};default:return{label:"Content (Markdown)",rows:3,placeholder:"Enter content using markdown..."}}}setupEventHandlers(e,t,n,{onSave:r,onCancel:i}){const s=e.querySelector("textarea"),o=e.querySelector(".insertr-btn-save"),a=e.querySelector(".insertr-btn-cancel");this.previewManager.setActiveContext(n),s&&s.addEventListener("input",()=>{const e=s.value;this.previewManager.schedulePreview(n,e)}),o&&o.addEventListener("click",()=>{const e=s.value;n.applyMarkdown(e),this.previewManager.clearPreview(),r({text:e}),this.close()}),a&&a.addEventListener("click",()=>{this.previewManager.clearPreview(),i(),this.close()});const l=e=>{"Escape"===e.key&&(this.previewManager.clearPreview(),i(),this.close(),document.removeEventListener("keydown",l))};document.addEventListener("keydown",l),t.addEventListener("click",e=>{e.target===t&&(this.previewManager.clearPreview(),i(),this.close())})}createOverlay(e){const t=document.createElement("div");return t.className="insertr-form-overlay",t.appendChild(e),t}positionForm(e,t){const n=e.getBoundingClientRect(),r=t.querySelector(".insertr-edit-form"),i=window.innerWidth;let s;if(i<768)s=Math.min(i-40,500);else{const e=600,t=Math.min(.9*i,800);s=Math.max(e,Math.min(1.5*n.width,t))}r.style.width=`${s}px`;const o=n.bottom+window.scrollY+10,a=n.left+window.scrollX+n.width/2-s/2,l=window.innerWidth-s-20,c=Math.max(20,Math.min(a,l));t.style.position="absolute",t.style.top=`${o}px`,t.style.left=`${c}px`,t.style.zIndex="10000",this.ensureModalVisible(e,t)}ensureModalVisible(e,t){requestAnimationFrame(()=>{const e=t.querySelector(".insertr-edit-form").getBoundingClientRect(),n=window.innerHeight;if(e.bottom>n){const t=e.bottom-n+20;window.scrollBy({top:t,behavior:"smooth"})}})}close(){this.previewManager&&this.previewManager.clearPreview(),this.currentOverlay&&(this.currentOverlay.remove(),this.currentOverlay=null)}escapeHtml(e){if("string"!=typeof e)return"";const t=document.createElement("div");return t.textContent=e,t.innerHTML}}class Ge{constructor(e){this.elements=e,this.primaryElement=e[0],this.originalContent=null}extractMarkdown(){return 1===this.elements.length?Ue.htmlToMarkdown(this.elements[0].innerHTML):Ue.extractGroupMarkdown(this.elements)}applyMarkdown(e){if(1===this.elements.length){const t=Ue.markdownToHtml(e);this.elements[0].innerHTML=t}else Ue.updateGroupElements(this.elements,e)}storeOriginalContent(){this.originalContent=this.elements.map(e=>e.innerHTML)}restoreOriginalContent(){this.originalContent&&this.elements.forEach((e,t)=>{void 0!==this.originalContent[t]&&(e.innerHTML=this.originalContent[t])})}applyPreviewStyling(){this.elements.forEach(e=>{e.classList.add("insertr-preview-active")}),this.primaryElement.classList.contains("insertr-group")&&this.primaryElement.classList.add("insertr-preview-active")}removePreviewStyling(){this.elements.forEach(e=>{e.classList.remove("insertr-preview-active")}),this.primaryElement.classList.contains("insertr-group")&&this.primaryElement.classList.remove("insertr-preview-active")}}class je{constructor(){this.previewTimeout=null,this.activeContext=null,this.resizeObserver=null}setActiveContext(e){this.clearPreview(),this.activeContext=e,this.startResizeObserver()}schedulePreview(e,t){this.previewTimeout&&clearTimeout(this.previewTimeout),this.previewTimeout=setTimeout(()=>{this.updatePreview(e,t)},500)}updatePreview(e,t){e.originalContent||e.storeOriginalContent(),e.applyMarkdown(t),e.applyPreviewStyling()}clearPreview(){this.activeContext&&(this.activeContext.restoreOriginalContent(),this.activeContext.removePreviewStyling(),this.activeContext=null),this.previewTimeout&&(clearTimeout(this.previewTimeout),this.previewTimeout=null),this.stopResizeObserver()}startResizeObserver(){this.stopResizeObserver(),this.activeContext&&(this.resizeObserver=new ResizeObserver(()=>{this.onHeightChange&&this.onHeightChange(this.activeContext.primaryElement)}),this.activeContext.elements.forEach(e=>{this.resizeObserver.observe(e)}))}stopResizeObserver(){this.resizeObserver&&(this.resizeObserver.disconnect(),this.resizeObserver=null)}setHeightChangeCallback(e){this.onHeightChange=e}}class Ze{constructor(){this.previewTimeouts=new Map,this.activeElement=null,this.originalContent=null,this.originalStyles=null,this.resizeObserver=null,this.onHeightChangeCallback=null}schedulePreview(e,t,n){const r=this.getElementId(e);this.previewTimeouts.has(r)&&clearTimeout(this.previewTimeouts.get(r));const i=setTimeout(()=>{this.updatePreview(e,t,n)},500);this.previewTimeouts.set(r,i)}updatePreview(e,t,n){this.originalContent||this.activeElement!==e||(this.originalContent=this.extractOriginalContent(e,n)),this.applyPreviewContent(e,t,n)}extractOriginalContent(e,t){return"link"===t?{text:e.textContent,url:e.href}:e.textContent}applyPreviewContent(e,t,n){switch(e.classList.add("insertr-preview-active"),n){case"text":case"h1":case"h2":case"h3":case"h4":case"h5":case"h6":case"span":case"button":case"textarea":case"p":t&&t.trim()&&(e.textContent=t);break;case"link":"object"==typeof t?(void 0!==t.text&&t.text.trim()&&(e.textContent=t.text),void 0!==t.url&&t.url.trim()&&(e.href=t.url)):t&&t.trim()&&(e.textContent=t)}}clearPreview(e){if(!e)return;const t=this.getElementId(e);this.previewTimeouts.has(t)&&(clearTimeout(this.previewTimeouts.get(t)),this.previewTimeouts.delete(t)),this.stopResizeObserver(),this.originalContent&&e===this.activeElement&&this.restoreOriginalContent(e),e.classList.remove("insertr-preview-active"),this.activeElement=null,this.originalContent=null}restoreOriginalContent(e){this.originalContent&&("object"==typeof this.originalContent?(e.textContent=this.originalContent.text,this.originalContent.url&&(e.href=this.originalContent.url)):e.textContent=this.originalContent)}getElementId(e){return e._insertrId||(e._insertrId="insertr_"+Date.now()+"_"+Math.random().toString(36).substr(2,9)),e._insertrId}setActiveElement(e){this.activeElement=e,this.originalContent=null,this.startResizeObserver(e)}setHeightChangeCallback(e){this.onHeightChangeCallback=e}startResizeObserver(e){this.stopResizeObserver(),this.resizeObserver=new ResizeObserver(t=>{requestAnimationFrame(()=>{this.onHeightChangeCallback&&e===this.activeElement&&this.onHeightChangeCallback(e)})}),this.resizeObserver.observe(e)}stopResizeObserver(){this.resizeObserver&&(this.resizeObserver.disconnect(),this.resizeObserver=null)}}class Qe{constructor(e=null){this.apiClient=e,this.currentOverlay=null,this.previewManager=new Ze,this.markdownEditor=new Ve,this.setupStyles()}showEditForm(e,t,n,r){this.closeForm();const{element:i,contentId:s,contentType:o}=e;if("markdown"===this.getFieldConfig(i,o).type)return this.markdownEditor.edit(i,n,r);if(i.classList.contains("insertr-group")){const e=this.getGroupChildren(i);return this.markdownEditor.edit(e,n,r)}return this.showLegacyEditForm(e,t,n,r)}showLegacyEditForm(e,t,n,r){const{element:i,contentId:s,contentType:o}=e,a=this.getFieldConfig(i,o);this.previewManager.setActiveElement(i),this.previewManager.setHeightChangeCallback(e=>{this.repositionModal(e,c)});const l=this.createEditForm(s,a,t),c=this.createOverlay(l);this.positionForm(i,c),this.setupFormHandlers(l,c,i,a,{onSave:n,onCancel:r}),document.body.appendChild(c),this.currentOverlay=c;const h=l.querySelector("input, textarea");return h&&setTimeout(()=>h.focus(),100),c}getGroupChildren(e){const t=[];for(const n of e.children)n.textContent.trim().length>0&&t.push(n);return t}closeForm(){this.markdownEditor.close(),this.previewManager.activeElement&&this.previewManager.clearPreview(this.previewManager.activeElement),this.currentOverlay&&(this.currentOverlay.remove(),this.currentOverlay=null)}getFieldConfig(e,t){const n=e.tagName.toLowerCase(),r=Array.from(e.classList);let i={h1:{type:"text",label:"Headline",maxLength:60,placeholder:"Enter headline..."},h2:{type:"text",label:"Subheading",maxLength:80,placeholder:"Enter subheading..."},h3:{type:"markdown",label:"Section Title",rows:2,placeholder:"Enter title (markdown supported)..."},h4:{type:"markdown",label:"Title",rows:2,placeholder:"Enter title (markdown supported)..."},h5:{type:"markdown",label:"Title",rows:2,placeholder:"Enter title (markdown supported)..."},h6:{type:"markdown",label:"Title",rows:2,placeholder:"Enter title (markdown supported)..."},p:{type:"markdown",label:"Content",rows:4,placeholder:"Enter content using markdown..."},a:{type:"link",label:"Link",placeholder:"Enter link text...",includeUrl:!0},span:{type:"markdown",label:"Text",rows:2,placeholder:"Enter text (markdown supported)..."},button:{type:"text",label:"Button Text",placeholder:"Enter button text..."}}[n]||{type:"text",label:"Text",placeholder:"Enter text..."};return r.includes("lead")&&(i={...i,label:"Lead Paragraph",rows:4,placeholder:"Enter lead paragraph..."}),"markdown"===t&&(i={...i,type:"markdown",label:"Markdown Content",rows:8}),i}createEditForm(e,t,n){const r=document.createElement("div");r.className="insertr-edit-form";let i=`
    ${t.label}
    `;return"markdown"===t.type?i+=this.createMarkdownField(t,n):"link"===t.type&&t.includeUrl?i+=this.createLinkField(t,n):"textarea"===t.type?i+=this.createTextareaField(t,n):i+=this.createTextField(t,n),i+=`\n
    \n \n \n \n
    \n `,r.innerHTML=i,r}createMarkdownField(e,t){return`\n
    \n \n
    \n Supports Markdown formatting (bold, italic, links, etc.)\n
    \n
    \n `}createLinkField(e,t){const n="object"==typeof t?t.text||"":t,r="object"==typeof t&&t.url||"";return`\n
    \n \n \n
    \n
    \n \n \n
    \n `}createTextareaField(e,t){const n="object"==typeof t?t.text||"":t;return`\n
    \n \n
    \n `}createTextField(e,t){const n="object"==typeof t?t.text||"":t;return`\n
    \n \n
    \n `}createOverlay(e){const t=document.createElement("div");return t.className="insertr-form-overlay",t.appendChild(e),t}getElementId(e){return e.id||e.getAttribute("data-content-id")||`element-${e.tagName}-${Date.now()}`}async showVersionHistory(e,t,n){try{const t=this.getApiClient(),r=await t.getContentVersions(e),i=this.createVersionHistoryModal(e,r,n);document.body.appendChild(i),this.setupVersionHistoryHandlers(i,e)}catch(e){console.error("Failed to load version history:",e),this.showVersionHistoryError("Failed to load version history. Please try again.")}}createVersionHistoryModal(e,t,n){const r=document.createElement("div");r.className="insertr-version-modal";let i="";return i=t&&t.length>0?t.map((e,n)=>`\n
    \n
    \n ${0===n?"Previous Version":"Version "+(t.length-n)}\n ${this.formatDate(e.created_at)}\n ${e.created_by?`by ${e.created_by}`:""}\n
    \n
    ${this.escapeHtml(this.truncateContent(e.value,100))}
    \n
    \n \n \n
    \n
    \n `).join(""):'
    No previous versions found
    ',r.innerHTML=`\n
    \n
    \n
    \n

    Version History

    \n \n
    \n
    \n ${i}\n
    \n
    \n
    \n `,r}setupVersionHistoryHandlers(e,t){const n=e.querySelector(".insertr-btn-close"),r=e.querySelector(".insertr-version-backdrop");n&&n.addEventListener("click",()=>e.remove()),r.addEventListener("click",t=>{t.target===r&&e.remove()});e.querySelectorAll(".insertr-btn-restore").forEach(n=>{n.addEventListener("click",async()=>{const r=n.getAttribute("data-version-id");await this.confirmRestore()&&(await this.restoreVersion(t,r),e.remove(),this.closeForm())})});e.querySelectorAll(".insertr-btn-view-diff").forEach(e=>{e.addEventListener("click",()=>{const t=e.getAttribute("data-version-id");this.showVersionDetails(t)})})}formatDate(e){const t=new Date(e),n=new Date-t;if(n<864e5){const e=Math.floor(n/36e5);if(e<1){return`${Math.floor(n/6e4)}m ago`}return`${e}h ago`}if(n<6048e5){return`${Math.floor(n/864e5)}d ago`}return t.toLocaleDateString()}truncateContent(e,t){return e.length<=t?e:e.substring(0,t)+"..."}async confirmRestore(){return confirm("Are you sure you want to restore this version? This will replace the current content.")}async restoreVersion(e,t){try{const n=this.getApiClient();return await n.rollbackContent(e,t),!0}catch(e){return console.error("Failed to restore version:",e),alert("Failed to restore version. Please try again."),!1}}showVersionDetails(e){alert(`Version details not implemented yet (Version ID: ${e})`)}showVersionHistoryError(e){alert(e)}getApiClient(){return this.apiClient||window.insertrAPIClient||null}repositionModal(e,t){requestAnimationFrame(()=>{const n=e.getBoundingClientRect();t.querySelector(".insertr-edit-form");const r=n.bottom+window.scrollY+10;t.style.top=`${r}px`,this.ensureModalVisible(e,t)})}ensureModalVisible(e,t){requestAnimationFrame(()=>{const e=t.querySelector(".insertr-edit-form").getBoundingClientRect(),n=window.innerHeight,r=e.bottom;if(r>n){const e=r-n+20;window.scrollBy({top:e,behavior:"smooth"})}})}setupFormHandlers(e,t,n,r,{onSave:i,onCancel:s}){const o=e.querySelector(".insertr-btn-save"),a=e.querySelector(".insertr-btn-cancel"),l=this.getElementType(n,r);this.setupLivePreview(e,n,l),o&&o.addEventListener("click",()=>{this.previewManager.clearPreview(n);const t=this.extractFormData(e);i(t),this.closeForm()}),a&&a.addEventListener("click",()=>{this.previewManager.clearPreview(n),s(),this.closeForm()});const c=e.querySelector(".insertr-btn-history");c&&c.addEventListener("click",()=>{const e=c.getAttribute("data-content-id");this.showVersionHistory(e,n,i)});const h=e=>{"Escape"===e.key&&(this.previewManager.clearPreview(n),s(),this.closeForm(),document.removeEventListener("keydown",h))};document.addEventListener("keydown",h),t.addEventListener("click",e=>{e.target===t&&(this.previewManager.clearPreview(n),s(),this.closeForm())})}setupLivePreview(e,t,n){e.querySelectorAll("input, textarea").forEach(r=>{r.addEventListener("input",()=>{const r=this.extractInputValue(e,n);this.previewManager.schedulePreview(t,r,n)})})}extractInputValue(e,t){const n=e.querySelector('input[name="text"]'),r=e.querySelector('input[name="url"]'),i=e.querySelector('input[name="content"], textarea[name="content"]');return n&&r?{text:n.value,url:r.value}:i?i.value:""}getElementType(e,t){if("link"===t.type)return"link";if("markdown"===t.type)return"markdown";if("textarea"===t.type)return"textarea";return"p"===e.tagName.toLowerCase()?"p":"text"}extractFormData(e){const t={},n=e.querySelector('input[name="text"]'),r=e.querySelector('input[name="url"]'),i=e.querySelector('input[name="content"], textarea[name="content"]');return n&&r?(t.text=n.value,t.url=r.value):i&&(t.text=i.value),t}escapeHtml(e){if("string"!=typeof e)return"";const t=document.createElement("div");return t.textContent=e,t.innerHTML}setupStyles(){const e=document.createElement("style");e.type="text/css",e.innerHTML="\n .insertr-form-overlay {\n position: absolute;\n z-index: 10000;\n }\n\n .insertr-edit-form {\n background: white;\n border: 2px solid #007cba;\n border-radius: 8px;\n padding: 1rem;\n box-shadow: 0 8px 25px rgba(0,0,0,0.15);\n width: 100%;\n box-sizing: border-box;\n font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;\n }\n\n .insertr-form-header {\n font-weight: 600;\n color: #1f2937;\n margin-bottom: 1rem;\n padding-bottom: 0.5rem;\n border-bottom: 1px solid #e5e7eb;\n font-size: 0.875rem;\n text-transform: uppercase;\n letter-spacing: 0.5px;\n }\n\n .insertr-form-group {\n margin-bottom: 1rem;\n }\n\n .insertr-form-group:last-child {\n margin-bottom: 0;\n }\n\n .insertr-form-label {\n display: block;\n font-weight: 600;\n color: #374151;\n margin-bottom: 0.5rem;\n font-size: 0.875rem;\n }\n\n .insertr-form-input, \n .insertr-form-textarea {\n width: 100%;\n padding: 0.75rem;\n border: 1px solid #d1d5db;\n border-radius: 6px;\n font-family: inherit;\n font-size: 1rem;\n transition: border-color 0.2s, box-shadow 0.2s;\n box-sizing: border-box;\n }\n\n .insertr-form-input:focus,\n .insertr-form-textarea:focus {\n outline: none;\n border-color: #007cba;\n box-shadow: 0 0 0 3px rgba(0, 124, 186, 0.1);\n }\n\n .insertr-form-textarea {\n min-height: 120px;\n resize: vertical;\n font-family: 'SF Mono', Monaco, 'Cascadia Code', monospace;\n }\n\n .insertr-markdown-editor {\n min-height: 200px;\n font-family: 'SF Mono', Monaco, 'Cascadia Code', monospace;\n font-size: 0.9rem;\n line-height: 1.5;\n background-color: #f8fafc;\n }\n\n .insertr-form-actions {\n display: flex;\n gap: 0.5rem;\n justify-content: flex-end;\n margin-top: 1rem;\n padding-top: 1rem;\n border-top: 1px solid #e5e7eb;\n }\n\n .insertr-btn-save {\n background: #10b981;\n color: white;\n border: none;\n padding: 0.5rem 1rem;\n border-radius: 6px;\n font-weight: 500;\n cursor: pointer;\n transition: background-color 0.2s;\n font-size: 0.875rem;\n }\n\n .insertr-btn-save:hover {\n background: #059669;\n }\n\n .insertr-btn-cancel {\n background: #6b7280;\n color: white;\n border: none;\n padding: 0.5rem 1rem;\n border-radius: 6px;\n font-weight: 500;\n cursor: pointer;\n transition: background-color 0.2s;\n font-size: 0.875rem;\n }\n\n .insertr-btn-cancel:hover {\n background: #4b5563;\n }\n\n .insertr-form-help {\n font-size: 0.75rem;\n color: #6b7280;\n margin-top: 0.25rem;\n }\n\n /* Live Preview Styles */\n .insertr-preview-active {\n position: relative;\n background: rgba(0, 124, 186, 0.05) !important;\n outline: 2px solid #007cba !important;\n outline-offset: 2px;\n transition: all 0.3s ease;\n }\n\n .insertr-preview-active::after {\n content: \"Preview\";\n position: absolute;\n top: -25px;\n left: 0;\n background: #007cba;\n color: white;\n padding: 2px 8px;\n border-radius: 3px;\n font-size: 0.75rem;\n font-weight: 500;\n z-index: 10001;\n white-space: nowrap;\n font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;\n }\n\n /* Enhanced modal sizing for comfortable editing */\n .insertr-edit-form {\n min-width: 600px; /* Ensures ~70 character width */\n max-width: 800px;\n }\n\n @media (max-width: 768px) {\n .insertr-edit-form {\n min-width: 90vw;\n max-width: 90vw;\n }\n \n .insertr-preview-active::after {\n top: -20px;\n font-size: 0.7rem;\n padding: 1px 6px;\n }\n }\n\n /* Enhanced input styling for comfortable editing */\n .insertr-form-input {\n font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, monospace;\n letter-spacing: 0.02em;\n }\n ",document.head.appendChild(e)}}class We{constructor(e,t,n,r={}){this.core=e,this.auth=t,this.apiClient=n,this.options=r,this.isActive=!1,this.formRenderer=new Qe(n)}start(){if(this.isActive)return;console.log("🚀 Starting Insertr Editor"),this.isActive=!0,this.addEditorStyles();const e=this.core.getAllElements();console.log(`📝 Found ${e.length} editable elements`),e.forEach(e=>this.initializeElement(e))}initializeElement(e){const{element:t,contentId:n,contentType:r}=e;t.style.cursor="pointer",t.style.position="relative",this.addHoverEffects(t),this.addClickHandler(t,e)}addHoverEffects(e){e.addEventListener("mouseenter",()=>{e.classList.add("insertr-editing-hover")}),e.addEventListener("mouseleave",()=>{e.classList.remove("insertr-editing-hover")})}addClickHandler(e,t){e.addEventListener("click",e=>{this.auth.isAuthenticated()&&this.auth.isEditMode()&&(e.preventDefault(),this.openEditor(t))})}openEditor(e){const{element:t}=e,n=this.extractCurrentContent(t);this.formRenderer.showEditForm(e,n,t=>this.handleSave(e,t),()=>this.handleCancel(e))}extractCurrentContent(e){return"a"===e.tagName.toLowerCase()?{text:e.textContent.trim(),url:e.getAttribute("href")||""}:e.textContent.trim()}async handleSave(e,t){console.log("💾 Saving content:",e.contentId,t);try{let n;n=(e.element.tagName.toLowerCase(),t.text||t);if(!await this.apiClient.updateContent(e.contentId,n)){const t=this.determineContentType(e.element);await this.apiClient.createContent(e.contentId,n,t)||console.error("❌ Failed to save content to server:",e.contentId)}this.updateElementContent(e.element,t),this.formRenderer.closeForm(),console.log("✅ Content saved:",e.contentId,n)}catch(n){console.error("❌ Error saving content:",n),this.updateElementContent(e.element,t),this.formRenderer.closeForm()}}determineContentType(e){const t=e.tagName.toLowerCase();return"a"===t||"button"===t?"link":"p"===t||"div"===t?"markdown":"text"}handleCancel(e){console.log("❌ Edit cancelled:",e.contentId)}updateElementContent(e,t){e.classList.contains("insertr-group")||this.isMarkdownElement(e)?console.log("🔄 Skipping element update - handled by unified markdown editor"):"a"===e.tagName.toLowerCase()?(void 0!==t.text&&(e.textContent=t.text),void 0!==t.url&&e.setAttribute("href",t.url)):e.textContent=t.text||""}isMarkdownElement(e){return new Set(["p","h3","h4","h5","h6","span"]).has(e.tagName.toLowerCase())}addEditorStyles(){const e=document.createElement("style");e.type="text/css",e.innerHTML='\n .insertr-editing-hover {\n outline: 2px dashed #007cba !important;\n outline-offset: 2px !important;\n background-color: rgba(0, 124, 186, 0.05) !important;\n }\n \n .insertr:hover::after {\n content: "✏️ " attr(data-content-type);\n position: absolute;\n top: -25px;\n left: 0;\n background: #007cba;\n color: white;\n padding: 2px 6px;\n font-size: 11px;\n border-radius: 3px;\n white-space: nowrap;\n z-index: 1000;\n font-family: monospace;\n }\n\n /* Version History Modal Styles */\n .insertr-version-modal {\n position: fixed;\n top: 0;\n left: 0;\n right: 0;\n bottom: 0;\n z-index: 10001;\n }\n\n .insertr-version-backdrop {\n position: absolute;\n top: 0;\n left: 0;\n right: 0;\n bottom: 0;\n background-color: rgba(0, 0, 0, 0.5);\n display: flex;\n align-items: center;\n justify-content: center;\n padding: 20px;\n }\n\n .insertr-version-content-modal {\n background: white;\n border-radius: 8px;\n max-width: 600px;\n width: 100%;\n max-height: 80vh;\n box-shadow: 0 10px 25px rgba(0, 0, 0, 0.2);\n display: flex;\n flex-direction: column;\n }\n\n .insertr-version-header {\n padding: 20px 20px 0;\n border-bottom: 1px solid #eee;\n display: flex;\n justify-content: space-between;\n align-items: center;\n flex-shrink: 0;\n }\n\n .insertr-version-header h3 {\n margin: 0 0 20px;\n color: #333;\n font-size: 18px;\n }\n\n .insertr-btn-close {\n background: none;\n border: none;\n font-size: 24px;\n cursor: pointer;\n color: #666;\n padding: 0;\n width: 30px;\n height: 30px;\n display: flex;\n align-items: center;\n justify-content: center;\n }\n\n .insertr-btn-close:hover {\n color: #333;\n }\n\n .insertr-version-list {\n overflow-y: auto;\n padding: 20px;\n flex: 1;\n }\n\n .insertr-version-item {\n border: 1px solid #e1e5e9;\n border-radius: 6px;\n padding: 16px;\n margin-bottom: 12px;\n background: #f8f9fa;\n }\n\n .insertr-version-meta {\n display: flex;\n align-items: center;\n gap: 12px;\n margin-bottom: 8px;\n font-size: 13px;\n }\n\n .insertr-version-label {\n font-weight: 600;\n color: #0969da;\n }\n\n .insertr-version-date {\n color: #656d76;\n }\n\n .insertr-version-user {\n color: #656d76;\n }\n\n .insertr-version-content {\n margin-bottom: 12px;\n padding: 8px;\n background: white;\n border-radius: 4px;\n font-family: monospace;\n font-size: 14px;\n color: #24292f;\n white-space: pre-wrap;\n }\n\n .insertr-version-actions {\n display: flex;\n gap: 8px;\n }\n\n .insertr-btn-restore {\n background: #0969da;\n color: white;\n border: none;\n padding: 6px 12px;\n border-radius: 4px;\n cursor: pointer;\n font-size: 12px;\n font-weight: 500;\n }\n\n .insertr-btn-restore:hover {\n background: #0860ca;\n }\n\n .insertr-btn-view-diff {\n background: #f6f8fa;\n color: #24292f;\n border: 1px solid #d1d9e0;\n padding: 6px 12px;\n border-radius: 4px;\n cursor: pointer;\n font-size: 12px;\n font-weight: 500;\n }\n\n .insertr-btn-view-diff:hover {\n background: #f3f4f6;\n }\n\n .insertr-version-empty {\n text-align: center;\n color: #656d76;\n font-style: italic;\n padding: 40px 20px;\n }\n\n /* History Button in Form */\n .insertr-btn-history {\n background: #6f42c1;\n color: white;\n border: none;\n padding: 8px 16px;\n border-radius: 4px;\n cursor: pointer;\n font-size: 14px;\n font-weight: 500;\n margin-left: auto;\n }\n\n .insertr-btn-history:hover {\n background: #5a359a;\n }\n ',document.head.appendChild(e)}}class Xe{constructor(e={}){this.options={mockAuth:!1!==e.mockAuth,hideGatesAfterAuth:!0===e.hideGatesAfterAuth,...e},this.state={isAuthenticated:!1,editMode:!1,currentUser:null,activeEditor:null,isInitialized:!1,isAuthenticating:!1},this.statusIndicator=null}init(){console.log("🔧 Insertr: Scanning for editor gates"),this.setupEditorGates()}initializeFullSystem(){this.state.isInitialized||(console.log("🔐 Initializing Insertr Editing System"),this.createAuthControls(),this.setupAuthenticationControls(),this.createStatusIndicator(),this.updateBodyClasses(),this.state.editMode=!0,this.state.isInitialized=!0,window.Insertr&&window.Insertr.startEditor&&window.Insertr.startEditor(),this.updateButtonStates(),this.updateStatusIndicator(),console.log("📱 Editing system active - Controls in bottom-right corner"),console.log("✏️ Edit mode enabled - Click elements to edit"))}setupEditorGates(){const e=document.querySelectorAll(".insertr-gate");0!==e.length?(console.log(`🚪 Found ${e.length} editor gate(s)`),this.addGateStyles(),e.forEach((e,t)=>{e.hasAttribute("data-original-text")||e.setAttribute("data-original-text",e.textContent),e.addEventListener("click",n=>{n.preventDefault(),this.handleGateClick(e,t)}),e.style.cursor="pointer"})):console.log("ℹ️ No .insertr-gate elements found - editor access disabled")}async handleGateClick(e,t){if(this.state.isAuthenticating)return void console.log("⏳ Authentication already in progress...");console.log(`🚀 Editor gate activated (gate ${t+1})`),this.state.isAuthenticating=!0;const n=e.textContent;e.setAttribute("data-original-text",n),e.textContent="⏳ Signing in...",e.style.pointerEvents="none";try{await this.performOAuthFlow(),this.initializeFullSystem(),this.options.hideGatesAfterAuth?this.hideAllGates():this.updateGateState()}catch(t){console.error("❌ Authentication failed:",t);const n=e.getAttribute("data-original-text");n&&(e.textContent=n),e.style.pointerEvents=""}finally{this.state.isAuthenticating=!1}}async performOAuthFlow(){if(this.options.mockAuth)return console.log("🔐 Mock OAuth: Simulating authentication..."),await new Promise(e=>setTimeout(e,1e3)),this.state.isAuthenticated=!0,this.state.currentUser={name:"Site Owner",email:"owner@example.com",role:"admin"},void console.log("✅ Mock OAuth: Authentication successful");throw new Error("Production OAuth not implemented yet")}hideAllGates(){document.body.classList.add("insertr-hide-gates"),console.log("🚪 Editor gates hidden (hideGatesAfterAuth enabled)")}updateGateState(){document.querySelectorAll(".insertr-gate").forEach(e=>{const t=e.getAttribute("data-original-text");t&&(e.textContent=t),e.style.pointerEvents="",e.style.opacity=""}),console.log("🚪 Editor gates restored to original state")}createAuthControls(){if(document.getElementById("insertr-auth-controls"))return;document.body.insertAdjacentHTML("beforeend",'\n
    \n \n \n
    \n '),this.addControlStyles()}setupAuthenticationControls(){const e=document.getElementById("insertr-auth-toggle"),t=document.getElementById("insertr-edit-toggle");e&&e.addEventListener("click",()=>this.toggleAuthentication()),t&&t.addEventListener("click",()=>this.toggleEditMode())}toggleAuthentication(){this.state.isAuthenticated=!this.state.isAuthenticated,this.state.currentUser=this.state.isAuthenticated?{name:"Demo User",email:"demo@example.com",role:"editor"}:null,this.state.isAuthenticated||(this.state.editMode=!1),this.updateBodyClasses(),this.updateButtonStates(),this.updateStatusIndicator(),console.log(this.state.isAuthenticated?"✅ Authenticated as Demo User":"❌ Logged out")}toggleEditMode(){this.state.isAuthenticated?(this.state.editMode=!this.state.editMode,!this.state.editMode&&this.state.activeEditor&&(this.state.activeEditor=null),this.updateBodyClasses(),this.updateButtonStates(),this.updateStatusIndicator(),console.log(this.state.editMode?"✏️ Edit mode ON - Click elements to edit":"👀 Edit mode OFF - Read-only view")):console.warn("❌ Cannot enable edit mode - not authenticated")}updateBodyClasses(){document.body.classList.toggle("insertr-authenticated",this.state.isAuthenticated),document.body.classList.toggle("insertr-edit-mode",this.state.editMode)}updateButtonStates(){const e=document.getElementById("insertr-auth-toggle"),t=document.getElementById("insertr-edit-toggle");e&&(e.textContent=this.state.isAuthenticated?"Logout":"Login as Client",e.className="insertr-auth-btn "+(this.state.isAuthenticated?"insertr-authenticated":"")),t&&(t.style.display=this.state.isAuthenticated?"inline-block":"none",t.textContent="Edit Mode: "+(this.state.editMode?"On":"Off"),t.className="insertr-auth-btn "+(this.state.editMode?"insertr-edit-active":""))}createStatusIndicator(){if(document.getElementById("insertr-status"))return;document.body.insertAdjacentHTML("beforeend",'\n
    \n
    \n Visitor Mode\n \n
    \n
    \n '),this.statusIndicator=document.getElementById("insertr-status"),this.updateStatusIndicator()}updateStatusIndicator(){const e=document.querySelector(".insertr-status-text"),t=document.querySelector(".insertr-status-dot");e&&t&&(this.state.isAuthenticated?this.state.editMode?(e.textContent="Editing",t.className="insertr-status-dot insertr-status-editing"):(e.textContent="Authenticated",t.className="insertr-status-dot insertr-status-authenticated"):(e.textContent="Visitor Mode",t.className="insertr-status-dot insertr-status-visitor"))}isAuthenticated(){return this.state.isAuthenticated}isEditMode(){return this.state.editMode}getCurrentUser(){return this.state.currentUser}addGateStyles(){const e=document.createElement("style");e.type="text/css",e.innerHTML="\n .insertr-gate {\n transition: opacity 0.2s ease;\n user-select: none;\n }\n\n .insertr-gate:hover {\n opacity: 0.7;\n }\n\n /* Optional: Hide gates when authenticated (only if hideGatesAfterAuth option is true) */\n body.insertr-hide-gates .insertr-gate {\n display: none !important;\n }\n ",document.head.appendChild(e)}addControlStyles(){const e=document.createElement("style");e.type="text/css",e.innerHTML="\n .insertr-auth-controls {\n position: fixed;\n bottom: 20px;\n right: 20px;\n z-index: 9999;\n display: flex;\n flex-direction: column;\n gap: 8px;\n font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;\n }\n\n .insertr-auth-btn {\n background: #4f46e5;\n color: white;\n border: none;\n padding: 8px 16px;\n border-radius: 6px;\n font-size: 14px;\n font-weight: 500;\n cursor: pointer;\n transition: all 0.2s;\n box-shadow: 0 2px 4px rgba(0,0,0,0.1);\n }\n\n .insertr-auth-btn:hover {\n background: #4338ca;\n transform: translateY(-1px);\n box-shadow: 0 4px 8px rgba(0,0,0,0.15);\n }\n\n .insertr-auth-btn.insertr-authenticated {\n background: #059669;\n }\n\n .insertr-auth-btn.insertr-authenticated:hover {\n background: #047857;\n }\n\n .insertr-auth-btn.insertr-edit-active {\n background: #dc2626;\n }\n\n .insertr-auth-btn.insertr-edit-active:hover {\n background: #b91c1c;\n }\n\n .insertr-status {\n position: fixed;\n bottom: 20px;\n left: 20px;\n z-index: 9999;\n background: white;\n border: 1px solid #e5e7eb;\n border-radius: 8px;\n padding: 8px 12px;\n box-shadow: 0 4px 12px rgba(0,0,0,0.1);\n font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;\n max-width: 200px;\n }\n\n .insertr-status-content {\n display: flex;\n align-items: center;\n gap: 8px;\n }\n\n .insertr-status-text {\n font-size: 12px;\n font-weight: 500;\n color: #374151;\n }\n\n .insertr-status-dot {\n width: 8px;\n height: 8px;\n border-radius: 50%;\n background: #9ca3af;\n }\n\n .insertr-status-dot.insertr-status-visitor {\n background: #9ca3af;\n }\n\n .insertr-status-dot.insertr-status-authenticated {\n background: #059669;\n }\n\n .insertr-status-dot.insertr-status-editing {\n background: #dc2626;\n animation: insertr-pulse 2s infinite;\n }\n\n @keyframes insertr-pulse {\n 0%, 100% { opacity: 1; }\n 50% { opacity: 0.5; }\n }\n\n /* Hide editing interface when not in edit mode */\n body:not(.insertr-edit-mode) .insertr:hover::after {\n display: none !important;\n }\n\n /* Only show editing features when in edit mode */\n .insertr-authenticated.insertr-edit-mode .insertr {\n cursor: pointer;\n }\n\n .insertr-authenticated.insertr-edit-mode .insertr:hover {\n outline: 2px dashed #007cba !important;\n outline-offset: 2px !important;\n background-color: rgba(0, 124, 186, 0.05) !important;\n }\n ",document.head.appendChild(e)}async authenticateWithOAuth(e="google"){console.log(`🔐 Mock OAuth login with ${e}`),setTimeout(()=>{this.state.isAuthenticated=!0,this.state.currentUser={name:"OAuth User",email:"user@example.com",provider:e,role:"editor"},this.updateBodyClasses(),this.updateButtonStates(),this.updateStatusIndicator(),console.log("✅ OAuth authentication successful")},1e3)}}class Ye{constructor(e={}){const t="localhost"===window.location.hostname||"127.0.0.1"===window.location.hostname,n=t?"http://localhost:8080/api/content":"/api/content";this.baseUrl=e.apiEndpoint||n,this.siteId=e.siteId||"demo",t&&!e.apiEndpoint&&console.log(`🔌 API Client: Using development server at ${this.baseUrl}`)}async getContent(e){try{const t=await fetch(`${this.baseUrl}/${e}?site_id=${this.siteId}`);return t.ok?await t.json():null}catch(t){return console.warn("Failed to fetch content:",e,t),null}}async updateContent(e,t){try{const n=await fetch(`${this.baseUrl}/${e}?site_id=${this.siteId}`,{method:"PUT",headers:{"Content-Type":"application/json","X-User-ID":this.getCurrentUser()},body:JSON.stringify({value:t})});return n.ok?(console.log(`✅ Content updated: ${e}`),!0):(console.warn(`⚠️ Update failed (${n.status}): ${e}`),!1)}catch(t){return"TypeError"===t.name&&t.message.includes("fetch")?(console.warn(`🔌 API Server not reachable at ${this.baseUrl}`),console.warn("💡 Start full-stack development: just dev")):console.error("Failed to update content:",e,t),!1}}async createContent(e,t,n){try{const r=await fetch(`${this.baseUrl}?site_id=${this.siteId}`,{method:"POST",headers:{"Content-Type":"application/json","X-User-ID":this.getCurrentUser()},body:JSON.stringify({id:e,value:t,type:n})});return r.ok?(console.log(`✅ Content created: ${e} (${n})`),!0):(console.warn(`⚠️ Create failed (${r.status}): ${e}`),!1)}catch(t){return"TypeError"===t.name&&t.message.includes("fetch")?(console.warn(`🔌 API Server not reachable at ${this.baseUrl}`),console.warn("💡 Start full-stack development: just dev")):console.error("Failed to create content:",e,t),!1}}async getContentVersions(e){try{const t=await fetch(`${this.baseUrl}/${e}/versions?site_id=${this.siteId}`);if(t.ok){return(await t.json()).versions||[]}return console.warn(`⚠️ Failed to fetch versions (${t.status}): ${e}`),[]}catch(t){return console.error("Failed to fetch version history:",e,t),[]}}async rollbackContent(e,t){try{const n=await fetch(`${this.baseUrl}/${e}/rollback?site_id=${this.siteId}`,{method:"POST",headers:{"Content-Type":"application/json","X-User-ID":this.getCurrentUser()},body:JSON.stringify({version_id:t})});return n.ok?(console.log(`✅ Content rolled back: ${e} to version ${t}`),await n.json()):(console.warn(`⚠️ Rollback failed (${n.status}): ${e}`),!1)}catch(t){return console.error("Failed to rollback content:",e,t),!1}}getCurrentUser(){return"anonymous"}}function Ke(){document.querySelector(".insertr")&&window.Insertr.init()}return window.Insertr={core:null,editor:null,auth:null,apiClient:null,init(t={}){return console.log("🔧 Insertr v1.0.0 initializing... (Hot Reload Ready)"),this.core=new e(t),this.auth=new Xe(t),this.apiClient=new Ye(t),this.editor=new We(this.core,this.auth,this.apiClient,t),"loading"===document.readyState?document.addEventListener("DOMContentLoaded",()=>this.start()):this.start(),this},start(){this.auth&&this.auth.init()},startEditor(){this.editor&&!this.editor.isActive&&this.editor.start()},login(){return this.auth?this.auth.toggleAuthentication():null},logout(){this.auth&&this.auth.isAuthenticated()&&this.auth.toggleAuthentication()},toggleEditMode(){return this.auth?this.auth.toggleEditMode():null},isAuthenticated(){return!!this.auth&&this.auth.isAuthenticated()},isEditMode(){return!!this.auth&&this.auth.isEditMode()}},"loading"===document.readyState?document.addEventListener("DOMContentLoaded",Ke):Ke(),window.Insertr}(); diff --git a/insertr-cli/pkg/content/client.go b/insertr-cli/pkg/content/client.go deleted file mode 100644 index 673d21e..0000000 --- a/insertr-cli/pkg/content/client.go +++ /dev/null @@ -1,164 +0,0 @@ -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 deleted file mode 100644 index 1de83ff..0000000 --- a/insertr-cli/pkg/content/enhancer.go +++ /dev/null @@ -1,216 +0,0 @@ -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 - 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) -} diff --git a/insertr-cli/pkg/content/injector.go b/insertr-cli/pkg/content/injector.go deleted file mode 100644 index b64e1ce..0000000 --- a/insertr-cli/pkg/content/injector.go +++ /dev/null @@ -1,236 +0,0 @@ -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: - // 2. Inject local script tag for development: - // 3. Continue with inline injection for certain use cases - - // Currently disabled to avoid duplicate scripts - return -} - -// 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, - }) -} - -// 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 -} diff --git a/insertr-cli/pkg/content/library.go b/insertr-cli/pkg/content/library.go deleted file mode 100644 index 06a3f36..0000000 --- a/insertr-cli/pkg/content/library.go +++ /dev/null @@ -1,50 +0,0 @@ -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) -} diff --git a/insertr-cli/pkg/content/mock.go b/insertr-cli/pkg/content/mock.go deleted file mode 100644 index 9d33d70..0000000 --- a/insertr-cli/pkg/content/mock.go +++ /dev/null @@ -1,138 +0,0 @@ -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 -} diff --git a/insertr-cli/pkg/content/types.go b/insertr-cli/pkg/content/types.go deleted file mode 100644 index b28270f..0000000 --- a/insertr-cli/pkg/content/types.go +++ /dev/null @@ -1,28 +0,0 @@ -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) -} diff --git a/insertr-cli/pkg/parser/id_generator.go b/insertr-cli/pkg/parser/id_generator.go deleted file mode 100644 index 932ed22..0000000 --- a/insertr-cli/pkg/parser/id_generator.go +++ /dev/null @@ -1,167 +0,0 @@ -package parser - -import ( - "crypto/sha1" - "fmt" - "regexp" - "strings" - - "golang.org/x/net/html" -) - -// IDGenerator generates unique content IDs for elements -type IDGenerator struct { - usedIDs map[string]bool -} - -// NewIDGenerator creates a new ID generator -func NewIDGenerator() *IDGenerator { - return &IDGenerator{ - usedIDs: make(map[string]bool), - } -} - -// Generate creates a content ID for an HTML element -func (g *IDGenerator) Generate(node *html.Node) string { - context := g.getSemanticContext(node) - purpose := g.getPurpose(node) - contentHash := g.getContentHash(node) - - baseID := g.createBaseID(context, purpose, contentHash) - return g.ensureUnique(baseID) -} - -// getSemanticContext determines the semantic context from parent elements -func (g *IDGenerator) getSemanticContext(node *html.Node) string { - // Walk up the tree to find semantic containers - parent := node.Parent - for parent != nil && parent.Type == html.ElementNode { - classes := getClasses(parent) - - // Check for common semantic section classes - for _, class := range []string{"hero", "services", "nav", "navbar", "footer", "about", "contact", "testimonial"} { - if containsClass(classes, class) { - return class - } - } - - // Check for semantic HTML elements - switch parent.Data { - case "nav": - return "nav" - case "header": - return "header" - case "footer": - return "footer" - case "main": - return "main" - case "aside": - return "aside" - } - - parent = parent.Parent - } - - return "content" -} - -// getPurpose determines the purpose/role of the element -func (g *IDGenerator) getPurpose(node *html.Node) string { - tag := strings.ToLower(node.Data) - classes := getClasses(node) - - // Check for specific CSS classes that indicate purpose - for _, class := range classes { - switch { - case strings.Contains(class, "title"): - return "title" - case strings.Contains(class, "headline"): - return "headline" - case strings.Contains(class, "description"): - return "description" - case strings.Contains(class, "subtitle"): - return "subtitle" - case strings.Contains(class, "cta"): - return "cta" - case strings.Contains(class, "button"): - return "button" - case strings.Contains(class, "logo"): - return "logo" - case strings.Contains(class, "lead"): - return "lead" - } - } - - // Infer purpose from HTML tag - switch tag { - case "h1": - return "title" - case "h2": - return "subtitle" - case "h3", "h4", "h5", "h6": - return "heading" - case "p": - return "text" - case "a": - return "link" - case "button": - return "button" - default: - return "content" - } -} - -// getContentHash creates a short hash of the content for ID generation -func (g *IDGenerator) getContentHash(node *html.Node) string { - text := extractTextContent(node) - - // Create hash of the text content - hash := fmt.Sprintf("%x", sha1.Sum([]byte(text))) - - // Return first 6 characters for brevity - return hash[:6] -} - -// createBaseID creates the base ID from components -func (g *IDGenerator) createBaseID(context, purpose, contentHash string) string { - parts := []string{} - - // Add context if meaningful - if context != "content" { - parts = append(parts, context) - } - - // Add purpose - parts = append(parts, purpose) - - // Always add content hash for uniqueness - parts = append(parts, contentHash) - - baseID := strings.Join(parts, "-") - - // Clean up the ID - baseID = regexp.MustCompile(`-+`).ReplaceAllString(baseID, "-") - baseID = strings.Trim(baseID, "-") - - // Ensure it's not empty - if baseID == "" { - baseID = fmt.Sprintf("content-%s", contentHash) - } - - return baseID -} - -// ensureUnique makes sure the ID is unique by adding a suffix if needed -func (g *IDGenerator) ensureUnique(baseID string) string { - if !g.usedIDs[baseID] { - g.usedIDs[baseID] = true - return baseID - } - - // If base ID is taken, add a hash suffix - hash := fmt.Sprintf("%x", sha1.Sum([]byte(baseID)))[:6] - uniqueID := fmt.Sprintf("%s-%s", baseID, hash) - - g.usedIDs[uniqueID] = true - return uniqueID -} diff --git a/insertr-cli/pkg/parser/parser.go b/insertr-cli/pkg/parser/parser.go deleted file mode 100644 index add1a87..0000000 --- a/insertr-cli/pkg/parser/parser.go +++ /dev/null @@ -1,229 +0,0 @@ -package parser - -import ( - "fmt" - "io/fs" - "os" - "path/filepath" - "strings" - - "golang.org/x/net/html" -) - -// Parser handles HTML parsing and element detection -type Parser struct { - idGenerator *IDGenerator -} - -// New creates a new Parser instance -func New() *Parser { - return &Parser{ - idGenerator: NewIDGenerator(), - } -} - -// ParseDirectory parses all HTML files in the given directory -func (p *Parser) ParseDirectory(dir string) (*ParseResult, error) { - result := &ParseResult{ - Elements: []Element{}, - Warnings: []string{}, - Stats: ParseStats{ - TypeBreakdown: make(map[ContentType]int), - }, - } - - err := filepath.WalkDir(dir, func(path string, d fs.DirEntry, err error) error { - if err != nil { - return err - } - - // Only process HTML files - if d.IsDir() || !strings.HasSuffix(strings.ToLower(path), ".html") { - return nil - } - - elements, warnings, err := p.parseFile(path) - if err != nil { - result.Warnings = append(result.Warnings, - fmt.Sprintf("Error parsing %s: %v", path, err)) - return nil // Continue processing other files - } - - result.Elements = append(result.Elements, elements...) - result.Warnings = append(result.Warnings, warnings...) - result.Stats.FilesProcessed++ - - return nil - }) - - if err != nil { - return nil, fmt.Errorf("error walking directory: %w", err) - } - - // Calculate statistics - p.calculateStats(result) - - return result, nil -} - -// parseFile parses a single HTML file -func (p *Parser) parseFile(filePath string) ([]Element, []string, error) { - file, err := os.Open(filePath) - if err != nil { - return nil, nil, fmt.Errorf("error opening file: %w", err) - } - defer file.Close() - - doc, err := html.Parse(file) - if err != nil { - return nil, nil, fmt.Errorf("error parsing HTML: %w", err) - } - - var elements []Element - var warnings []string - - p.findInsertrElements(doc, filePath, &elements, &warnings) - - return elements, warnings, nil -} - -// findInsertrElements recursively finds all elements with "insertr" class -func (p *Parser) findInsertrElements(node *html.Node, filePath string, elements *[]Element, warnings *[]string) { - if node.Type == html.ElementNode { - classes := getClasses(node) - - // Check if element has "insertr" class - if containsClass(classes, "insertr") { - if isContainer(node) { - // Container element - expand to viable children - viableChildren := findViableChildren(node) - for _, child := range viableChildren { - childClasses := getClasses(child) - element, warning := p.createElement(child, filePath, childClasses) - *elements = append(*elements, element) - if warning != "" { - *warnings = append(*warnings, warning) - } - } - - // Don't process children recursively since we've handled the container's children - return - } else { - // Regular element - process as before - element, warning := p.createElement(node, filePath, classes) - *elements = append(*elements, element) - if warning != "" { - *warnings = append(*warnings, warning) - } - } - } - } - - // Recursively check children - for child := node.FirstChild; child != nil; child = child.NextSibling { - p.findInsertrElements(child, filePath, elements, warnings) - } -} - -// createElement creates an Element from an HTML node -func (p *Parser) createElement(node *html.Node, filePath string, classes []string) (Element, string) { - var warning string - - // Resolve content ID (existing or generated) - contentID, hasExistingID := p.resolveContentID(node) - if !hasExistingID { - contentID = p.idGenerator.Generate(node) - } - - // Detect content type - contentType := p.detectContentType(node, classes) - - // Extract text content - content := extractTextContent(node) - - element := Element{ - FilePath: filePath, - Node: node, - ContentID: contentID, - Type: contentType, - Tag: strings.ToLower(node.Data), - Classes: classes, - Content: content, - HasID: hasExistingID, - Generated: !hasExistingID, - } - - // Generate warnings for edge cases - if content == "" { - warning = fmt.Sprintf("Element <%s> with id '%s' has no text content", - element.Tag, element.ContentID) - } - - return element, warning -} - -// resolveContentID gets the content ID from existing attributes -func (p *Parser) resolveContentID(node *html.Node) (string, bool) { - // 1. Check for existing HTML id attribute - if id := getAttribute(node, "id"); id != "" { - return id, true - } - - // 2. Check for data-content-id attribute - if contentID := getAttribute(node, "data-content-id"); contentID != "" { - return contentID, true - } - - // 3. No existing ID found - return "", false -} - -// detectContentType determines the content type based on element and classes -func (p *Parser) detectContentType(node *html.Node, classes []string) ContentType { - // Check for explicit type classes first - if containsClass(classes, "insertr-markdown") { - return ContentMarkdown - } - if containsClass(classes, "insertr-link") { - return ContentLink - } - if containsClass(classes, "insertr-text") { - return ContentText - } - - // Infer from HTML tag and context - tag := strings.ToLower(node.Data) - switch tag { - case "h1", "h2", "h3", "h4", "h5", "h6": - return ContentText - case "p": - // Paragraphs default to markdown for rich content - return ContentMarkdown - case "a", "button": - return ContentLink - case "div", "section": - // Default divs/sections to markdown for rich content - return ContentMarkdown - case "span": - return ContentText - default: - return ContentText - } -} - -// calculateStats computes statistics for the parse result -func (p *Parser) calculateStats(result *ParseResult) { - result.Stats.TotalElements = len(result.Elements) - - for _, element := range result.Elements { - // Count existing vs generated IDs - if element.HasID { - result.Stats.ExistingIDs++ - } else { - result.Stats.GeneratedIDs++ - } - - // Count content types - result.Stats.TypeBreakdown[element.Type]++ - } -} diff --git a/insertr-cli/pkg/parser/types.go b/insertr-cli/pkg/parser/types.go deleted file mode 100644 index ad1d22e..0000000 --- a/insertr-cli/pkg/parser/types.go +++ /dev/null @@ -1,41 +0,0 @@ -package parser - -import "golang.org/x/net/html" - -// ContentType represents the type of editable content -type ContentType string - -const ( - ContentText ContentType = "text" - ContentMarkdown ContentType = "markdown" - ContentLink ContentType = "link" -) - -// Element represents a parsed editable element -type Element struct { - FilePath string `json:"file_path"` - Node *html.Node `json:"-"` // Don't serialize HTML node - ContentID string `json:"content_id"` - Type ContentType `json:"type"` - Tag string `json:"tag"` - Classes []string `json:"classes"` - Content string `json:"content"` - HasID bool `json:"has_id"` // Whether element had existing ID - Generated bool `json:"generated"` // Whether ID was generated -} - -// ParseResult contains the results of parsing HTML files -type ParseResult struct { - Elements []Element `json:"elements"` - Warnings []string `json:"warnings"` - Stats ParseStats `json:"stats"` -} - -// ParseStats provides statistics about the parsing operation -type ParseStats struct { - FilesProcessed int `json:"files_processed"` - TotalElements int `json:"total_elements"` - ExistingIDs int `json:"existing_ids"` - GeneratedIDs int `json:"generated_ids"` - TypeBreakdown map[ContentType]int `json:"type_breakdown"` -} diff --git a/insertr-cli/pkg/parser/utils.go b/insertr-cli/pkg/parser/utils.go deleted file mode 100644 index d4de57c..0000000 --- a/insertr-cli/pkg/parser/utils.go +++ /dev/null @@ -1,159 +0,0 @@ -package parser - -import ( - "strings" - - "golang.org/x/net/html" -) - -// getClasses extracts CSS classes from an HTML node -func getClasses(node *html.Node) []string { - classAttr := getAttribute(node, "class") - if classAttr == "" { - return []string{} - } - - classes := strings.Fields(classAttr) - return classes -} - -// containsClass checks if a class list contains a specific class -func containsClass(classes []string, target string) bool { - for _, class := range classes { - if class == target { - return true - } - } - return false -} - -// getAttribute gets an attribute value from an HTML node -func getAttribute(node *html.Node, key string) string { - for _, attr := range node.Attr { - if attr.Key == key { - return attr.Val - } - } - return "" -} - -// extractTextContent gets the text content from an HTML node -func extractTextContent(node *html.Node) string { - var text strings.Builder - extractTextRecursive(node, &text) - return strings.TrimSpace(text.String()) -} - -// extractTextRecursive recursively extracts text from node and children -func extractTextRecursive(node *html.Node, text *strings.Builder) { - if node.Type == html.TextNode { - text.WriteString(node.Data) - } - - for child := node.FirstChild; child != nil; child = child.NextSibling { - // Skip script and style elements - if child.Type == html.ElementNode && - (child.Data == "script" || child.Data == "style") { - continue - } - extractTextRecursive(child, text) - } -} - -// hasOnlyTextContent checks if a node contains only text content (no nested HTML elements) -func hasOnlyTextContent(node *html.Node) bool { - if node.Type != html.ElementNode { - return false - } - - for child := node.FirstChild; child != nil; child = child.NextSibling { - switch child.Type { - case html.ElementNode: - // Found a nested HTML element - not text-only - return false - case html.TextNode: - // Text nodes are fine, continue checking - continue - default: - // Comments, etc. - continue checking - continue - } - } - return true -} - -// isContainer checks if a tag is typically used as a container element -func isContainer(node *html.Node) bool { - if node.Type != html.ElementNode { - return false - } - - containerTags := map[string]bool{ - "div": true, - "section": true, - "article": true, - "header": true, - "footer": true, - "main": true, - "aside": true, - "nav": true, - } - - return containerTags[node.Data] -} - -// findViableChildren finds all child elements that are viable for editing -func findViableChildren(node *html.Node) []*html.Node { - var viable []*html.Node - - for child := node.FirstChild; child != nil; child = child.NextSibling { - // Skip whitespace-only text nodes - if child.Type == html.TextNode { - if strings.TrimSpace(child.Data) == "" { - continue - } - } - - // Only consider element nodes - if child.Type != html.ElementNode { - continue - } - - // Skip self-closing elements for now - if isSelfClosing(child) { - continue - } - - // Check if element has only text content - if hasOnlyTextContent(child) { - viable = append(viable, child) - } - } - - return viable -} - -// isSelfClosing checks if an element is typically self-closing -func isSelfClosing(node *html.Node) bool { - if node.Type != html.ElementNode { - return false - } - - selfClosingTags := map[string]bool{ - "img": true, - "input": true, - "br": true, - "hr": true, - "meta": true, - "link": true, - "area": true, - "base": true, - "col": true, - "embed": true, - "source": true, - "track": true, - "wbr": true, - } - - return selfClosingTags[node.Data] -} diff --git a/insertr-cli/scripts/rebuild-library.sh b/insertr-cli/scripts/rebuild-library.sh deleted file mode 100755 index b34cb74..0000000 --- a/insertr-cli/scripts/rebuild-library.sh +++ /dev/null @@ -1,20 +0,0 @@ -#!/bin/bash - -# Rebuild library and copy to CLI assets -# Used by Air for hot reloading when library changes - -set -e - -echo "🔄 Library changed, rebuilding..." - -# Build the library (this will also copy to demo-site) -cd ../lib -npm run build --silent - -# Copy to CLI assets -echo "📁 Copying updated library to CLI assets..." -cp dist/* ../insertr-cli/pkg/content/assets/ - -# Get library version for confirmation -VERSION=$(node -e "console.log(require('./package.json').version)") -echo "✅ Library v$VERSION ready for CLI embedding" \ No newline at end of file diff --git a/insertr-server/README.md b/insertr-server/README.md deleted file mode 100644 index aa649bc..0000000 --- a/insertr-server/README.md +++ /dev/null @@ -1,144 +0,0 @@ -# ⚠️ Deprecated: Insertr Content Server - -> **This standalone server has been replaced by the unified `insertr` binary.** -> **Please see the [main README](../README.md) for updated instructions.** - -## 🔄 Migration to Unified Binary - -The Insertr Content Server functionality has been integrated into the unified `insertr` binary. Instead of running a separate server binary, use: - -```bash -# Old approach (deprecated) -./insertr-server --port 8080 - -# New unified approach -./insertr serve --port 8080 --dev-mode -``` - -## ✅ All Features Preserved - -The unified binary includes all server functionality: - -- **Content Management**: Full CRUD operations for content items -- **Version Control**: Complete edit history with rollback functionality -- **User Attribution**: Track who made each change -- **Type-Safe Database**: Uses sqlc for generated Go code from SQL -- **SQLite & PostgreSQL**: Database flexibility for development to production - -## 🚀 Updated API Endpoints - -### Content Operations (Unchanged) -- `GET /api/content?site_id={site}` - Get all content for a site -- `GET /api/content/{id}?site_id={site}` - Get single content item -- `GET /api/content/bulk?site_id={site}&ids[]={id1}&ids[]={id2}` - Get multiple content items -- `POST /api/content` - Create new content -- `PUT /api/content/{id}?site_id={site}` - Update existing content -- `DELETE /api/content/{id}?site_id={site}` - Delete content - -### Version Control (Unchanged) -- `GET /api/content/{id}/versions?site_id={site}` - Get version history -- `POST /api/content/{id}/rollback?site_id={site}` - Rollback to specific version - -### Health & Status (Unchanged) -- `GET /health` - Server health check - -## 🚀 New Quick Start (Unified Binary) - -```bash -# Build unified binary (from project root) -go build -o insertr . - -# Start server with development mode -./insertr serve --dev-mode --port 8080 - -# Start production server -./insertr serve --port 8080 --db "postgresql://user:pass@host/db" - -# Check health (same endpoint) -curl http://localhost:8080/health -``` - -## User Attribution (Unchanged) - -All content operations still support user attribution via the `X-User-ID` header: - -```bash -curl -X PUT "http://localhost:8080/api/content/hero-title?site_id=demo" \ - -H "Content-Type: application/json" \ - -H "X-User-ID: john@example.com" \ - -d '{"value": "Updated content"}' -``` - -## 🛠️ Development (Updated for Unified Binary) - -### Using sqlc (From Project Root) - -```bash -# Install sqlc -go install github.com/sqlc-dev/sqlc/cmd/sqlc@latest - -# Generate Go code from SQL (from project root) -sqlc generate - -# Build unified binary -go build -o insertr . - -# Development with hot reload -just dev # Full-stack development -air # Hot reload unified binary only -``` - -### Database Schema (Location Updated) - -See `db/sqlite/schema.sql` and `db/postgresql/schema.sql` for database-specific schemas. Key tables: - -- `content` - Current content versions -- `content_versions` - Complete version history - -### Example Version Control Workflow - -```bash -# Create content -curl -X POST "http://localhost:8080/api/content" \ - -H "Content-Type: application/json" \ - -H "X-User-ID: alice@example.com" \ - -d '{ - "id": "hero-title", - "site_id": "demo", - "value": "Original Title", - "type": "text" - }' - -# Update content (creates version) -curl -X PUT "http://localhost:8080/api/content/hero-title?site_id=demo" \ - -H "Content-Type: application/json" \ - -H "X-User-ID: bob@example.com" \ - -d '{"value": "Updated Title"}' - -# View version history -curl "http://localhost:8080/api/content/hero-title/versions?site_id=demo" - -# Rollback to version 1 -curl -X POST "http://localhost:8080/api/content/hero-title/rollback?site_id=demo" \ - -H "Content-Type: application/json" \ - -H "X-User-ID: admin@example.com" \ - -d '{"version_id": 1}' -``` - ---- - -## 📖 For Complete Documentation - -**➡️ See the [main README](../README.md) for:** -- Unified binary installation and usage -- Complete configuration options (YAML, environment variables, CLI flags) -- Development workflow with `just dev` -- Production deployment guidance -- Full architecture documentation - -**➡️ See [INTEGRATION-SUMMARY.md](../INTEGRATION-SUMMARY.md) for:** -- Technical architecture details -- Database schema information -- API integration examples - -The unified `insertr` binary provides all server functionality with improved developer experience and simplified deployment. \ No newline at end of file diff --git a/insertr-server/cmd/server/main.go b/insertr-server/cmd/server/main.go deleted file mode 100644 index 6f37463..0000000 --- a/insertr-server/cmd/server/main.go +++ /dev/null @@ -1,107 +0,0 @@ -package main - -import ( - "flag" - "fmt" - "log" - "net/http" - "os" - "os/signal" - "syscall" - - "github.com/gorilla/mux" - "github.com/insertr/server/internal/api" - "github.com/insertr/server/internal/db" -) - -func main() { - // Command line flags - var ( - port = flag.Int("port", 8080, "Server port") - dbPath = flag.String("db", "./insertr.db", "SQLite database path") - ) - flag.Parse() - - // Initialize database - database, err := db.NewDatabase(*dbPath) - if err != nil { - log.Fatalf("Failed to initialize database: %v", err) - } - defer database.Close() - - // Initialize handlers - contentHandler := api.NewContentHandler(database) - - // Setup router - router := mux.NewRouter() - - // Add middleware - router.Use(api.CORSMiddleware) - router.Use(api.LoggingMiddleware) - router.Use(api.ContentTypeMiddleware) - - // Health check endpoint - router.HandleFunc("/health", api.HealthMiddleware()) - - // API routes - apiRouter := router.PathPrefix("/api/content").Subrouter() - - // Content endpoints matching the expected API contract - apiRouter.HandleFunc("/bulk", contentHandler.GetBulkContent).Methods("GET") - apiRouter.HandleFunc("/{id}/versions", contentHandler.GetContentVersions).Methods("GET") - apiRouter.HandleFunc("/{id}/rollback", contentHandler.RollbackContent).Methods("POST") - apiRouter.HandleFunc("/{id}", contentHandler.GetContent).Methods("GET") - apiRouter.HandleFunc("/{id}", contentHandler.UpdateContent).Methods("PUT") - apiRouter.HandleFunc("/{id}", contentHandler.DeleteContent).Methods("DELETE") - apiRouter.HandleFunc("", contentHandler.GetAllContent).Methods("GET") - apiRouter.HandleFunc("", contentHandler.CreateContent).Methods("POST") - - // Handle CORS preflight requests explicitly - apiRouter.HandleFunc("/{id}/versions", api.CORSPreflightHandler).Methods("OPTIONS") - apiRouter.HandleFunc("/{id}/rollback", api.CORSPreflightHandler).Methods("OPTIONS") - apiRouter.HandleFunc("/{id}", api.CORSPreflightHandler).Methods("OPTIONS") - apiRouter.HandleFunc("", api.CORSPreflightHandler).Methods("OPTIONS") - apiRouter.HandleFunc("/bulk", api.CORSPreflightHandler).Methods("OPTIONS") - - // Start server - addr := fmt.Sprintf(":%d", *port) - fmt.Printf("🚀 Insertr Content Server starting...\n") - fmt.Printf("📁 Database: %s\n", *dbPath) - fmt.Printf("🌐 Server running at: http://localhost%s\n", addr) - fmt.Printf("💚 Health check: http://localhost%s/health\n", addr) - fmt.Printf("📊 API endpoints:\n") - fmt.Printf(" GET /api/content?site_id={site}\n") - fmt.Printf(" GET /api/content/{id}?site_id={site}\n") - fmt.Printf(" GET /api/content/bulk?site_id={site}&ids[]={id1}&ids[]={id2}\n") - fmt.Printf(" GET /api/content/{id}/versions?site_id={site}\n") - fmt.Printf(" POST /api/content\n") - fmt.Printf(" PUT /api/content/{id}\n") - fmt.Printf(" POST /api/content/{id}/rollback\n") - fmt.Printf(" DELETE /api/content/{id}?site_id={site}\n") - fmt.Printf("\n🔄 Press Ctrl+C to shutdown gracefully\n\n") - - // Setup graceful shutdown - server := &http.Server{ - Addr: addr, - Handler: router, - } - - // Start server in a goroutine - go func() { - if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed { - log.Fatalf("Server failed to start: %v", err) - } - }() - - // Wait for interrupt signal - quit := make(chan os.Signal, 1) - signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM) - <-quit - - fmt.Println("\n🛑 Shutting down server...") - if err := server.Close(); err != nil { - log.Fatalf("Server forced to shutdown: %v", err) - } - - fmt.Println("✅ Server shutdown complete") -} diff --git a/insertr-server/db/postgresql/schema.sql b/insertr-server/db/postgresql/schema.sql deleted file mode 100644 index b64133f..0000000 --- a/insertr-server/db/postgresql/schema.sql +++ /dev/null @@ -1,42 +0,0 @@ --- PostgreSQL-specific schema with BIGINT UNIX timestamps --- Main content table (current versions only) -CREATE TABLE content ( - id TEXT NOT NULL, - site_id TEXT NOT NULL, - value TEXT NOT NULL, - type TEXT NOT NULL CHECK (type IN ('text', 'markdown', 'link')), - created_at BIGINT DEFAULT EXTRACT(EPOCH FROM NOW()) NOT NULL, - updated_at BIGINT DEFAULT EXTRACT(EPOCH FROM NOW()) NOT NULL, - last_edited_by TEXT DEFAULT 'system' NOT NULL, - PRIMARY KEY (id, site_id) -); - --- Version history table for rollback functionality -CREATE TABLE content_versions ( - version_id SERIAL PRIMARY KEY, - content_id TEXT NOT NULL, - site_id TEXT NOT NULL, - value TEXT NOT NULL, - type TEXT NOT NULL, - created_at BIGINT DEFAULT EXTRACT(EPOCH FROM NOW()) NOT NULL, - created_by TEXT DEFAULT 'system' NOT NULL -); - --- Indexes for performance -CREATE INDEX IF NOT EXISTS idx_content_site_id ON content(site_id); -CREATE INDEX IF NOT EXISTS idx_content_updated_at ON content(updated_at); -CREATE INDEX IF NOT EXISTS idx_content_versions_lookup ON content_versions(content_id, site_id, created_at DESC); - --- Function and trigger to automatically update updated_at timestamp -CREATE OR REPLACE FUNCTION update_updated_at_column() -RETURNS TRIGGER AS $$ -BEGIN - NEW.updated_at = EXTRACT(EPOCH FROM NOW()); - RETURN NEW; -END; -$$ language 'plpgsql'; - -CREATE TRIGGER update_content_updated_at - BEFORE UPDATE ON content - FOR EACH ROW - EXECUTE FUNCTION update_updated_at_column(); \ No newline at end of file diff --git a/insertr-server/db/postgresql/setup.sql b/insertr-server/db/postgresql/setup.sql deleted file mode 100644 index e9dcd2f..0000000 --- a/insertr-server/db/postgresql/setup.sql +++ /dev/null @@ -1,47 +0,0 @@ --- name: InitializeSchema :exec -CREATE TABLE IF NOT EXISTS content ( - id TEXT NOT NULL, - site_id TEXT NOT NULL, - value TEXT NOT NULL, - type TEXT NOT NULL CHECK (type IN ('text', 'markdown', 'link')), - created_at BIGINT DEFAULT (EXTRACT(EPOCH FROM NOW())) NOT NULL, - updated_at BIGINT DEFAULT (EXTRACT(EPOCH FROM NOW())) NOT NULL, - last_edited_by TEXT DEFAULT 'system' NOT NULL, - PRIMARY KEY (id, site_id) -); - --- name: InitializeVersionsTable :exec -CREATE TABLE IF NOT EXISTS content_versions ( - version_id SERIAL PRIMARY KEY, - content_id TEXT NOT NULL, - site_id TEXT NOT NULL, - value TEXT NOT NULL, - type TEXT NOT NULL, - created_at BIGINT DEFAULT (EXTRACT(EPOCH FROM NOW())) NOT NULL, - created_by TEXT DEFAULT 'system' NOT NULL -); - --- name: CreateContentSiteIndex :exec -CREATE INDEX IF NOT EXISTS idx_content_site_id ON content(site_id); - --- name: CreateContentUpdatedAtIndex :exec -CREATE INDEX IF NOT EXISTS idx_content_updated_at ON content(updated_at); - --- name: CreateVersionsLookupIndex :exec -CREATE INDEX IF NOT EXISTS idx_content_versions_lookup ON content_versions(content_id, site_id, created_at DESC); - --- name: CreateUpdateFunction :exec -CREATE OR REPLACE FUNCTION update_content_timestamp() -RETURNS TRIGGER AS $$ -BEGIN - NEW.updated_at = EXTRACT(EPOCH FROM NOW()); - RETURN NEW; -END; -$$ LANGUAGE plpgsql; - --- name: CreateUpdateTrigger :exec -DROP TRIGGER IF EXISTS update_content_updated_at ON content; -CREATE TRIGGER update_content_updated_at -BEFORE UPDATE ON content -FOR EACH ROW -EXECUTE FUNCTION update_content_timestamp(); \ No newline at end of file diff --git a/insertr-server/db/queries/content.sql b/insertr-server/db/queries/content.sql deleted file mode 100644 index 0bc22ff..0000000 --- a/insertr-server/db/queries/content.sql +++ /dev/null @@ -1,30 +0,0 @@ --- name: GetContent :one -SELECT id, site_id, value, type, created_at, updated_at, last_edited_by -FROM content -WHERE id = sqlc.arg(id) AND site_id = sqlc.arg(site_id); - --- name: GetAllContent :many -SELECT id, site_id, value, type, created_at, updated_at, last_edited_by -FROM content -WHERE site_id = sqlc.arg(site_id) -ORDER BY updated_at DESC; - --- name: GetBulkContent :many -SELECT id, site_id, value, type, created_at, updated_at, last_edited_by -FROM content -WHERE site_id = sqlc.arg(site_id) AND id IN (sqlc.slice('ids')); - --- name: CreateContent :one -INSERT INTO content (id, site_id, value, type, last_edited_by) -VALUES (sqlc.arg(id), sqlc.arg(site_id), sqlc.arg(value), sqlc.arg(type), sqlc.arg(last_edited_by)) -RETURNING id, site_id, value, type, created_at, updated_at, last_edited_by; - --- name: UpdateContent :one -UPDATE content -SET value = sqlc.arg(value), type = sqlc.arg(type), last_edited_by = sqlc.arg(last_edited_by) -WHERE id = sqlc.arg(id) AND site_id = sqlc.arg(site_id) -RETURNING id, site_id, value, type, created_at, updated_at, last_edited_by; - --- name: DeleteContent :exec -DELETE FROM content -WHERE id = sqlc.arg(id) AND site_id = sqlc.arg(site_id); \ No newline at end of file diff --git a/insertr-server/db/queries/versions.sql b/insertr-server/db/queries/versions.sql deleted file mode 100644 index 1339907..0000000 --- a/insertr-server/db/queries/versions.sql +++ /dev/null @@ -1,29 +0,0 @@ --- name: CreateContentVersion :exec -INSERT INTO content_versions (content_id, site_id, value, type, created_by) -VALUES (sqlc.arg(content_id), sqlc.arg(site_id), sqlc.arg(value), sqlc.arg(type), sqlc.arg(created_by)); - --- name: GetContentVersionHistory :many -SELECT version_id, content_id, site_id, value, type, created_at, created_by -FROM content_versions -WHERE content_id = sqlc.arg(content_id) AND site_id = sqlc.arg(site_id) -ORDER BY created_at DESC -LIMIT sqlc.arg(limit_count); - --- name: GetContentVersion :one -SELECT version_id, content_id, site_id, value, type, created_at, created_by -FROM content_versions -WHERE version_id = sqlc.arg(version_id); - --- name: GetAllVersionsForSite :many -SELECT - cv.version_id, cv.content_id, cv.site_id, cv.value, cv.type, cv.created_at, cv.created_by, - c.value as current_value -FROM content_versions cv -LEFT JOIN content c ON cv.content_id = c.id AND cv.site_id = c.site_id -WHERE cv.site_id = sqlc.arg(site_id) -ORDER BY cv.created_at DESC -LIMIT sqlc.arg(limit_count); - --- name: DeleteOldVersions :exec -DELETE FROM content_versions -WHERE created_at < sqlc.arg(created_before) AND site_id = sqlc.arg(site_id); \ No newline at end of file diff --git a/insertr-server/db/sqlite/schema.sql b/insertr-server/db/sqlite/schema.sql deleted file mode 100644 index 88c4fe9..0000000 --- a/insertr-server/db/sqlite/schema.sql +++ /dev/null @@ -1,36 +0,0 @@ --- SQLite-specific schema with INTEGER timestamps --- Main content table (current versions only) -CREATE TABLE content ( - id TEXT NOT NULL, - site_id TEXT NOT NULL, - value TEXT NOT NULL, - type TEXT NOT NULL CHECK (type IN ('text', 'markdown', 'link')), - created_at INTEGER DEFAULT (strftime('%s', 'now')) NOT NULL, - updated_at INTEGER DEFAULT (strftime('%s', 'now')) NOT NULL, - last_edited_by TEXT DEFAULT 'system' NOT NULL, - PRIMARY KEY (id, site_id) -); - --- Version history table for rollback functionality -CREATE TABLE content_versions ( - version_id INTEGER PRIMARY KEY AUTOINCREMENT, - content_id TEXT NOT NULL, - site_id TEXT NOT NULL, - value TEXT NOT NULL, - type TEXT NOT NULL, - created_at INTEGER DEFAULT (strftime('%s', 'now')) NOT NULL, - created_by TEXT DEFAULT 'system' NOT NULL -); - --- Indexes for performance -CREATE INDEX IF NOT EXISTS idx_content_site_id ON content(site_id); -CREATE INDEX IF NOT EXISTS idx_content_updated_at ON content(updated_at); -CREATE INDEX IF NOT EXISTS idx_content_versions_lookup ON content_versions(content_id, site_id, created_at DESC); - --- Trigger to automatically update updated_at timestamp -CREATE TRIGGER IF NOT EXISTS update_content_updated_at -AFTER UPDATE ON content -FOR EACH ROW -BEGIN - UPDATE content SET updated_at = strftime('%s', 'now') WHERE id = NEW.id AND site_id = NEW.site_id; -END; \ No newline at end of file diff --git a/insertr-server/db/sqlite/setup.sql b/insertr-server/db/sqlite/setup.sql deleted file mode 100644 index bfe8fcd..0000000 --- a/insertr-server/db/sqlite/setup.sql +++ /dev/null @@ -1,39 +0,0 @@ --- name: InitializeSchema :exec -CREATE TABLE IF NOT EXISTS content ( - id TEXT NOT NULL, - site_id TEXT NOT NULL, - value TEXT NOT NULL, - type TEXT NOT NULL CHECK (type IN ('text', 'markdown', 'link')), - created_at INTEGER DEFAULT (strftime('%s', 'now')) NOT NULL, - updated_at INTEGER DEFAULT (strftime('%s', 'now')) NOT NULL, - last_edited_by TEXT DEFAULT 'system' NOT NULL, - PRIMARY KEY (id, site_id) -); - --- name: InitializeVersionsTable :exec -CREATE TABLE IF NOT EXISTS content_versions ( - version_id INTEGER PRIMARY KEY AUTOINCREMENT, - content_id TEXT NOT NULL, - site_id TEXT NOT NULL, - value TEXT NOT NULL, - type TEXT NOT NULL, - created_at INTEGER DEFAULT (strftime('%s', 'now')) NOT NULL, - created_by TEXT DEFAULT 'system' NOT NULL -); - --- name: CreateContentSiteIndex :exec -CREATE INDEX IF NOT EXISTS idx_content_site_id ON content(site_id); - --- name: CreateContentUpdatedAtIndex :exec -CREATE INDEX IF NOT EXISTS idx_content_updated_at ON content(updated_at); - --- name: CreateVersionsLookupIndex :exec -CREATE INDEX IF NOT EXISTS idx_content_versions_lookup ON content_versions(content_id, site_id, created_at DESC); - --- name: CreateUpdateTrigger :exec -CREATE TRIGGER IF NOT EXISTS update_content_updated_at -AFTER UPDATE ON content -FOR EACH ROW -BEGIN - UPDATE content SET updated_at = strftime('%s', 'now') WHERE id = NEW.id AND site_id = NEW.site_id; -END; \ No newline at end of file diff --git a/insertr-server/go.mod b/insertr-server/go.mod deleted file mode 100644 index 369583b..0000000 --- a/insertr-server/go.mod +++ /dev/null @@ -1,10 +0,0 @@ -module github.com/insertr/server - -go 1.24.6 - -require ( - github.com/gorilla/mux v1.8.1 - github.com/mattn/go-sqlite3 v1.14.32 -) - -require github.com/lib/pq v1.10.9 // indirect diff --git a/insertr-server/go.sum b/insertr-server/go.sum deleted file mode 100644 index 03194b7..0000000 --- a/insertr-server/go.sum +++ /dev/null @@ -1,6 +0,0 @@ -github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY= -github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ= -github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= -github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= -github.com/mattn/go-sqlite3 v1.14.32 h1:JD12Ag3oLy1zQA+BNn74xRgaBbdhbNIDYvQUEuuErjs= -github.com/mattn/go-sqlite3 v1.14.32/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= diff --git a/insertr-server/insertr-server b/insertr-server/insertr-server deleted file mode 100755 index 4d4c725..0000000 Binary files a/insertr-server/insertr-server and /dev/null differ diff --git a/insertr-server/internal/api/handlers.go b/insertr-server/internal/api/handlers.go deleted file mode 100644 index 7ed901e..0000000 --- a/insertr-server/internal/api/handlers.go +++ /dev/null @@ -1,668 +0,0 @@ -package api - -import ( - "context" - "database/sql" - "encoding/json" - "fmt" - "net/http" - "strconv" - "strings" - "time" - - "github.com/gorilla/mux" - "github.com/insertr/server/internal/db" - "github.com/insertr/server/internal/db/postgresql" - "github.com/insertr/server/internal/db/sqlite" -) - -// ContentHandler handles all content-related HTTP requests -type ContentHandler struct { - database *db.Database -} - -// NewContentHandler creates a new content handler -func NewContentHandler(database *db.Database) *ContentHandler { - return &ContentHandler{ - database: database, - } -} - -// GetContent handles GET /api/content/{id} -func (h *ContentHandler) GetContent(w http.ResponseWriter, r *http.Request) { - vars := mux.Vars(r) - contentID := vars["id"] - siteID := r.URL.Query().Get("site_id") - - if siteID == "" { - http.Error(w, "site_id parameter is required", http.StatusBadRequest) - return - } - - var content interface{} - var err error - - switch h.database.GetDBType() { - case "sqlite3": - content, err = h.database.GetSQLiteQueries().GetContent(context.Background(), sqlite.GetContentParams{ - ID: contentID, - SiteID: siteID, - }) - case "postgresql": - content, err = h.database.GetPostgreSQLQueries().GetContent(context.Background(), postgresql.GetContentParams{ - ID: contentID, - SiteID: siteID, - }) - default: - http.Error(w, "Unsupported database type", http.StatusInternalServerError) - return - } - - if err != nil { - if err == sql.ErrNoRows { - http.Error(w, "Content not found", http.StatusNotFound) - return - } - http.Error(w, fmt.Sprintf("Database error: %v", err), http.StatusInternalServerError) - return - } - - item := h.convertToAPIContent(content) - - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(item) -} - -// GetAllContent handles GET /api/content -func (h *ContentHandler) GetAllContent(w http.ResponseWriter, r *http.Request) { - siteID := r.URL.Query().Get("site_id") - if siteID == "" { - http.Error(w, "site_id parameter is required", http.StatusBadRequest) - return - } - - var dbContent interface{} - var err error - - switch h.database.GetDBType() { - case "sqlite3": - dbContent, err = h.database.GetSQLiteQueries().GetAllContent(context.Background(), siteID) - case "postgresql": - dbContent, err = h.database.GetPostgreSQLQueries().GetAllContent(context.Background(), siteID) - default: - http.Error(w, "Unsupported database type", http.StatusInternalServerError) - return - } - - if err != nil { - http.Error(w, fmt.Sprintf("Database error: %v", err), http.StatusInternalServerError) - return - } - - items := h.convertToAPIContentList(dbContent) - response := ContentResponse{Content: items} - - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(response) -} - -// GetBulkContent handles GET /api/content/bulk -func (h *ContentHandler) GetBulkContent(w http.ResponseWriter, r *http.Request) { - siteID := r.URL.Query().Get("site_id") - if siteID == "" { - http.Error(w, "site_id parameter is required", http.StatusBadRequest) - return - } - - // Parse ids parameter - idsParam := r.URL.Query()["ids[]"] - if len(idsParam) == 0 { - // Try single ids parameter - idsStr := r.URL.Query().Get("ids") - if idsStr == "" { - http.Error(w, "ids parameter is required", http.StatusBadRequest) - return - } - idsParam = strings.Split(idsStr, ",") - } - - var dbContent interface{} - var err error - - switch h.database.GetDBType() { - case "sqlite3": - dbContent, err = h.database.GetSQLiteQueries().GetBulkContent(context.Background(), sqlite.GetBulkContentParams{ - SiteID: siteID, - Ids: idsParam, - }) - case "postgresql": - dbContent, err = h.database.GetPostgreSQLQueries().GetBulkContent(context.Background(), postgresql.GetBulkContentParams{ - SiteID: siteID, - Ids: idsParam, - }) - default: - http.Error(w, "Unsupported database type", http.StatusInternalServerError) - return - } - - if err != nil { - http.Error(w, fmt.Sprintf("Database error: %v", err), http.StatusInternalServerError) - return - } - - items := h.convertToAPIContentList(dbContent) - response := ContentResponse{Content: items} - - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(response) -} - -// CreateContent handles POST /api/content -func (h *ContentHandler) CreateContent(w http.ResponseWriter, r *http.Request) { - var req CreateContentRequest - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - http.Error(w, "Invalid JSON", http.StatusBadRequest) - return - } - - siteID := r.URL.Query().Get("site_id") - if siteID == "" { - siteID = req.SiteID // fallback to request body - } - if siteID == "" { - siteID = "default" // final fallback - } - - // Extract user from request (for now, use X-User-ID header or fallback) - userID := r.Header.Get("X-User-ID") - if userID == "" && req.CreatedBy != "" { - userID = req.CreatedBy - } - if userID == "" { - userID = "anonymous" - } - - var content interface{} - var err error - - switch h.database.GetDBType() { - case "sqlite3": - content, err = h.database.GetSQLiteQueries().CreateContent(context.Background(), sqlite.CreateContentParams{ - ID: req.ID, - SiteID: siteID, - Value: req.Value, - Type: req.Type, - LastEditedBy: userID, - }) - case "postgresql": - content, err = h.database.GetPostgreSQLQueries().CreateContent(context.Background(), postgresql.CreateContentParams{ - ID: req.ID, - SiteID: siteID, - Value: req.Value, - Type: req.Type, - LastEditedBy: userID, - }) - default: - http.Error(w, "Unsupported database type", http.StatusInternalServerError) - return - } - - if err != nil { - http.Error(w, fmt.Sprintf("Failed to create content: %v", err), http.StatusInternalServerError) - return - } - - item := h.convertToAPIContent(content) - - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusCreated) - json.NewEncoder(w).Encode(item) -} - -// UpdateContent handles PUT /api/content/{id} -func (h *ContentHandler) UpdateContent(w http.ResponseWriter, r *http.Request) { - vars := mux.Vars(r) - contentID := vars["id"] - siteID := r.URL.Query().Get("site_id") - - if siteID == "" { - http.Error(w, "site_id parameter is required", http.StatusBadRequest) - return - } - - var req UpdateContentRequest - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - http.Error(w, "Invalid JSON", http.StatusBadRequest) - return - } - - // Extract user from request - userID := r.Header.Get("X-User-ID") - if userID == "" && req.UpdatedBy != "" { - userID = req.UpdatedBy - } - if userID == "" { - userID = "anonymous" - } - - // Get current content for version history and type preservation - var currentContent interface{} - var err error - - switch h.database.GetDBType() { - case "sqlite3": - currentContent, err = h.database.GetSQLiteQueries().GetContent(context.Background(), sqlite.GetContentParams{ - ID: contentID, - SiteID: siteID, - }) - case "postgresql": - currentContent, err = h.database.GetPostgreSQLQueries().GetContent(context.Background(), postgresql.GetContentParams{ - ID: contentID, - SiteID: siteID, - }) - default: - http.Error(w, "Unsupported database type", http.StatusInternalServerError) - return - } - - if err != nil { - if err == sql.ErrNoRows { - http.Error(w, "Content not found", http.StatusNotFound) - return - } - http.Error(w, fmt.Sprintf("Database error: %v", err), http.StatusInternalServerError) - return - } - - // Archive current version before updating - err = h.createContentVersion(currentContent) - if err != nil { - http.Error(w, fmt.Sprintf("Failed to create version: %v", err), http.StatusInternalServerError) - return - } - - // Determine content type - contentType := req.Type - if contentType == "" { - contentType = h.getContentType(currentContent) // preserve existing type if not specified - } - - // Update the content - var updatedContent interface{} - - switch h.database.GetDBType() { - case "sqlite3": - updatedContent, err = h.database.GetSQLiteQueries().UpdateContent(context.Background(), sqlite.UpdateContentParams{ - Value: req.Value, - Type: contentType, - LastEditedBy: userID, - ID: contentID, - SiteID: siteID, - }) - case "postgresql": - updatedContent, err = h.database.GetPostgreSQLQueries().UpdateContent(context.Background(), postgresql.UpdateContentParams{ - Value: req.Value, - Type: contentType, - LastEditedBy: userID, - ID: contentID, - SiteID: siteID, - }) - default: - http.Error(w, "Unsupported database type", http.StatusInternalServerError) - return - } - - if err != nil { - http.Error(w, fmt.Sprintf("Failed to update content: %v", err), http.StatusInternalServerError) - return - } - - item := h.convertToAPIContent(updatedContent) - - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(item) -} - -// DeleteContent handles DELETE /api/content/{id} -func (h *ContentHandler) DeleteContent(w http.ResponseWriter, r *http.Request) { - vars := mux.Vars(r) - contentID := vars["id"] - siteID := r.URL.Query().Get("site_id") - - if siteID == "" { - http.Error(w, "site_id parameter is required", http.StatusBadRequest) - return - } - - var err error - - switch h.database.GetDBType() { - case "sqlite3": - err = h.database.GetSQLiteQueries().DeleteContent(context.Background(), sqlite.DeleteContentParams{ - ID: contentID, - SiteID: siteID, - }) - case "postgresql": - err = h.database.GetPostgreSQLQueries().DeleteContent(context.Background(), postgresql.DeleteContentParams{ - ID: contentID, - SiteID: siteID, - }) - default: - http.Error(w, "Unsupported database type", http.StatusInternalServerError) - return - } - - if err != nil { - http.Error(w, fmt.Sprintf("Failed to delete content: %v", err), http.StatusInternalServerError) - return - } - - w.WriteHeader(http.StatusNoContent) -} - -// GetContentVersions handles GET /api/content/{id}/versions -func (h *ContentHandler) GetContentVersions(w http.ResponseWriter, r *http.Request) { - vars := mux.Vars(r) - contentID := vars["id"] - siteID := r.URL.Query().Get("site_id") - - if siteID == "" { - http.Error(w, "site_id parameter is required", http.StatusBadRequest) - return - } - - // Parse limit parameter (default to 10) - limit := int64(10) - if limitStr := r.URL.Query().Get("limit"); limitStr != "" { - if parsedLimit, err := strconv.ParseInt(limitStr, 10, 64); err == nil { - limit = parsedLimit - } - } - - var dbVersions interface{} - var err error - - switch h.database.GetDBType() { - case "sqlite3": - dbVersions, err = h.database.GetSQLiteQueries().GetContentVersionHistory(context.Background(), sqlite.GetContentVersionHistoryParams{ - ContentID: contentID, - SiteID: siteID, - LimitCount: limit, - }) - case "postgresql": - // Note: PostgreSQL uses different parameter names due to int32 vs int64 - dbVersions, err = h.database.GetPostgreSQLQueries().GetContentVersionHistory(context.Background(), postgresql.GetContentVersionHistoryParams{ - ContentID: contentID, - SiteID: siteID, - LimitCount: int32(limit), - }) - default: - http.Error(w, "Unsupported database type", http.StatusInternalServerError) - return - } - - if err != nil { - http.Error(w, fmt.Sprintf("Database error: %v", err), http.StatusInternalServerError) - return - } - - versions := h.convertToAPIVersionList(dbVersions) - response := ContentVersionsResponse{Versions: versions} - - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(response) -} - -// RollbackContent handles POST /api/content/{id}/rollback -func (h *ContentHandler) RollbackContent(w http.ResponseWriter, r *http.Request) { - vars := mux.Vars(r) - contentID := vars["id"] - siteID := r.URL.Query().Get("site_id") - - if siteID == "" { - http.Error(w, "site_id parameter is required", http.StatusBadRequest) - return - } - - var req RollbackContentRequest - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - http.Error(w, "Invalid JSON", http.StatusBadRequest) - return - } - - // Get the target version - var targetVersion interface{} - var err error - - switch h.database.GetDBType() { - case "sqlite3": - targetVersion, err = h.database.GetSQLiteQueries().GetContentVersion(context.Background(), req.VersionID) - case "postgresql": - targetVersion, err = h.database.GetPostgreSQLQueries().GetContentVersion(context.Background(), int32(req.VersionID)) - default: - http.Error(w, "Unsupported database type", http.StatusInternalServerError) - return - } - - if err != nil { - if err == sql.ErrNoRows { - http.Error(w, "Version not found", http.StatusNotFound) - return - } - http.Error(w, fmt.Sprintf("Database error: %v", err), http.StatusInternalServerError) - return - } - - // Verify the version belongs to the correct content - if !h.versionMatches(targetVersion, contentID, siteID) { - http.Error(w, "Version does not match content", http.StatusBadRequest) - return - } - - // Extract user from request - userID := r.Header.Get("X-User-ID") - if userID == "" && req.RolledBackBy != "" { - userID = req.RolledBackBy - } - if userID == "" { - userID = "anonymous" - } - - // Archive current version before rollback - var currentContent interface{} - - switch h.database.GetDBType() { - case "sqlite3": - currentContent, err = h.database.GetSQLiteQueries().GetContent(context.Background(), sqlite.GetContentParams{ - ID: contentID, - SiteID: siteID, - }) - case "postgresql": - currentContent, err = h.database.GetPostgreSQLQueries().GetContent(context.Background(), postgresql.GetContentParams{ - ID: contentID, - SiteID: siteID, - }) - default: - http.Error(w, "Unsupported database type", http.StatusInternalServerError) - return - } - - if err != nil { - http.Error(w, fmt.Sprintf("Failed to get current content: %v", err), http.StatusInternalServerError) - return - } - - err = h.createContentVersion(currentContent) - if err != nil { - http.Error(w, fmt.Sprintf("Failed to create version: %v", err), http.StatusInternalServerError) - return - } - - // Rollback to target version - var updatedContent interface{} - - switch h.database.GetDBType() { - case "sqlite3": - sqliteVersion := targetVersion.(sqlite.ContentVersion) - updatedContent, err = h.database.GetSQLiteQueries().UpdateContent(context.Background(), sqlite.UpdateContentParams{ - Value: sqliteVersion.Value, - Type: sqliteVersion.Type, - LastEditedBy: userID, - ID: contentID, - SiteID: siteID, - }) - case "postgresql": - pgVersion := targetVersion.(postgresql.ContentVersion) - updatedContent, err = h.database.GetPostgreSQLQueries().UpdateContent(context.Background(), postgresql.UpdateContentParams{ - Value: pgVersion.Value, - Type: pgVersion.Type, - LastEditedBy: userID, - ID: contentID, - SiteID: siteID, - }) - default: - http.Error(w, "Unsupported database type", http.StatusInternalServerError) - return - } - - if err != nil { - http.Error(w, fmt.Sprintf("Failed to rollback content: %v", err), http.StatusInternalServerError) - return - } - - item := h.convertToAPIContent(updatedContent) - - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(item) -} - -// Helper functions for type conversion -func (h *ContentHandler) convertToAPIContent(content interface{}) ContentItem { - switch h.database.GetDBType() { - case "sqlite3": - c := content.(sqlite.Content) - return ContentItem{ - ID: c.ID, - SiteID: c.SiteID, - Value: c.Value, - Type: c.Type, - CreatedAt: time.Unix(c.CreatedAt, 0), - UpdatedAt: time.Unix(c.UpdatedAt, 0), - LastEditedBy: c.LastEditedBy, - } - case "postgresql": - c := content.(postgresql.Content) - return ContentItem{ - ID: c.ID, - SiteID: c.SiteID, - Value: c.Value, - Type: c.Type, - CreatedAt: time.Unix(c.CreatedAt, 0), - UpdatedAt: time.Unix(c.UpdatedAt, 0), - LastEditedBy: c.LastEditedBy, - } - } - return ContentItem{} // Should never happen -} - -func (h *ContentHandler) convertToAPIContentList(contentList interface{}) []ContentItem { - switch h.database.GetDBType() { - case "sqlite3": - list := contentList.([]sqlite.Content) - items := make([]ContentItem, len(list)) - for i, content := range list { - items[i] = h.convertToAPIContent(content) - } - return items - case "postgresql": - list := contentList.([]postgresql.Content) - items := make([]ContentItem, len(list)) - for i, content := range list { - items[i] = h.convertToAPIContent(content) - } - return items - } - return []ContentItem{} // Should never happen -} - -func (h *ContentHandler) convertToAPIVersionList(versionList interface{}) []ContentVersion { - switch h.database.GetDBType() { - case "sqlite3": - list := versionList.([]sqlite.ContentVersion) - versions := make([]ContentVersion, len(list)) - for i, version := range list { - versions[i] = ContentVersion{ - VersionID: version.VersionID, - ContentID: version.ContentID, - SiteID: version.SiteID, - Value: version.Value, - Type: version.Type, - CreatedAt: time.Unix(version.CreatedAt, 0), - CreatedBy: version.CreatedBy, - } - } - return versions - case "postgresql": - list := versionList.([]postgresql.ContentVersion) - versions := make([]ContentVersion, len(list)) - for i, version := range list { - versions[i] = ContentVersion{ - VersionID: int64(version.VersionID), - ContentID: version.ContentID, - SiteID: version.SiteID, - Value: version.Value, - Type: version.Type, - CreatedAt: time.Unix(version.CreatedAt, 0), - CreatedBy: version.CreatedBy, - } - } - return versions - } - return []ContentVersion{} // Should never happen -} - -func (h *ContentHandler) createContentVersion(content interface{}) error { - switch h.database.GetDBType() { - case "sqlite3": - c := content.(sqlite.Content) - return h.database.GetSQLiteQueries().CreateContentVersion(context.Background(), sqlite.CreateContentVersionParams{ - ContentID: c.ID, - SiteID: c.SiteID, - Value: c.Value, - Type: c.Type, - CreatedBy: c.LastEditedBy, - }) - case "postgresql": - c := content.(postgresql.Content) - return h.database.GetPostgreSQLQueries().CreateContentVersion(context.Background(), postgresql.CreateContentVersionParams{ - ContentID: c.ID, - SiteID: c.SiteID, - Value: c.Value, - Type: c.Type, - CreatedBy: c.LastEditedBy, - }) - } - return fmt.Errorf("unsupported database type") -} - -func (h *ContentHandler) getContentType(content interface{}) string { - switch h.database.GetDBType() { - case "sqlite3": - return content.(sqlite.Content).Type - case "postgresql": - return content.(postgresql.Content).Type - } - return "" -} - -func (h *ContentHandler) versionMatches(version interface{}, contentID, siteID string) bool { - switch h.database.GetDBType() { - case "sqlite3": - v := version.(sqlite.ContentVersion) - return v.ContentID == contentID && v.SiteID == siteID - case "postgresql": - v := version.(postgresql.ContentVersion) - return v.ContentID == contentID && v.SiteID == siteID - } - return false -} diff --git a/insertr-server/internal/api/middleware.go b/insertr-server/internal/api/middleware.go deleted file mode 100644 index 0a94037..0000000 --- a/insertr-server/internal/api/middleware.go +++ /dev/null @@ -1,127 +0,0 @@ -package api - -import ( - "log" - "net/http" - "time" -) - -// CORSMiddleware adds CORS headers to enable browser requests -func CORSMiddleware(next http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - origin := r.Header.Get("Origin") - - // Allow localhost and 127.0.0.1 on common development ports - allowedOrigins := []string{ - "http://localhost:3000", - "http://127.0.0.1:3000", - "http://localhost:8080", - "http://127.0.0.1:8080", - } - - // Check if origin is allowed - originAllowed := false - for _, allowed := range allowedOrigins { - if origin == allowed { - originAllowed = true - break - } - } - - if originAllowed { - w.Header().Set("Access-Control-Allow-Origin", origin) - } else { - // Fallback to wildcard for development (can be restricted in production) - w.Header().Set("Access-Control-Allow-Origin", "*") - } - - w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS") - w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization") - w.Header().Set("Access-Control-Allow-Credentials", "true") - - // Note: Explicit OPTIONS handling is done via routes, not here - next.ServeHTTP(w, r) - }) -} - -// LoggingMiddleware logs HTTP requests -func LoggingMiddleware(next http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - start := time.Now() - - // Create a response writer wrapper to capture status code - wrapper := &responseWriter{ResponseWriter: w, statusCode: http.StatusOK} - - next.ServeHTTP(wrapper, r) - - log.Printf("%s %s %d %v", r.Method, r.URL.Path, wrapper.statusCode, time.Since(start)) - }) -} - -// responseWriter wraps http.ResponseWriter to capture status code -type responseWriter struct { - http.ResponseWriter - statusCode int -} - -func (rw *responseWriter) WriteHeader(code int) { - rw.statusCode = code - rw.ResponseWriter.WriteHeader(code) -} - -// ContentTypeMiddleware ensures JSON responses have proper content type -func ContentTypeMiddleware(next http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - // Set default content type for API responses - if r.URL.Path != "/" && (r.Method == "GET" || r.Method == "POST" || r.Method == "PUT") { - w.Header().Set("Content-Type", "application/json") - } - - next.ServeHTTP(w, r) - }) -} - -// HealthMiddleware provides a simple health check endpoint -func HealthMiddleware() http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusOK) - w.Write([]byte(`{"status":"healthy","service":"insertr-server"}`)) - } -} - -// CORSPreflightHandler handles CORS preflight requests (OPTIONS) -func CORSPreflightHandler(w http.ResponseWriter, r *http.Request) { - origin := r.Header.Get("Origin") - - // Allow localhost and 127.0.0.1 on common development ports - allowedOrigins := []string{ - "http://localhost:3000", - "http://127.0.0.1:3000", - "http://localhost:8080", - "http://127.0.0.1:8080", - } - - // Check if origin is allowed - originAllowed := false - for _, allowed := range allowedOrigins { - if origin == allowed { - originAllowed = true - break - } - } - - if originAllowed { - w.Header().Set("Access-Control-Allow-Origin", origin) - } else { - // Fallback to wildcard for development - w.Header().Set("Access-Control-Allow-Origin", "*") - } - - w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS") - w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization") - w.Header().Set("Access-Control-Allow-Credentials", "true") - w.Header().Set("Access-Control-Max-Age", "86400") // Cache preflight for 24 hours - - w.WriteHeader(http.StatusOK) -} diff --git a/insertr-server/internal/api/models.go b/insertr-server/internal/api/models.go deleted file mode 100644 index 7aaa220..0000000 --- a/insertr-server/internal/api/models.go +++ /dev/null @@ -1,52 +0,0 @@ -package api - -import "time" - -// API request/response models -type ContentItem struct { - ID string `json:"id"` - SiteID string `json:"site_id"` - Value string `json:"value"` - Type string `json:"type"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` - LastEditedBy string `json:"last_edited_by"` -} - -type ContentVersion struct { - VersionID int64 `json:"version_id"` - ContentID string `json:"content_id"` - SiteID string `json:"site_id"` - Value string `json:"value"` - Type string `json:"type"` - CreatedAt time.Time `json:"created_at"` - CreatedBy string `json:"created_by"` -} - -type ContentResponse struct { - Content []ContentItem `json:"content"` -} - -type ContentVersionsResponse struct { - Versions []ContentVersion `json:"versions"` -} - -// Request models -type CreateContentRequest struct { - ID string `json:"id"` - SiteID string `json:"site_id,omitempty"` - Value string `json:"value"` - Type string `json:"type"` - CreatedBy string `json:"created_by,omitempty"` -} - -type UpdateContentRequest struct { - Value string `json:"value"` - Type string `json:"type,omitempty"` - UpdatedBy string `json:"updated_by,omitempty"` -} - -type RollbackContentRequest struct { - VersionID int64 `json:"version_id"` - RolledBackBy string `json:"rolled_back_by,omitempty"` -} diff --git a/insertr-server/internal/db/database.go b/insertr-server/internal/db/database.go deleted file mode 100644 index eb8ded0..0000000 --- a/insertr-server/internal/db/database.go +++ /dev/null @@ -1,184 +0,0 @@ -package db - -import ( - "context" - "database/sql" - "fmt" - "strings" - - _ "github.com/lib/pq" - _ "github.com/mattn/go-sqlite3" - - "github.com/insertr/server/internal/db/postgresql" - "github.com/insertr/server/internal/db/sqlite" -) - -// Database wraps the database connection and queries -type Database struct { - conn *sql.DB - dbType string - - // Type-specific query interfaces - sqliteQueries *sqlite.Queries - postgresqlQueries *postgresql.Queries -} - -// NewDatabase creates a new database connection -func NewDatabase(dbPath string) (*Database, error) { - var conn *sql.DB - var dbType string - var err error - - // Determine database type from connection string - if strings.Contains(dbPath, "postgres://") || strings.Contains(dbPath, "postgresql://") { - dbType = "postgresql" - conn, err = sql.Open("postgres", dbPath) - } else { - dbType = "sqlite3" - conn, err = sql.Open("sqlite3", dbPath) - } - - if err != nil { - return nil, fmt.Errorf("failed to open database: %w", err) - } - - // Test connection - if err := conn.Ping(); err != nil { - conn.Close() - return nil, fmt.Errorf("failed to ping database: %w", err) - } - - // Initialize the appropriate queries - db := &Database{ - conn: conn, - dbType: dbType, - } - - switch dbType { - case "sqlite3": - // Initialize SQLite schema using generated functions - db.sqliteQueries = sqlite.New(conn) - if err := db.initializeSQLiteSchema(); err != nil { - conn.Close() - return nil, fmt.Errorf("failed to initialize SQLite schema: %w", err) - } - case "postgresql": - // Initialize PostgreSQL schema using generated functions - db.postgresqlQueries = postgresql.New(conn) - if err := db.initializePostgreSQLSchema(); err != nil { - conn.Close() - return nil, fmt.Errorf("failed to initialize PostgreSQL schema: %w", err) - } - default: - return nil, fmt.Errorf("unsupported database type: %s", dbType) - } - - return db, nil -} - -// Close closes the database connection -func (db *Database) Close() error { - return db.conn.Close() -} - -// GetQueries returns the appropriate query interface -func (db *Database) GetSQLiteQueries() *sqlite.Queries { - return db.sqliteQueries -} - -func (db *Database) GetPostgreSQLQueries() *postgresql.Queries { - return db.postgresqlQueries -} - -// GetDBType returns the database type -func (db *Database) GetDBType() string { - return db.dbType -} - -// initializeSQLiteSchema sets up the SQLite database schema -func (db *Database) initializeSQLiteSchema() error { - ctx := context.Background() - - // Create tables - if err := db.sqliteQueries.InitializeSchema(ctx); err != nil { - return fmt.Errorf("failed to create content table: %w", err) - } - - if err := db.sqliteQueries.InitializeVersionsTable(ctx); err != nil { - return fmt.Errorf("failed to create content_versions table: %w", err) - } - - // Create indexes manually (sqlc doesn't generate CREATE INDEX functions for SQLite) - indexQueries := []string{ - "CREATE INDEX IF NOT EXISTS idx_content_site_id ON content(site_id);", - "CREATE INDEX IF NOT EXISTS idx_content_updated_at ON content(updated_at);", - "CREATE INDEX IF NOT EXISTS idx_content_versions_lookup ON content_versions(content_id, site_id, created_at DESC);", - } - - for _, query := range indexQueries { - if _, err := db.conn.Exec(query); err != nil { - return fmt.Errorf("failed to create index: %w", err) - } - } - - // Create update trigger manually (sqlc doesn't generate trigger creation functions) - triggerQuery := ` - CREATE TRIGGER IF NOT EXISTS update_content_updated_at - AFTER UPDATE ON content - FOR EACH ROW - BEGIN - UPDATE content SET updated_at = strftime('%s', 'now') WHERE id = NEW.id AND site_id = NEW.site_id; - END;` - - if _, err := db.conn.Exec(triggerQuery); err != nil { - return fmt.Errorf("failed to create update trigger: %w", err) - } - - return nil -} - -// initializePostgreSQLSchema sets up the PostgreSQL database schema -func (db *Database) initializePostgreSQLSchema() error { - ctx := context.Background() - - // Create tables using sqlc-generated functions - if err := db.postgresqlQueries.InitializeSchema(ctx); err != nil { - return fmt.Errorf("failed to create content table: %w", err) - } - - if err := db.postgresqlQueries.InitializeVersionsTable(ctx); err != nil { - return fmt.Errorf("failed to create content_versions table: %w", err) - } - - // Create indexes using sqlc-generated functions (PostgreSQL supports this) - if err := db.postgresqlQueries.CreateContentSiteIndex(ctx); err != nil { - return fmt.Errorf("failed to create content site index: %w", err) - } - - if err := db.postgresqlQueries.CreateContentUpdatedAtIndex(ctx); err != nil { - return fmt.Errorf("failed to create content updated_at index: %w", err) - } - - if err := db.postgresqlQueries.CreateVersionsLookupIndex(ctx); err != nil { - return fmt.Errorf("failed to create versions lookup index: %w", err) - } - - // Create update function using sqlc-generated function - if err := db.postgresqlQueries.CreateUpdateFunction(ctx); err != nil { - return fmt.Errorf("failed to create update function: %w", err) - } - - // Create trigger manually (sqlc doesn't generate trigger creation functions) - triggerQuery := ` - DROP TRIGGER IF EXISTS update_content_updated_at ON content; - CREATE TRIGGER update_content_updated_at - BEFORE UPDATE ON content - FOR EACH ROW - EXECUTE FUNCTION update_content_timestamp();` - - if _, err := db.conn.Exec(triggerQuery); err != nil { - return fmt.Errorf("failed to create update trigger: %w", err) - } - - return nil -} diff --git a/insertr-server/internal/db/postgresql/content.sql.go b/insertr-server/internal/db/postgresql/content.sql.go deleted file mode 100644 index b3230c3..0000000 --- a/insertr-server/internal/db/postgresql/content.sql.go +++ /dev/null @@ -1,214 +0,0 @@ -// Code generated by sqlc. DO NOT EDIT. -// versions: -// sqlc v1.29.0 -// source: content.sql - -package postgresql - -import ( - "context" - "strings" -) - -const createContent = `-- name: CreateContent :one -INSERT INTO content (id, site_id, value, type, last_edited_by) -VALUES ($1, $2, $3, $4, $5) -RETURNING id, site_id, value, type, created_at, updated_at, last_edited_by -` - -type CreateContentParams struct { - ID string `json:"id"` - SiteID string `json:"site_id"` - Value string `json:"value"` - Type string `json:"type"` - LastEditedBy string `json:"last_edited_by"` -} - -func (q *Queries) CreateContent(ctx context.Context, arg CreateContentParams) (Content, error) { - row := q.db.QueryRowContext(ctx, createContent, - arg.ID, - arg.SiteID, - arg.Value, - arg.Type, - arg.LastEditedBy, - ) - var i Content - err := row.Scan( - &i.ID, - &i.SiteID, - &i.Value, - &i.Type, - &i.CreatedAt, - &i.UpdatedAt, - &i.LastEditedBy, - ) - return i, err -} - -const deleteContent = `-- name: DeleteContent :exec -DELETE FROM content -WHERE id = $1 AND site_id = $2 -` - -type DeleteContentParams struct { - ID string `json:"id"` - SiteID string `json:"site_id"` -} - -func (q *Queries) DeleteContent(ctx context.Context, arg DeleteContentParams) error { - _, err := q.db.ExecContext(ctx, deleteContent, arg.ID, arg.SiteID) - return err -} - -const getAllContent = `-- name: GetAllContent :many -SELECT id, site_id, value, type, created_at, updated_at, last_edited_by -FROM content -WHERE site_id = $1 -ORDER BY updated_at DESC -` - -func (q *Queries) GetAllContent(ctx context.Context, siteID string) ([]Content, error) { - rows, err := q.db.QueryContext(ctx, getAllContent, siteID) - if err != nil { - return nil, err - } - defer rows.Close() - var items []Content - for rows.Next() { - var i Content - if err := rows.Scan( - &i.ID, - &i.SiteID, - &i.Value, - &i.Type, - &i.CreatedAt, - &i.UpdatedAt, - &i.LastEditedBy, - ); err != nil { - return nil, err - } - items = append(items, i) - } - if err := rows.Close(); err != nil { - return nil, err - } - if err := rows.Err(); err != nil { - return nil, err - } - return items, nil -} - -const getBulkContent = `-- name: GetBulkContent :many -SELECT id, site_id, value, type, created_at, updated_at, last_edited_by -FROM content -WHERE site_id = $1 AND id IN ($2) -` - -type GetBulkContentParams struct { - SiteID string `json:"site_id"` - Ids []string `json:"ids"` -} - -func (q *Queries) GetBulkContent(ctx context.Context, arg GetBulkContentParams) ([]Content, error) { - query := getBulkContent - var queryParams []interface{} - queryParams = append(queryParams, arg.SiteID) - if len(arg.Ids) > 0 { - for _, v := range arg.Ids { - queryParams = append(queryParams, v) - } - query = strings.Replace(query, "/*SLICE:ids*/?", strings.Repeat(",?", len(arg.Ids))[1:], 1) - } else { - query = strings.Replace(query, "/*SLICE:ids*/?", "NULL", 1) - } - rows, err := q.db.QueryContext(ctx, query, queryParams...) - if err != nil { - return nil, err - } - defer rows.Close() - var items []Content - for rows.Next() { - var i Content - if err := rows.Scan( - &i.ID, - &i.SiteID, - &i.Value, - &i.Type, - &i.CreatedAt, - &i.UpdatedAt, - &i.LastEditedBy, - ); err != nil { - return nil, err - } - items = append(items, i) - } - if err := rows.Close(); err != nil { - return nil, err - } - if err := rows.Err(); err != nil { - return nil, err - } - return items, nil -} - -const getContent = `-- name: GetContent :one -SELECT id, site_id, value, type, created_at, updated_at, last_edited_by -FROM content -WHERE id = $1 AND site_id = $2 -` - -type GetContentParams struct { - ID string `json:"id"` - SiteID string `json:"site_id"` -} - -func (q *Queries) GetContent(ctx context.Context, arg GetContentParams) (Content, error) { - row := q.db.QueryRowContext(ctx, getContent, arg.ID, arg.SiteID) - var i Content - err := row.Scan( - &i.ID, - &i.SiteID, - &i.Value, - &i.Type, - &i.CreatedAt, - &i.UpdatedAt, - &i.LastEditedBy, - ) - return i, err -} - -const updateContent = `-- name: UpdateContent :one -UPDATE content -SET value = $1, type = $2, last_edited_by = $3 -WHERE id = $4 AND site_id = $5 -RETURNING id, site_id, value, type, created_at, updated_at, last_edited_by -` - -type UpdateContentParams struct { - Value string `json:"value"` - Type string `json:"type"` - LastEditedBy string `json:"last_edited_by"` - ID string `json:"id"` - SiteID string `json:"site_id"` -} - -func (q *Queries) UpdateContent(ctx context.Context, arg UpdateContentParams) (Content, error) { - row := q.db.QueryRowContext(ctx, updateContent, - arg.Value, - arg.Type, - arg.LastEditedBy, - arg.ID, - arg.SiteID, - ) - var i Content - err := row.Scan( - &i.ID, - &i.SiteID, - &i.Value, - &i.Type, - &i.CreatedAt, - &i.UpdatedAt, - &i.LastEditedBy, - ) - return i, err -} diff --git a/insertr-server/internal/db/postgresql/db.go b/insertr-server/internal/db/postgresql/db.go deleted file mode 100644 index 9f77c9d..0000000 --- a/insertr-server/internal/db/postgresql/db.go +++ /dev/null @@ -1,31 +0,0 @@ -// Code generated by sqlc. DO NOT EDIT. -// versions: -// sqlc v1.29.0 - -package postgresql - -import ( - "context" - "database/sql" -) - -type DBTX interface { - ExecContext(context.Context, string, ...interface{}) (sql.Result, error) - PrepareContext(context.Context, string) (*sql.Stmt, error) - QueryContext(context.Context, string, ...interface{}) (*sql.Rows, error) - QueryRowContext(context.Context, string, ...interface{}) *sql.Row -} - -func New(db DBTX) *Queries { - return &Queries{db: db} -} - -type Queries struct { - db DBTX -} - -func (q *Queries) WithTx(tx *sql.Tx) *Queries { - return &Queries{ - db: tx, - } -} diff --git a/insertr-server/internal/db/postgresql/models.go b/insertr-server/internal/db/postgresql/models.go deleted file mode 100644 index 7a53776..0000000 --- a/insertr-server/internal/db/postgresql/models.go +++ /dev/null @@ -1,25 +0,0 @@ -// Code generated by sqlc. DO NOT EDIT. -// versions: -// sqlc v1.29.0 - -package postgresql - -type Content struct { - ID string `json:"id"` - SiteID string `json:"site_id"` - Value string `json:"value"` - Type string `json:"type"` - CreatedAt int64 `json:"created_at"` - UpdatedAt int64 `json:"updated_at"` - LastEditedBy string `json:"last_edited_by"` -} - -type ContentVersion struct { - VersionID int32 `json:"version_id"` - ContentID string `json:"content_id"` - SiteID string `json:"site_id"` - Value string `json:"value"` - Type string `json:"type"` - CreatedAt int64 `json:"created_at"` - CreatedBy string `json:"created_by"` -} diff --git a/insertr-server/internal/db/postgresql/querier.go b/insertr-server/internal/db/postgresql/querier.go deleted file mode 100644 index 37cf939..0000000 --- a/insertr-server/internal/db/postgresql/querier.go +++ /dev/null @@ -1,31 +0,0 @@ -// Code generated by sqlc. DO NOT EDIT. -// versions: -// sqlc v1.29.0 - -package postgresql - -import ( - "context" -) - -type Querier interface { - CreateContent(ctx context.Context, arg CreateContentParams) (Content, error) - CreateContentSiteIndex(ctx context.Context) error - CreateContentUpdatedAtIndex(ctx context.Context) error - CreateContentVersion(ctx context.Context, arg CreateContentVersionParams) error - CreateUpdateFunction(ctx context.Context) error - CreateVersionsLookupIndex(ctx context.Context) error - DeleteContent(ctx context.Context, arg DeleteContentParams) error - DeleteOldVersions(ctx context.Context, arg DeleteOldVersionsParams) error - GetAllContent(ctx context.Context, siteID string) ([]Content, error) - GetAllVersionsForSite(ctx context.Context, arg GetAllVersionsForSiteParams) ([]GetAllVersionsForSiteRow, error) - GetBulkContent(ctx context.Context, arg GetBulkContentParams) ([]Content, error) - GetContent(ctx context.Context, arg GetContentParams) (Content, error) - GetContentVersion(ctx context.Context, versionID int32) (ContentVersion, error) - GetContentVersionHistory(ctx context.Context, arg GetContentVersionHistoryParams) ([]ContentVersion, error) - InitializeSchema(ctx context.Context) error - InitializeVersionsTable(ctx context.Context) error - UpdateContent(ctx context.Context, arg UpdateContentParams) (Content, error) -} - -var _ Querier = (*Queries)(nil) diff --git a/insertr-server/internal/db/postgresql/setup.sql.go b/insertr-server/internal/db/postgresql/setup.sql.go deleted file mode 100644 index 030a0e0..0000000 --- a/insertr-server/internal/db/postgresql/setup.sql.go +++ /dev/null @@ -1,87 +0,0 @@ -// Code generated by sqlc. DO NOT EDIT. -// versions: -// sqlc v1.29.0 -// source: setup.sql - -package postgresql - -import ( - "context" -) - -const createContentSiteIndex = `-- name: CreateContentSiteIndex :exec -CREATE INDEX IF NOT EXISTS idx_content_site_id ON content(site_id) -` - -func (q *Queries) CreateContentSiteIndex(ctx context.Context) error { - _, err := q.db.ExecContext(ctx, createContentSiteIndex) - return err -} - -const createContentUpdatedAtIndex = `-- name: CreateContentUpdatedAtIndex :exec -CREATE INDEX IF NOT EXISTS idx_content_updated_at ON content(updated_at) -` - -func (q *Queries) CreateContentUpdatedAtIndex(ctx context.Context) error { - _, err := q.db.ExecContext(ctx, createContentUpdatedAtIndex) - return err -} - -const createUpdateFunction = `-- name: CreateUpdateFunction :exec -CREATE OR REPLACE FUNCTION update_content_timestamp() -RETURNS TRIGGER AS $$ -BEGIN - NEW.updated_at = EXTRACT(EPOCH FROM NOW()); - RETURN NEW; -END; -$$ LANGUAGE plpgsql -` - -func (q *Queries) CreateUpdateFunction(ctx context.Context) error { - _, err := q.db.ExecContext(ctx, createUpdateFunction) - return err -} - -const createVersionsLookupIndex = `-- name: CreateVersionsLookupIndex :exec -CREATE INDEX IF NOT EXISTS idx_content_versions_lookup ON content_versions(content_id, site_id, created_at DESC) -` - -func (q *Queries) CreateVersionsLookupIndex(ctx context.Context) error { - _, err := q.db.ExecContext(ctx, createVersionsLookupIndex) - return err -} - -const initializeSchema = `-- name: InitializeSchema :exec -CREATE TABLE IF NOT EXISTS content ( - id TEXT NOT NULL, - site_id TEXT NOT NULL, - value TEXT NOT NULL, - type TEXT NOT NULL CHECK (type IN ('text', 'markdown', 'link')), - created_at BIGINT DEFAULT (EXTRACT(EPOCH FROM NOW())) NOT NULL, - updated_at BIGINT DEFAULT (EXTRACT(EPOCH FROM NOW())) NOT NULL, - last_edited_by TEXT DEFAULT 'system' NOT NULL, - PRIMARY KEY (id, site_id) -) -` - -func (q *Queries) InitializeSchema(ctx context.Context) error { - _, err := q.db.ExecContext(ctx, initializeSchema) - return err -} - -const initializeVersionsTable = `-- name: InitializeVersionsTable :exec -CREATE TABLE IF NOT EXISTS content_versions ( - version_id SERIAL PRIMARY KEY, - content_id TEXT NOT NULL, - site_id TEXT NOT NULL, - value TEXT NOT NULL, - type TEXT NOT NULL, - created_at BIGINT DEFAULT (EXTRACT(EPOCH FROM NOW())) NOT NULL, - created_by TEXT DEFAULT 'system' NOT NULL -) -` - -func (q *Queries) InitializeVersionsTable(ctx context.Context) error { - _, err := q.db.ExecContext(ctx, initializeVersionsTable) - return err -} diff --git a/insertr-server/internal/db/postgresql/versions.sql.go b/insertr-server/internal/db/postgresql/versions.sql.go deleted file mode 100644 index 00bd5d3..0000000 --- a/insertr-server/internal/db/postgresql/versions.sql.go +++ /dev/null @@ -1,175 +0,0 @@ -// Code generated by sqlc. DO NOT EDIT. -// versions: -// sqlc v1.29.0 -// source: versions.sql - -package postgresql - -import ( - "context" - "database/sql" -) - -const createContentVersion = `-- name: CreateContentVersion :exec -INSERT INTO content_versions (content_id, site_id, value, type, created_by) -VALUES ($1, $2, $3, $4, $5) -` - -type CreateContentVersionParams struct { - ContentID string `json:"content_id"` - SiteID string `json:"site_id"` - Value string `json:"value"` - Type string `json:"type"` - CreatedBy string `json:"created_by"` -} - -func (q *Queries) CreateContentVersion(ctx context.Context, arg CreateContentVersionParams) error { - _, err := q.db.ExecContext(ctx, createContentVersion, - arg.ContentID, - arg.SiteID, - arg.Value, - arg.Type, - arg.CreatedBy, - ) - return err -} - -const deleteOldVersions = `-- name: DeleteOldVersions :exec -DELETE FROM content_versions -WHERE created_at < $1 AND site_id = $2 -` - -type DeleteOldVersionsParams struct { - CreatedBefore int64 `json:"created_before"` - SiteID string `json:"site_id"` -} - -func (q *Queries) DeleteOldVersions(ctx context.Context, arg DeleteOldVersionsParams) error { - _, err := q.db.ExecContext(ctx, deleteOldVersions, arg.CreatedBefore, arg.SiteID) - return err -} - -const getAllVersionsForSite = `-- name: GetAllVersionsForSite :many -SELECT - cv.version_id, cv.content_id, cv.site_id, cv.value, cv.type, cv.created_at, cv.created_by, - c.value as current_value -FROM content_versions cv -LEFT JOIN content c ON cv.content_id = c.id AND cv.site_id = c.site_id -WHERE cv.site_id = $1 -ORDER BY cv.created_at DESC -LIMIT $2 -` - -type GetAllVersionsForSiteParams struct { - SiteID string `json:"site_id"` - LimitCount int32 `json:"limit_count"` -} - -type GetAllVersionsForSiteRow struct { - VersionID int32 `json:"version_id"` - ContentID string `json:"content_id"` - SiteID string `json:"site_id"` - Value string `json:"value"` - Type string `json:"type"` - CreatedAt int64 `json:"created_at"` - CreatedBy string `json:"created_by"` - CurrentValue sql.NullString `json:"current_value"` -} - -func (q *Queries) GetAllVersionsForSite(ctx context.Context, arg GetAllVersionsForSiteParams) ([]GetAllVersionsForSiteRow, error) { - rows, err := q.db.QueryContext(ctx, getAllVersionsForSite, arg.SiteID, arg.LimitCount) - if err != nil { - return nil, err - } - defer rows.Close() - var items []GetAllVersionsForSiteRow - for rows.Next() { - var i GetAllVersionsForSiteRow - if err := rows.Scan( - &i.VersionID, - &i.ContentID, - &i.SiteID, - &i.Value, - &i.Type, - &i.CreatedAt, - &i.CreatedBy, - &i.CurrentValue, - ); err != nil { - return nil, err - } - items = append(items, i) - } - if err := rows.Close(); err != nil { - return nil, err - } - if err := rows.Err(); err != nil { - return nil, err - } - return items, nil -} - -const getContentVersion = `-- name: GetContentVersion :one -SELECT version_id, content_id, site_id, value, type, created_at, created_by -FROM content_versions -WHERE version_id = $1 -` - -func (q *Queries) GetContentVersion(ctx context.Context, versionID int32) (ContentVersion, error) { - row := q.db.QueryRowContext(ctx, getContentVersion, versionID) - var i ContentVersion - err := row.Scan( - &i.VersionID, - &i.ContentID, - &i.SiteID, - &i.Value, - &i.Type, - &i.CreatedAt, - &i.CreatedBy, - ) - return i, err -} - -const getContentVersionHistory = `-- name: GetContentVersionHistory :many -SELECT version_id, content_id, site_id, value, type, created_at, created_by -FROM content_versions -WHERE content_id = $1 AND site_id = $2 -ORDER BY created_at DESC -LIMIT $3 -` - -type GetContentVersionHistoryParams struct { - ContentID string `json:"content_id"` - SiteID string `json:"site_id"` - LimitCount int32 `json:"limit_count"` -} - -func (q *Queries) GetContentVersionHistory(ctx context.Context, arg GetContentVersionHistoryParams) ([]ContentVersion, error) { - rows, err := q.db.QueryContext(ctx, getContentVersionHistory, arg.ContentID, arg.SiteID, arg.LimitCount) - if err != nil { - return nil, err - } - defer rows.Close() - var items []ContentVersion - for rows.Next() { - var i ContentVersion - if err := rows.Scan( - &i.VersionID, - &i.ContentID, - &i.SiteID, - &i.Value, - &i.Type, - &i.CreatedAt, - &i.CreatedBy, - ); err != nil { - return nil, err - } - items = append(items, i) - } - if err := rows.Close(); err != nil { - return nil, err - } - if err := rows.Err(); err != nil { - return nil, err - } - return items, nil -} diff --git a/insertr-server/internal/db/sqlite/content.sql.go b/insertr-server/internal/db/sqlite/content.sql.go deleted file mode 100644 index ce90e1a..0000000 --- a/insertr-server/internal/db/sqlite/content.sql.go +++ /dev/null @@ -1,214 +0,0 @@ -// Code generated by sqlc. DO NOT EDIT. -// versions: -// sqlc v1.29.0 -// source: content.sql - -package sqlite - -import ( - "context" - "strings" -) - -const createContent = `-- name: CreateContent :one -INSERT INTO content (id, site_id, value, type, last_edited_by) -VALUES (?1, ?2, ?3, ?4, ?5) -RETURNING id, site_id, value, type, created_at, updated_at, last_edited_by -` - -type CreateContentParams struct { - ID string `json:"id"` - SiteID string `json:"site_id"` - Value string `json:"value"` - Type string `json:"type"` - LastEditedBy string `json:"last_edited_by"` -} - -func (q *Queries) CreateContent(ctx context.Context, arg CreateContentParams) (Content, error) { - row := q.db.QueryRowContext(ctx, createContent, - arg.ID, - arg.SiteID, - arg.Value, - arg.Type, - arg.LastEditedBy, - ) - var i Content - err := row.Scan( - &i.ID, - &i.SiteID, - &i.Value, - &i.Type, - &i.CreatedAt, - &i.UpdatedAt, - &i.LastEditedBy, - ) - return i, err -} - -const deleteContent = `-- name: DeleteContent :exec -DELETE FROM content -WHERE id = ?1 AND site_id = ?2 -` - -type DeleteContentParams struct { - ID string `json:"id"` - SiteID string `json:"site_id"` -} - -func (q *Queries) DeleteContent(ctx context.Context, arg DeleteContentParams) error { - _, err := q.db.ExecContext(ctx, deleteContent, arg.ID, arg.SiteID) - return err -} - -const getAllContent = `-- name: GetAllContent :many -SELECT id, site_id, value, type, created_at, updated_at, last_edited_by -FROM content -WHERE site_id = ?1 -ORDER BY updated_at DESC -` - -func (q *Queries) GetAllContent(ctx context.Context, siteID string) ([]Content, error) { - rows, err := q.db.QueryContext(ctx, getAllContent, siteID) - if err != nil { - return nil, err - } - defer rows.Close() - var items []Content - for rows.Next() { - var i Content - if err := rows.Scan( - &i.ID, - &i.SiteID, - &i.Value, - &i.Type, - &i.CreatedAt, - &i.UpdatedAt, - &i.LastEditedBy, - ); err != nil { - return nil, err - } - items = append(items, i) - } - if err := rows.Close(); err != nil { - return nil, err - } - if err := rows.Err(); err != nil { - return nil, err - } - return items, nil -} - -const getBulkContent = `-- name: GetBulkContent :many -SELECT id, site_id, value, type, created_at, updated_at, last_edited_by -FROM content -WHERE site_id = ?1 AND id IN (/*SLICE:ids*/?) -` - -type GetBulkContentParams struct { - SiteID string `json:"site_id"` - Ids []string `json:"ids"` -} - -func (q *Queries) GetBulkContent(ctx context.Context, arg GetBulkContentParams) ([]Content, error) { - query := getBulkContent - var queryParams []interface{} - queryParams = append(queryParams, arg.SiteID) - if len(arg.Ids) > 0 { - for _, v := range arg.Ids { - queryParams = append(queryParams, v) - } - query = strings.Replace(query, "/*SLICE:ids*/?", strings.Repeat(",?", len(arg.Ids))[1:], 1) - } else { - query = strings.Replace(query, "/*SLICE:ids*/?", "NULL", 1) - } - rows, err := q.db.QueryContext(ctx, query, queryParams...) - if err != nil { - return nil, err - } - defer rows.Close() - var items []Content - for rows.Next() { - var i Content - if err := rows.Scan( - &i.ID, - &i.SiteID, - &i.Value, - &i.Type, - &i.CreatedAt, - &i.UpdatedAt, - &i.LastEditedBy, - ); err != nil { - return nil, err - } - items = append(items, i) - } - if err := rows.Close(); err != nil { - return nil, err - } - if err := rows.Err(); err != nil { - return nil, err - } - return items, nil -} - -const getContent = `-- name: GetContent :one -SELECT id, site_id, value, type, created_at, updated_at, last_edited_by -FROM content -WHERE id = ?1 AND site_id = ?2 -` - -type GetContentParams struct { - ID string `json:"id"` - SiteID string `json:"site_id"` -} - -func (q *Queries) GetContent(ctx context.Context, arg GetContentParams) (Content, error) { - row := q.db.QueryRowContext(ctx, getContent, arg.ID, arg.SiteID) - var i Content - err := row.Scan( - &i.ID, - &i.SiteID, - &i.Value, - &i.Type, - &i.CreatedAt, - &i.UpdatedAt, - &i.LastEditedBy, - ) - return i, err -} - -const updateContent = `-- name: UpdateContent :one -UPDATE content -SET value = ?1, type = ?2, last_edited_by = ?3 -WHERE id = ?4 AND site_id = ?5 -RETURNING id, site_id, value, type, created_at, updated_at, last_edited_by -` - -type UpdateContentParams struct { - Value string `json:"value"` - Type string `json:"type"` - LastEditedBy string `json:"last_edited_by"` - ID string `json:"id"` - SiteID string `json:"site_id"` -} - -func (q *Queries) UpdateContent(ctx context.Context, arg UpdateContentParams) (Content, error) { - row := q.db.QueryRowContext(ctx, updateContent, - arg.Value, - arg.Type, - arg.LastEditedBy, - arg.ID, - arg.SiteID, - ) - var i Content - err := row.Scan( - &i.ID, - &i.SiteID, - &i.Value, - &i.Type, - &i.CreatedAt, - &i.UpdatedAt, - &i.LastEditedBy, - ) - return i, err -} diff --git a/insertr-server/internal/db/sqlite/db.go b/insertr-server/internal/db/sqlite/db.go deleted file mode 100644 index 5841324..0000000 --- a/insertr-server/internal/db/sqlite/db.go +++ /dev/null @@ -1,31 +0,0 @@ -// Code generated by sqlc. DO NOT EDIT. -// versions: -// sqlc v1.29.0 - -package sqlite - -import ( - "context" - "database/sql" -) - -type DBTX interface { - ExecContext(context.Context, string, ...interface{}) (sql.Result, error) - PrepareContext(context.Context, string) (*sql.Stmt, error) - QueryContext(context.Context, string, ...interface{}) (*sql.Rows, error) - QueryRowContext(context.Context, string, ...interface{}) *sql.Row -} - -func New(db DBTX) *Queries { - return &Queries{db: db} -} - -type Queries struct { - db DBTX -} - -func (q *Queries) WithTx(tx *sql.Tx) *Queries { - return &Queries{ - db: tx, - } -} diff --git a/insertr-server/internal/db/sqlite/models.go b/insertr-server/internal/db/sqlite/models.go deleted file mode 100644 index d8e7a1c..0000000 --- a/insertr-server/internal/db/sqlite/models.go +++ /dev/null @@ -1,25 +0,0 @@ -// Code generated by sqlc. DO NOT EDIT. -// versions: -// sqlc v1.29.0 - -package sqlite - -type Content struct { - ID string `json:"id"` - SiteID string `json:"site_id"` - Value string `json:"value"` - Type string `json:"type"` - CreatedAt int64 `json:"created_at"` - UpdatedAt int64 `json:"updated_at"` - LastEditedBy string `json:"last_edited_by"` -} - -type ContentVersion struct { - VersionID int64 `json:"version_id"` - ContentID string `json:"content_id"` - SiteID string `json:"site_id"` - Value string `json:"value"` - Type string `json:"type"` - CreatedAt int64 `json:"created_at"` - CreatedBy string `json:"created_by"` -} diff --git a/insertr-server/internal/db/sqlite/querier.go b/insertr-server/internal/db/sqlite/querier.go deleted file mode 100644 index f2c5dac..0000000 --- a/insertr-server/internal/db/sqlite/querier.go +++ /dev/null @@ -1,27 +0,0 @@ -// Code generated by sqlc. DO NOT EDIT. -// versions: -// sqlc v1.29.0 - -package sqlite - -import ( - "context" -) - -type Querier interface { - CreateContent(ctx context.Context, arg CreateContentParams) (Content, error) - CreateContentVersion(ctx context.Context, arg CreateContentVersionParams) error - DeleteContent(ctx context.Context, arg DeleteContentParams) error - DeleteOldVersions(ctx context.Context, arg DeleteOldVersionsParams) error - GetAllContent(ctx context.Context, siteID string) ([]Content, error) - GetAllVersionsForSite(ctx context.Context, arg GetAllVersionsForSiteParams) ([]GetAllVersionsForSiteRow, error) - GetBulkContent(ctx context.Context, arg GetBulkContentParams) ([]Content, error) - GetContent(ctx context.Context, arg GetContentParams) (Content, error) - GetContentVersion(ctx context.Context, versionID int64) (ContentVersion, error) - GetContentVersionHistory(ctx context.Context, arg GetContentVersionHistoryParams) ([]ContentVersion, error) - InitializeSchema(ctx context.Context) error - InitializeVersionsTable(ctx context.Context) error - UpdateContent(ctx context.Context, arg UpdateContentParams) (Content, error) -} - -var _ Querier = (*Queries)(nil) diff --git a/insertr-server/internal/db/sqlite/setup.sql.go b/insertr-server/internal/db/sqlite/setup.sql.go deleted file mode 100644 index 800ef7e..0000000 --- a/insertr-server/internal/db/sqlite/setup.sql.go +++ /dev/null @@ -1,45 +0,0 @@ -// Code generated by sqlc. DO NOT EDIT. -// versions: -// sqlc v1.29.0 -// source: setup.sql - -package sqlite - -import ( - "context" -) - -const initializeSchema = `-- name: InitializeSchema :exec -CREATE TABLE IF NOT EXISTS content ( - id TEXT NOT NULL, - site_id TEXT NOT NULL, - value TEXT NOT NULL, - type TEXT NOT NULL CHECK (type IN ('text', 'markdown', 'link')), - created_at INTEGER DEFAULT (strftime('%s', 'now')) NOT NULL, - updated_at INTEGER DEFAULT (strftime('%s', 'now')) NOT NULL, - last_edited_by TEXT DEFAULT 'system' NOT NULL, - PRIMARY KEY (id, site_id) -) -` - -func (q *Queries) InitializeSchema(ctx context.Context) error { - _, err := q.db.ExecContext(ctx, initializeSchema) - return err -} - -const initializeVersionsTable = `-- name: InitializeVersionsTable :exec -CREATE TABLE IF NOT EXISTS content_versions ( - version_id INTEGER PRIMARY KEY AUTOINCREMENT, - content_id TEXT NOT NULL, - site_id TEXT NOT NULL, - value TEXT NOT NULL, - type TEXT NOT NULL, - created_at INTEGER DEFAULT (strftime('%s', 'now')) NOT NULL, - created_by TEXT DEFAULT 'system' NOT NULL -) -` - -func (q *Queries) InitializeVersionsTable(ctx context.Context) error { - _, err := q.db.ExecContext(ctx, initializeVersionsTable) - return err -} diff --git a/insertr-server/internal/db/sqlite/versions.sql.go b/insertr-server/internal/db/sqlite/versions.sql.go deleted file mode 100644 index 8d46807..0000000 --- a/insertr-server/internal/db/sqlite/versions.sql.go +++ /dev/null @@ -1,175 +0,0 @@ -// Code generated by sqlc. DO NOT EDIT. -// versions: -// sqlc v1.29.0 -// source: versions.sql - -package sqlite - -import ( - "context" - "database/sql" -) - -const createContentVersion = `-- name: CreateContentVersion :exec -INSERT INTO content_versions (content_id, site_id, value, type, created_by) -VALUES (?1, ?2, ?3, ?4, ?5) -` - -type CreateContentVersionParams struct { - ContentID string `json:"content_id"` - SiteID string `json:"site_id"` - Value string `json:"value"` - Type string `json:"type"` - CreatedBy string `json:"created_by"` -} - -func (q *Queries) CreateContentVersion(ctx context.Context, arg CreateContentVersionParams) error { - _, err := q.db.ExecContext(ctx, createContentVersion, - arg.ContentID, - arg.SiteID, - arg.Value, - arg.Type, - arg.CreatedBy, - ) - return err -} - -const deleteOldVersions = `-- name: DeleteOldVersions :exec -DELETE FROM content_versions -WHERE created_at < ?1 AND site_id = ?2 -` - -type DeleteOldVersionsParams struct { - CreatedBefore int64 `json:"created_before"` - SiteID string `json:"site_id"` -} - -func (q *Queries) DeleteOldVersions(ctx context.Context, arg DeleteOldVersionsParams) error { - _, err := q.db.ExecContext(ctx, deleteOldVersions, arg.CreatedBefore, arg.SiteID) - return err -} - -const getAllVersionsForSite = `-- name: GetAllVersionsForSite :many -SELECT - cv.version_id, cv.content_id, cv.site_id, cv.value, cv.type, cv.created_at, cv.created_by, - c.value as current_value -FROM content_versions cv -LEFT JOIN content c ON cv.content_id = c.id AND cv.site_id = c.site_id -WHERE cv.site_id = ?1 -ORDER BY cv.created_at DESC -LIMIT ?2 -` - -type GetAllVersionsForSiteParams struct { - SiteID string `json:"site_id"` - LimitCount int64 `json:"limit_count"` -} - -type GetAllVersionsForSiteRow struct { - VersionID int64 `json:"version_id"` - ContentID string `json:"content_id"` - SiteID string `json:"site_id"` - Value string `json:"value"` - Type string `json:"type"` - CreatedAt int64 `json:"created_at"` - CreatedBy string `json:"created_by"` - CurrentValue sql.NullString `json:"current_value"` -} - -func (q *Queries) GetAllVersionsForSite(ctx context.Context, arg GetAllVersionsForSiteParams) ([]GetAllVersionsForSiteRow, error) { - rows, err := q.db.QueryContext(ctx, getAllVersionsForSite, arg.SiteID, arg.LimitCount) - if err != nil { - return nil, err - } - defer rows.Close() - var items []GetAllVersionsForSiteRow - for rows.Next() { - var i GetAllVersionsForSiteRow - if err := rows.Scan( - &i.VersionID, - &i.ContentID, - &i.SiteID, - &i.Value, - &i.Type, - &i.CreatedAt, - &i.CreatedBy, - &i.CurrentValue, - ); err != nil { - return nil, err - } - items = append(items, i) - } - if err := rows.Close(); err != nil { - return nil, err - } - if err := rows.Err(); err != nil { - return nil, err - } - return items, nil -} - -const getContentVersion = `-- name: GetContentVersion :one -SELECT version_id, content_id, site_id, value, type, created_at, created_by -FROM content_versions -WHERE version_id = ?1 -` - -func (q *Queries) GetContentVersion(ctx context.Context, versionID int64) (ContentVersion, error) { - row := q.db.QueryRowContext(ctx, getContentVersion, versionID) - var i ContentVersion - err := row.Scan( - &i.VersionID, - &i.ContentID, - &i.SiteID, - &i.Value, - &i.Type, - &i.CreatedAt, - &i.CreatedBy, - ) - return i, err -} - -const getContentVersionHistory = `-- name: GetContentVersionHistory :many -SELECT version_id, content_id, site_id, value, type, created_at, created_by -FROM content_versions -WHERE content_id = ?1 AND site_id = ?2 -ORDER BY created_at DESC -LIMIT ?3 -` - -type GetContentVersionHistoryParams struct { - ContentID string `json:"content_id"` - SiteID string `json:"site_id"` - LimitCount int64 `json:"limit_count"` -} - -func (q *Queries) GetContentVersionHistory(ctx context.Context, arg GetContentVersionHistoryParams) ([]ContentVersion, error) { - rows, err := q.db.QueryContext(ctx, getContentVersionHistory, arg.ContentID, arg.SiteID, arg.LimitCount) - if err != nil { - return nil, err - } - defer rows.Close() - var items []ContentVersion - for rows.Next() { - var i ContentVersion - if err := rows.Scan( - &i.VersionID, - &i.ContentID, - &i.SiteID, - &i.Value, - &i.Type, - &i.CreatedAt, - &i.CreatedBy, - ); err != nil { - return nil, err - } - items = append(items, i) - } - if err := rows.Close(); err != nil { - return nil, err - } - if err := rows.Err(); err != nil { - return nil, err - } - return items, nil -} diff --git a/insertr-server/sqlc.yaml b/insertr-server/sqlc.yaml deleted file mode 100644 index 4fea7a4..0000000 --- a/insertr-server/sqlc.yaml +++ /dev/null @@ -1,31 +0,0 @@ -version: "2" -sql: - # SQLite configuration for development - - name: "sqlite" - engine: "sqlite" - queries: ["db/queries/", "db/sqlite/setup.sql"] - schema: "db/sqlite/schema.sql" - gen: - go: - package: "sqlite" - out: "internal/db/sqlite" - emit_json_tags: true - emit_prepared_queries: false - emit_interface: true - emit_exact_table_names: false - emit_pointers_for_null_types: false # All fields are NOT NULL now - - # PostgreSQL configuration for production - - name: "postgresql" - engine: "postgresql" - queries: ["db/queries/", "db/postgresql/setup.sql"] - schema: "db/postgresql/schema.sql" - gen: - go: - package: "postgresql" - out: "internal/db/postgresql" - emit_json_tags: true - emit_prepared_queries: false - emit_interface: true - emit_exact_table_names: false - emit_pointers_for_null_types: false # All fields are NOT NULL now \ No newline at end of file diff --git a/scripts/dev.js b/scripts/dev.js index 4af3eb6..ce225b6 100755 --- a/scripts/dev.js +++ b/scripts/dev.js @@ -43,13 +43,12 @@ const commands = { 'demo-site/about.html', 'lib/dist/insertr.js', 'lib/dist/insertr.min.js', - 'insertr-server/cmd/server/main.go', + 'cmd/serve.go', 'package.json' ]; const optionalFiles = [ - 'insertr-cli/insertr', - 'insertr-server/insertr-server' + 'insertr' ]; let allGood = true;