694 lines
19 KiB
Go
694 lines
19 KiB
Go
package seo
|
||
|
||
import (
|
||
"encoding/json"
|
||
"fmt"
|
||
"regexp"
|
||
"sort"
|
||
"strconv"
|
||
"strings"
|
||
"unicode"
|
||
|
||
"seo-optimizer/internal/logger"
|
||
"seo-optimizer/pkg/models"
|
||
)
|
||
|
||
// ContentBuilder applies all optimizations to post content
|
||
type ContentBuilder struct {
|
||
logger *logger.Logger
|
||
}
|
||
|
||
// NewContentBuilder creates a new content builder
|
||
func NewContentBuilder(log *logger.Logger) *ContentBuilder {
|
||
if log == nil {
|
||
log = logger.NewDefaultLogger()
|
||
}
|
||
|
||
return &ContentBuilder{
|
||
logger: log.WithComponent("content-builder"),
|
||
}
|
||
}
|
||
|
||
// BuildOptimizedContent applies all optimizations and returns the updated content
|
||
func (cb *ContentBuilder) BuildOptimizedContent(
|
||
original string,
|
||
optimization *models.OptimizationResponse,
|
||
) (string, error) {
|
||
content := original
|
||
originalHasFAQ := cb.hasFAQBlock(original)
|
||
originalHasBlocks := cb.hasGutenbergBlocks(original)
|
||
unplacedLinks := make([]models.InternalLink, 0)
|
||
|
||
cb.logger.Info("Building optimized content",
|
||
"changes", len(optimization.ContentOptimization.Changes),
|
||
"links", len(optimization.ContentOptimization.InternalLinks),
|
||
"fact_checks", len(optimization.Validation.FactChecks),
|
||
"broken_links", len(optimization.Validation.BrokenLinks),
|
||
"faq", optimization.FAQBlock.ShouldCreate,
|
||
)
|
||
|
||
// 1. Apply content changes (keyword injection, heading improvements, etc.)
|
||
for i, change := range optimization.ContentOptimization.Changes {
|
||
cb.logger.Debug("Applying content change",
|
||
"index", i,
|
||
"type", change.Type,
|
||
"location", change.Location,
|
||
)
|
||
|
||
content = cb.applyChange(content, change)
|
||
}
|
||
|
||
// 2. Apply validation fixes (update outdated info, fix broken links)
|
||
for i, factCheck := range optimization.Validation.FactChecks {
|
||
if factCheck.Status == "outdated" && factCheck.UpdatedText != "" {
|
||
cb.logger.Debug("Applying fact check update",
|
||
"index", i,
|
||
"claim", truncate(factCheck.Claim, 100),
|
||
)
|
||
|
||
content = cb.applyFactCheckUpdate(content, factCheck)
|
||
}
|
||
}
|
||
|
||
for i, brokenLink := range optimization.Validation.BrokenLinks {
|
||
cb.logger.Debug("Fixing broken link",
|
||
"index", i,
|
||
"url", brokenLink.URL,
|
||
"action", brokenLink.Action,
|
||
)
|
||
|
||
content = cb.fixBrokenLink(content, brokenLink)
|
||
}
|
||
|
||
// 3. Insert internal links at appropriate positions
|
||
for i, link := range optimization.ContentOptimization.InternalLinks {
|
||
cb.logger.Debug("Inserting internal link",
|
||
"index", i,
|
||
"target_post_id", link.TargetPostID,
|
||
"anchor", link.AnchorText,
|
||
)
|
||
|
||
updated, inserted := cb.insertInternalLink(content, link)
|
||
if inserted {
|
||
content = updated
|
||
} else {
|
||
unplacedLinks = append(unplacedLinks, link)
|
||
}
|
||
}
|
||
|
||
// 4. Convert to Gutenberg blocks if original content was classic
|
||
if !originalHasBlocks {
|
||
content = cb.wrapInGutenbergBlocks(content)
|
||
}
|
||
|
||
// 5. Ensure table blocks are properly structured for Gutenberg
|
||
content = cb.ensureTableBlocks(content)
|
||
|
||
// 6. Update or append FAQ block (never add a second FAQ section)
|
||
if optimization.FAQBlock.ShouldCreate && len(optimization.FAQBlock.Questions) > 0 {
|
||
headingLevel := detectHeadingLevel(content)
|
||
faqBlock := cb.buildFAQBlock(&optimization.FAQBlock, headingLevel)
|
||
if originalHasFAQ {
|
||
updated, replaced := cb.replaceFAQBlock(content, faqBlock)
|
||
if replaced {
|
||
content = updated
|
||
cb.logger.Info("Updated existing FAQ block",
|
||
"questions", len(optimization.FAQBlock.Questions),
|
||
)
|
||
} else {
|
||
cb.logger.Info("Failed to replace existing FAQ block; leaving original")
|
||
}
|
||
} else {
|
||
cb.logger.Info("Adding FAQ block",
|
||
"questions", len(optimization.FAQBlock.Questions),
|
||
)
|
||
content = content + "\n\n" + faqBlock
|
||
}
|
||
} else if originalHasFAQ {
|
||
cb.logger.Info("Skipping FAQ block (existing FAQ detected)")
|
||
}
|
||
|
||
// 7. Append Related Posts section for links that couldn't be placed in context
|
||
if len(unplacedLinks) > 0 {
|
||
headingLevel := detectHeadingLevel(content)
|
||
related := cb.buildRelatedPostsSection(unplacedLinks, headingLevel)
|
||
if related != "" {
|
||
content = content + "\n\n" + related
|
||
}
|
||
}
|
||
|
||
cb.logger.Info("Content optimization complete")
|
||
|
||
return content, nil
|
||
}
|
||
|
||
// applyChange replaces original text with updated text
|
||
func (cb *ContentBuilder) applyChange(content string, change models.ContentChange) string {
|
||
// Try exact match first
|
||
if strings.Contains(content, change.Original) {
|
||
return strings.Replace(content, change.Original, change.Updated, 1)
|
||
}
|
||
|
||
// Try normalized match (handle quotes and whitespace)
|
||
if start, end, ok := findNormalizedSpan(content, change.Original); ok {
|
||
return content[:start] + change.Updated + content[end:]
|
||
}
|
||
|
||
cb.logger.Warn("Could not find exact match for content change",
|
||
"original", truncate(change.Original, 100),
|
||
"type", change.Type,
|
||
)
|
||
|
||
return content
|
||
}
|
||
|
||
// applyFactCheckUpdate updates outdated technical information
|
||
func (cb *ContentBuilder) applyFactCheckUpdate(content string, factCheck models.FactCheck) string {
|
||
// Similar to applyChange but specific to fact checks
|
||
if strings.Contains(content, factCheck.Claim) {
|
||
return strings.Replace(content, factCheck.Claim, factCheck.UpdatedText, 1)
|
||
}
|
||
|
||
if start, end, ok := findNormalizedSpan(content, factCheck.Claim); ok {
|
||
return content[:start] + factCheck.UpdatedText + content[end:]
|
||
}
|
||
|
||
cb.logger.Warn("Could not find fact check claim in content",
|
||
"claim", truncate(factCheck.Claim, 100),
|
||
)
|
||
|
||
return content
|
||
}
|
||
|
||
// fixBrokenLink fixes or removes broken links
|
||
func (cb *ContentBuilder) fixBrokenLink(content string, broken models.BrokenLink) string {
|
||
if broken.Action == "replace" && broken.Replacement != "" {
|
||
// Replace URL in all link tags
|
||
content = strings.ReplaceAll(content, broken.URL, broken.Replacement)
|
||
return content
|
||
}
|
||
|
||
if broken.Action == "remove" {
|
||
// Remove the entire <a> tag containing this URL
|
||
// Pattern: <a [^>]*href="URL"[^>]*>.*?</a>
|
||
pattern := fmt.Sprintf(`<a[^>]*href="%s"[^>]*>.*?</a>`, regexp.QuoteMeta(broken.URL))
|
||
re := regexp.MustCompile(pattern)
|
||
content = re.ReplaceAllString(content, "")
|
||
|
||
// Also try with escaped quotes
|
||
pattern2 := fmt.Sprintf(`<a[^>]*href='%s'[^>]*>.*?</a>`, regexp.QuoteMeta(broken.URL))
|
||
re2 := regexp.MustCompile(pattern2)
|
||
content = re2.ReplaceAllString(content, "")
|
||
}
|
||
|
||
return content
|
||
}
|
||
|
||
// insertInternalLink inserts an internal link at the appropriate position
|
||
func (cb *ContentBuilder) insertInternalLink(content string, link models.InternalLink) (string, bool) {
|
||
// Build the link HTML
|
||
linkHTML := fmt.Sprintf(`<a href="/%s/">%s</a>`, link.TargetSlug, link.AnchorText)
|
||
|
||
// Try to find insertion point based on context
|
||
// This is a simplified implementation - could be enhanced with more sophisticated context matching
|
||
|
||
// If insertion context mentions specific text, try to find and insert after it
|
||
if strings.Contains(content, link.InsertionContext) {
|
||
// Insert after the context
|
||
parts := strings.SplitN(content, link.InsertionContext, 2)
|
||
if len(parts) == 2 {
|
||
// Find the end of the current sentence/paragraph
|
||
endIdx := findSentenceEnd(parts[1])
|
||
if endIdx > 0 {
|
||
return parts[0] + link.InsertionContext + parts[1][:endIdx] + " " + linkHTML + parts[1][endIdx:], true
|
||
}
|
||
}
|
||
}
|
||
|
||
if _, end, ok := findNormalizedSpan(content, link.InsertionContext); ok {
|
||
remainder := content[end:]
|
||
endIdx := findSentenceEnd(remainder)
|
||
if endIdx > 0 {
|
||
return content[:end] + remainder[:endIdx] + " " + linkHTML + remainder[endIdx:], true
|
||
}
|
||
return content[:end] + " " + linkHTML + content[end:], true
|
||
}
|
||
|
||
// Fallback: try to insert the link by replacing the anchor text if it appears in content
|
||
if strings.Contains(content, link.AnchorText) && !cb.isInsideTag(content, link.AnchorText) {
|
||
return strings.Replace(content, link.AnchorText, linkHTML, 1), true
|
||
}
|
||
|
||
cb.logger.Warn("Could not find appropriate insertion point for internal link",
|
||
"anchor", link.AnchorText,
|
||
"context", truncate(link.InsertionContext, 100),
|
||
)
|
||
|
||
return content, false
|
||
}
|
||
|
||
// isInsideTag checks if text is already inside an HTML tag
|
||
func (cb *ContentBuilder) isInsideTag(content, text string) bool {
|
||
idx := strings.Index(content, text)
|
||
if idx == -1 {
|
||
return false
|
||
}
|
||
|
||
// Check if there's an unclosed < before this position
|
||
before := content[:idx]
|
||
lastOpen := strings.LastIndex(before, "<")
|
||
lastClose := strings.LastIndex(before, ">")
|
||
|
||
// If last < comes after last >, we're inside a tag
|
||
return lastOpen > lastClose
|
||
}
|
||
|
||
func normalizeForMatch(input string) string {
|
||
normalized, _ := normalizeWithMap(input)
|
||
return normalized
|
||
}
|
||
|
||
func findNormalizedSpan(content, needle string) (int, int, bool) {
|
||
if strings.TrimSpace(needle) == "" {
|
||
return 0, 0, false
|
||
}
|
||
|
||
normalizedContent, indexMap := normalizeWithMap(content)
|
||
normalizedNeedle := normalizeForMatch(needle)
|
||
if normalizedNeedle == "" {
|
||
return 0, 0, false
|
||
}
|
||
|
||
idx := strings.Index(normalizedContent, normalizedNeedle)
|
||
if idx == -1 {
|
||
return 0, 0, false
|
||
}
|
||
|
||
endIdx := idx + len(normalizedNeedle) - 1
|
||
if idx < 0 || endIdx >= len(indexMap) {
|
||
return 0, 0, false
|
||
}
|
||
|
||
start := indexMap[idx]
|
||
end := indexMap[endIdx] + 1
|
||
if start < 0 || end > len(content) || start >= end {
|
||
return 0, 0, false
|
||
}
|
||
return start, end, true
|
||
}
|
||
|
||
func normalizeWithMap(input string) (string, []int) {
|
||
var b strings.Builder
|
||
indexMap := make([]int, 0, len(input))
|
||
lastSpace := false
|
||
|
||
for i, r := range input {
|
||
if unicode.IsSpace(r) {
|
||
if lastSpace {
|
||
continue
|
||
}
|
||
lastSpace = true
|
||
b.WriteByte(' ')
|
||
indexMap = append(indexMap, i)
|
||
continue
|
||
}
|
||
|
||
lastSpace = false
|
||
r = normalizeQuote(r)
|
||
token := strings.ToLower(string(r))
|
||
b.WriteString(token)
|
||
for j := 0; j < len(token); j++ {
|
||
indexMap = append(indexMap, i)
|
||
}
|
||
}
|
||
|
||
return b.String(), indexMap
|
||
}
|
||
|
||
func normalizeQuote(r rune) rune {
|
||
switch r {
|
||
case '“', '”':
|
||
return '"'
|
||
case '‘', '’':
|
||
return '\''
|
||
default:
|
||
return r
|
||
}
|
||
}
|
||
|
||
func insertAfterFirstParagraph(content, linkHTML string) (string, bool) {
|
||
if strings.TrimSpace(content) == "" {
|
||
return content, false
|
||
}
|
||
if idx := strings.Index(content, "</p>"); idx != -1 {
|
||
insertAt := idx + len("</p>")
|
||
return content[:insertAt] + " " + linkHTML + content[insertAt:], true
|
||
}
|
||
if idx := strings.Index(content, "\n\n"); idx != -1 {
|
||
return content[:idx] + "\n\n" + linkHTML + "\n\n" + content[idx+2:], true
|
||
}
|
||
return content + "\n\n" + linkHTML, true
|
||
}
|
||
|
||
// findSentenceEnd finds the end of the current sentence (. ! ?)
|
||
func findSentenceEnd(text string) int {
|
||
for i, char := range text {
|
||
if char == '.' || char == '!' || char == '?' {
|
||
return i + 1
|
||
}
|
||
}
|
||
return 0
|
||
}
|
||
|
||
// buildFAQBlock generates a RankMath FAQ Gutenberg block
|
||
func (cb *ContentBuilder) buildFAQBlock(faq *models.FAQBlock, headingLevel int) string {
|
||
if len(faq.Questions) == 0 {
|
||
return ""
|
||
}
|
||
|
||
// Build questions JSON array for Gutenberg comment
|
||
questionsJSON, err := json.Marshal(faq.Questions)
|
||
if err != nil {
|
||
cb.logger.Error("Failed to marshal FAQ questions", "error", err)
|
||
return ""
|
||
}
|
||
|
||
// Build the HTML structure for each FAQ item
|
||
var faqItems strings.Builder
|
||
for _, q := range faq.Questions {
|
||
faqItems.WriteString(fmt.Sprintf(`<div class="rank-math-faq-item"><h3 class="rank-math-question">%s</h3><div class="rank-math-answer">%s</div></div>`,
|
||
q.Title,
|
||
q.Content,
|
||
))
|
||
}
|
||
|
||
// Gutenberg block format for RankMath FAQ
|
||
// <!-- wp:rank-math/faq-block {"questions":[...]} -->
|
||
// <div class="wp-block-rank-math-faq-block">
|
||
// <div class="rank-math-faq-item">...</div>
|
||
// ...
|
||
// </div>
|
||
// <!-- /wp:rank-math/faq-block -->
|
||
if headingLevel < 1 || headingLevel > 6 {
|
||
headingLevel = 2
|
||
}
|
||
heading := fmt.Sprintf(`<!-- wp:heading {"level":%d} -->
|
||
<h%d>Frequently Asked Questions</h%d>
|
||
<!-- /wp:heading -->`, headingLevel, headingLevel, headingLevel)
|
||
|
||
return fmt.Sprintf(`%s
|
||
|
||
<!-- wp:rank-math/faq-block {"questions":%s} -->
|
||
<div class="wp-block-rank-math-faq-block">%s</div>
|
||
<!-- /wp:rank-math/faq-block -->`, heading, string(questionsJSON), faqItems.String())
|
||
}
|
||
|
||
func (cb *ContentBuilder) hasFAQBlock(content string) bool {
|
||
if strings.Contains(content, "wp:rank-math/faq-block") {
|
||
return true
|
||
}
|
||
if strings.Contains(content, "wp-block-rank-math-faq-block") {
|
||
return true
|
||
}
|
||
return false
|
||
}
|
||
|
||
func (cb *ContentBuilder) hasGutenbergBlocks(content string) bool {
|
||
return strings.Contains(content, "<!-- wp:")
|
||
}
|
||
|
||
func (cb *ContentBuilder) wrapInGutenbergBlocks(content string) string {
|
||
trimmed := strings.TrimSpace(content)
|
||
if trimmed == "" {
|
||
return content
|
||
}
|
||
|
||
blockRe := regexp.MustCompile(`(?is)(<h[1-6][^>]*>.*?</h[1-6]>|<ul[^>]*>.*?</ul>|<ol[^>]*>.*?</ol>|<p[^>]*>.*?</p>|<figure[^>]*wp-block-table[^>]*>.*?</figure>|<table[^>]*>.*?</table>)`)
|
||
matches := blockRe.FindAllStringIndex(trimmed, -1)
|
||
if len(matches) == 0 {
|
||
parts := splitParagraphs(trimmed)
|
||
var builder strings.Builder
|
||
for i, part := range parts {
|
||
part = strings.TrimSpace(part)
|
||
if part == "" {
|
||
continue
|
||
}
|
||
if i > 0 && builder.Len() > 0 {
|
||
builder.WriteString("\n\n")
|
||
}
|
||
builder.WriteString(wrapTextAsParagraph(part))
|
||
}
|
||
return builder.String()
|
||
}
|
||
|
||
var builder strings.Builder
|
||
last := 0
|
||
for i, match := range matches {
|
||
start, end := match[0], match[1]
|
||
if start > last {
|
||
leading := strings.TrimSpace(trimmed[last:start])
|
||
if leading != "" {
|
||
if builder.Len() > 0 {
|
||
builder.WriteString("\n\n")
|
||
}
|
||
builder.WriteString(wrapTextAsParagraph(leading))
|
||
}
|
||
}
|
||
|
||
block := trimmed[start:end]
|
||
if builder.Len() > 0 {
|
||
builder.WriteString("\n\n")
|
||
}
|
||
builder.WriteString(wrapHTMLBlock(block))
|
||
|
||
last = end
|
||
if i == len(matches)-1 && last < len(trimmed) {
|
||
trailing := strings.TrimSpace(trimmed[last:])
|
||
if trailing != "" {
|
||
builder.WriteString("\n\n")
|
||
builder.WriteString(wrapTextAsParagraph(trailing))
|
||
}
|
||
}
|
||
}
|
||
|
||
return builder.String()
|
||
}
|
||
|
||
func wrapTextAsParagraph(text string) string {
|
||
return fmt.Sprintf("<!-- wp:paragraph -->\n<p>%s</p>\n<!-- /wp:paragraph -->", text)
|
||
}
|
||
|
||
func wrapHTMLBlock(block string) string {
|
||
block = strings.TrimSpace(block)
|
||
|
||
if strings.HasPrefix(strings.ToLower(block), "<p") {
|
||
return fmt.Sprintf("<!-- wp:paragraph -->\n%s\n<!-- /wp:paragraph -->", block)
|
||
}
|
||
|
||
if strings.HasPrefix(strings.ToLower(block), "<ul") || strings.HasPrefix(strings.ToLower(block), "<ol") {
|
||
return fmt.Sprintf("<!-- wp:list -->\n%s\n<!-- /wp:list -->", block)
|
||
}
|
||
|
||
if strings.HasPrefix(strings.ToLower(block), "<h") {
|
||
level := extractHeadingLevel(block)
|
||
if level > 0 {
|
||
return fmt.Sprintf("<!-- wp:heading {\"level\":%d} -->\n%s\n<!-- /wp:heading -->", level, block)
|
||
}
|
||
return fmt.Sprintf("<!-- wp:heading -->\n%s\n<!-- /wp:heading -->", block)
|
||
}
|
||
|
||
if strings.HasPrefix(strings.ToLower(block), "<figure") && strings.Contains(strings.ToLower(block), "wp-block-table") {
|
||
return fmt.Sprintf("<!-- wp:table -->\n%s\n<!-- /wp:table -->", block)
|
||
}
|
||
|
||
if strings.HasPrefix(strings.ToLower(block), "<table") {
|
||
return fmt.Sprintf("<!-- wp:table -->\n<figure class=\"wp-block-table\">%s</figure>\n<!-- /wp:table -->", block)
|
||
}
|
||
|
||
return fmt.Sprintf("<!-- wp:paragraph -->\n<p>%s</p>\n<!-- /wp:paragraph -->", block)
|
||
}
|
||
|
||
func extractHeadingLevel(block string) int {
|
||
re := regexp.MustCompile(`(?i)<h([1-6])[^>]*>`)
|
||
matches := re.FindStringSubmatch(block)
|
||
if len(matches) != 2 {
|
||
return 0
|
||
}
|
||
switch matches[1] {
|
||
case "1":
|
||
return 1
|
||
case "2":
|
||
return 2
|
||
case "3":
|
||
return 3
|
||
case "4":
|
||
return 4
|
||
case "5":
|
||
return 5
|
||
case "6":
|
||
return 6
|
||
default:
|
||
return 0
|
||
}
|
||
}
|
||
|
||
func detectHeadingLevel(content string) int {
|
||
blockHeadingRe := regexp.MustCompile(`<!--\s*wp:heading\s*(\{[^}]*\})?\s*-->`)
|
||
blockHeadingLevels := regexp.MustCompile(`"level"\s*:\s*(\d)`)
|
||
|
||
if match := blockHeadingRe.FindString(content); match != "" {
|
||
levelMatch := blockHeadingLevels.FindStringSubmatch(match)
|
||
if len(levelMatch) == 2 {
|
||
if level, err := strconv.Atoi(levelMatch[1]); err == nil {
|
||
return level
|
||
}
|
||
}
|
||
}
|
||
|
||
htmlHeadingRe := regexp.MustCompile(`(?i)<h([1-6])[^>]*>`)
|
||
if match := htmlHeadingRe.FindStringSubmatch(content); len(match) == 2 {
|
||
if level, err := strconv.Atoi(match[1]); err == nil {
|
||
return level
|
||
}
|
||
}
|
||
|
||
return 2
|
||
}
|
||
|
||
func splitParagraphs(content string) []string {
|
||
re := regexp.MustCompile(`\r?\n\s*\r?\n`)
|
||
return re.Split(content, -1)
|
||
}
|
||
|
||
func (cb *ContentBuilder) ensureTableBlocks(content string) string {
|
||
if strings.TrimSpace(content) == "" {
|
||
return content
|
||
}
|
||
|
||
type match struct {
|
||
start int
|
||
end int
|
||
kind string
|
||
}
|
||
|
||
var matches []match
|
||
figureRe := regexp.MustCompile(`(?is)<figure[^>]*wp-block-table[^>]*>.*?</figure>`)
|
||
tableRe := regexp.MustCompile(`(?is)<table[^>]*>.*?</table>`)
|
||
|
||
for _, loc := range figureRe.FindAllStringIndex(content, -1) {
|
||
matches = append(matches, match{start: loc[0], end: loc[1], kind: "figure"})
|
||
}
|
||
for _, loc := range tableRe.FindAllStringIndex(content, -1) {
|
||
matches = append(matches, match{start: loc[0], end: loc[1], kind: "table"})
|
||
}
|
||
|
||
if len(matches) == 0 {
|
||
return content
|
||
}
|
||
|
||
sort.Slice(matches, func(i, j int) bool {
|
||
if matches[i].start == matches[j].start {
|
||
return matches[i].end > matches[j].end
|
||
}
|
||
return matches[i].start < matches[j].start
|
||
})
|
||
|
||
var b strings.Builder
|
||
last := 0
|
||
for _, m := range matches {
|
||
if m.start < last {
|
||
continue
|
||
}
|
||
b.WriteString(content[last:m.start])
|
||
|
||
block := content[m.start:m.end]
|
||
openIdx := strings.LastIndex(content[:m.start], "<!-- wp:table")
|
||
closeIdx := strings.LastIndex(content[:m.start], "<!-- /wp:table")
|
||
insideTableBlock := openIdx != -1 && (closeIdx == -1 || openIdx > closeIdx)
|
||
if insideTableBlock {
|
||
b.WriteString(block)
|
||
last = m.end
|
||
continue
|
||
}
|
||
|
||
switch m.kind {
|
||
case "figure":
|
||
b.WriteString(fmt.Sprintf("<!-- wp:table -->\n%s\n<!-- /wp:table -->", block))
|
||
case "table":
|
||
lower := strings.ToLower(block)
|
||
if strings.Contains(lower, "wp-block-table") {
|
||
b.WriteString(block)
|
||
} else {
|
||
b.WriteString(fmt.Sprintf("<!-- wp:table -->\n<figure class=\"wp-block-table\">%s</figure>\n<!-- /wp:table -->", block))
|
||
}
|
||
default:
|
||
b.WriteString(block)
|
||
}
|
||
last = m.end
|
||
}
|
||
b.WriteString(content[last:])
|
||
return b.String()
|
||
}
|
||
|
||
func (cb *ContentBuilder) replaceFAQBlock(content, replacement string) (string, bool) {
|
||
if strings.TrimSpace(content) == "" || strings.TrimSpace(replacement) == "" {
|
||
return content, false
|
||
}
|
||
re := regexp.MustCompile(`(?s)<!--\s*wp:rank-math/faq-block\b[^>]*-->.*?<!--\s*/wp:rank-math/faq-block\s*-->`)
|
||
if !re.MatchString(content) {
|
||
return content, false
|
||
}
|
||
updated := re.ReplaceAllString(content, replacement)
|
||
return updated, true
|
||
}
|
||
|
||
func (cb *ContentBuilder) buildRelatedPostsSection(links []models.InternalLink, headingLevel int) string {
|
||
if len(links) == 0 {
|
||
return ""
|
||
}
|
||
if headingLevel < 1 || headingLevel > 6 {
|
||
headingLevel = 2
|
||
}
|
||
|
||
var b strings.Builder
|
||
b.WriteString(fmt.Sprintf("<!-- wp:heading {\"level\":%d} -->\n<h%d>Related posts</h%d>\n<!-- /wp:heading -->\n\n", headingLevel, headingLevel, headingLevel))
|
||
b.WriteString("<!-- wp:list -->\n<ul>")
|
||
for _, link := range links {
|
||
anchor := strings.TrimSpace(link.AnchorText)
|
||
if anchor == "" {
|
||
anchor = strings.TrimSpace(link.TargetSlug)
|
||
}
|
||
if anchor == "" {
|
||
continue
|
||
}
|
||
b.WriteString(fmt.Sprintf("<li><a href=\"/%s/\">%s</a></li>", link.TargetSlug, anchor))
|
||
}
|
||
b.WriteString("</ul>\n<!-- /wp:list -->")
|
||
return b.String()
|
||
}
|
||
|
||
// normalizeText normalizes text for comparison (handles whitespace, HTML entities)
|
||
func normalizeText(text string) string {
|
||
// Normalize whitespace
|
||
text = regexp.MustCompile(`\s+`).ReplaceAllString(text, " ")
|
||
text = strings.TrimSpace(text)
|
||
|
||
// Decode common HTML entities
|
||
text = strings.ReplaceAll(text, " ", " ")
|
||
text = strings.ReplaceAll(text, "&", "&")
|
||
text = strings.ReplaceAll(text, "<", "<")
|
||
text = strings.ReplaceAll(text, ">", ">")
|
||
text = strings.ReplaceAll(text, """, "\"")
|
||
text = strings.ReplaceAll(text, "'", "'")
|
||
|
||
return text
|
||
}
|
||
|
||
// truncate truncates a string to max length
|
||
func truncate(s string, max int) string {
|
||
if len(s) <= max {
|
||
return s
|
||
}
|
||
return s[:max] + "..."
|
||
}
|