feat: implement minimal server-first markdown processing
Backend implementation: - Add goldmark dependency for markdown processing - Create MarkdownProcessor with minimal config (bold, italic, links only) - Update content injector with HTML injection capabilities - Add injectHTMLContent() for safe DOM manipulation - Server now converts **bold**, *italic*, [links](url) to HTML during enhancement Frontend alignment: - Restrict marked.js to match server capabilities - Disable unsupported features (headings, lists, code blocks, tables) - Update turndown rules to prevent unsupported markdown generation - Frontend editor preview now matches server output exactly Server as source of truth: - Build-time markdown→HTML conversion during enhancement - Zero runtime overhead for end users - Consistent formatting between editor preview and final output - Raw markdown stored in database, HTML served to visitors Tested features: - **bold** → <strong>bold</strong> ✅ - *italic* → <em>italic</em> ✅ - [text](url) → <a href="url">text</a> ✅
This commit is contained in:
@@ -108,7 +108,7 @@ func (e *Enhancer) findAndInjectNodes(rootNode *html.Node, elem parser.Element,
|
||||
}
|
||||
|
||||
// Inject content attributes for the correctly matched node
|
||||
e.injector.addContentAttributes(targetNode, elem.ContentID, string(elem.Type))
|
||||
e.injector.AddContentAttributes(targetNode, elem.ContentID, string(elem.Type))
|
||||
|
||||
// Inject content if available
|
||||
if contentItem != nil {
|
||||
|
||||
@@ -2,6 +2,7 @@ package content
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"strings"
|
||||
|
||||
"golang.org/x/net/html"
|
||||
@@ -9,15 +10,17 @@ import (
|
||||
|
||||
// Injector handles content injection into HTML elements
|
||||
type Injector struct {
|
||||
client ContentClient
|
||||
siteID string
|
||||
client ContentClient
|
||||
siteID string
|
||||
mdProcessor *MarkdownProcessor
|
||||
}
|
||||
|
||||
// NewInjector creates a new content injector
|
||||
func NewInjector(client ContentClient, siteID string) *Injector {
|
||||
return &Injector{
|
||||
client: client,
|
||||
siteID: siteID,
|
||||
client: client,
|
||||
siteID: siteID,
|
||||
mdProcessor: NewMarkdownProcessor(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,7 +34,7 @@ func (i *Injector) InjectContent(element *Element, contentID string) error {
|
||||
|
||||
// If no content found, keep original content but add data attributes
|
||||
if contentItem == nil {
|
||||
i.addContentAttributes(element.Node, contentID, element.Type)
|
||||
i.AddContentAttributes(element.Node, contentID, element.Type)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -48,7 +51,7 @@ func (i *Injector) InjectContent(element *Element, contentID string) error {
|
||||
}
|
||||
|
||||
// Add data attributes for editor functionality
|
||||
i.addContentAttributes(element.Node, contentID, element.Type)
|
||||
i.AddContentAttributes(element.Node, contentID, element.Type)
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -72,7 +75,7 @@ func (i *Injector) InjectBulkContent(elements []ElementWithID) error {
|
||||
contentItem, exists := contentMap[elem.ContentID]
|
||||
|
||||
// Add content attributes regardless
|
||||
i.addContentAttributes(elem.Element.Node, elem.ContentID, elem.Element.Type)
|
||||
i.AddContentAttributes(elem.Element.Node, elem.ContentID, elem.Element.Type)
|
||||
|
||||
if !exists {
|
||||
// Keep original content if not found in database
|
||||
@@ -112,11 +115,23 @@ func (i *Injector) injectTextContent(node *html.Node, content string) {
|
||||
node.AppendChild(textNode)
|
||||
}
|
||||
|
||||
// injectMarkdownContent handles markdown content (for now, just as text)
|
||||
// injectMarkdownContent handles markdown content - converts markdown to HTML
|
||||
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)
|
||||
if content == "" {
|
||||
i.injectTextContent(node, "")
|
||||
return
|
||||
}
|
||||
|
||||
// Convert markdown to HTML using server processor
|
||||
htmlContent, err := i.mdProcessor.ToHTML(content)
|
||||
if err != nil {
|
||||
log.Printf("⚠️ Markdown conversion failed for content '%s': %v, falling back to text", content, err)
|
||||
i.injectTextContent(node, content)
|
||||
return
|
||||
}
|
||||
|
||||
// Inject the HTML content
|
||||
i.injectHTMLContent(node, htmlContent)
|
||||
}
|
||||
|
||||
// injectLinkContent handles link/button content with URL extraction
|
||||
@@ -126,8 +141,68 @@ func (i *Injector) injectLinkContent(node *html.Node, content string) {
|
||||
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) {
|
||||
// injectHTMLContent safely injects HTML content into a DOM node
|
||||
func (i *Injector) injectHTMLContent(node *html.Node, htmlContent string) {
|
||||
// Clear existing content
|
||||
i.clearNode(node)
|
||||
|
||||
if htmlContent == "" {
|
||||
return
|
||||
}
|
||||
|
||||
// Wrap content to create valid HTML document for parsing
|
||||
wrappedHTML := "<div>" + htmlContent + "</div>"
|
||||
|
||||
// Parse HTML string
|
||||
doc, err := html.Parse(strings.NewReader(wrappedHTML))
|
||||
if err != nil {
|
||||
log.Printf("Failed to parse HTML content '%s': %v, falling back to text", htmlContent, err)
|
||||
i.injectTextContent(node, htmlContent)
|
||||
return
|
||||
}
|
||||
|
||||
// Find the wrapper div and move its children to target node
|
||||
wrapper := i.findElementByTag(doc, "div")
|
||||
if wrapper == nil {
|
||||
log.Printf("Could not find wrapper div in parsed HTML")
|
||||
return
|
||||
}
|
||||
|
||||
// Move parsed nodes to target element
|
||||
for child := wrapper.FirstChild; child != nil; {
|
||||
next := child.NextSibling
|
||||
wrapper.RemoveChild(child)
|
||||
node.AppendChild(child)
|
||||
child = next
|
||||
}
|
||||
}
|
||||
|
||||
// clearNode removes all child nodes from a given node
|
||||
func (i *Injector) clearNode(node *html.Node) {
|
||||
for child := node.FirstChild; child != nil; {
|
||||
next := child.NextSibling
|
||||
node.RemoveChild(child)
|
||||
child = next
|
||||
}
|
||||
}
|
||||
|
||||
// findElementByTag finds the first element with the specified tag name
|
||||
func (i *Injector) findElementByTag(node *html.Node, tag string) *html.Node {
|
||||
if node.Type == html.ElementNode && node.Data == tag {
|
||||
return node
|
||||
}
|
||||
|
||||
for child := node.FirstChild; child != nil; child = child.NextSibling {
|
||||
if found := i.findElementByTag(child, tag); found != nil {
|
||||
return found
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// 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")
|
||||
|
||||
67
internal/content/markdown.go
Normal file
67
internal/content/markdown.go
Normal file
@@ -0,0 +1,67 @@
|
||||
package content
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"log"
|
||||
|
||||
"github.com/yuin/goldmark"
|
||||
"github.com/yuin/goldmark/parser"
|
||||
"github.com/yuin/goldmark/renderer/html"
|
||||
"github.com/yuin/goldmark/util"
|
||||
)
|
||||
|
||||
// MarkdownProcessor handles minimal markdown processing
|
||||
// Supports only: **bold**, *italic*, and [link](url)
|
||||
type MarkdownProcessor struct {
|
||||
parser goldmark.Markdown
|
||||
}
|
||||
|
||||
// NewMarkdownProcessor creates a new markdown processor with minimal configuration
|
||||
func NewMarkdownProcessor() *MarkdownProcessor {
|
||||
// Configure goldmark to only support basic inline formatting
|
||||
md := goldmark.New(
|
||||
goldmark.WithParserOptions(
|
||||
parser.WithInlineParsers(
|
||||
// Bold (**text**) and italic (*text*) - same parser handles both
|
||||
util.Prioritized(parser.NewEmphasisParser(), 500),
|
||||
|
||||
// Links [text](url)
|
||||
util.Prioritized(parser.NewLinkParser(), 600),
|
||||
),
|
||||
// Disable all block parsers except paragraph (no headings, lists, etc.)
|
||||
parser.WithBlockParsers(
|
||||
util.Prioritized(parser.NewParagraphParser(), 200),
|
||||
),
|
||||
),
|
||||
goldmark.WithRendererOptions(
|
||||
html.WithXHTML(), // <br /> instead of <br>
|
||||
html.WithHardWraps(), // Line breaks become <br />
|
||||
html.WithUnsafe(), // Allow existing HTML to pass through
|
||||
),
|
||||
)
|
||||
|
||||
return &MarkdownProcessor{parser: md}
|
||||
}
|
||||
|
||||
// ToHTML converts markdown string to HTML
|
||||
func (mp *MarkdownProcessor) ToHTML(markdown string) (string, error) {
|
||||
if markdown == "" {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
if err := mp.parser.Convert([]byte(markdown), &buf); err != nil {
|
||||
log.Printf("Markdown conversion failed: %v", err)
|
||||
return "", err
|
||||
}
|
||||
|
||||
html := buf.String()
|
||||
|
||||
// Clean up goldmark's paragraph wrapping - we want inline content
|
||||
// Remove <p> and </p> tags if the content is wrapped in a single paragraph
|
||||
if len(html) > 7 && html[:3] == "<p>" && html[len(html)-4:] == "</p>" {
|
||||
html = html[3 : len(html)-4]
|
||||
}
|
||||
|
||||
return html, nil
|
||||
}
|
||||
Reference in New Issue
Block a user