diff --git a/.gitignore b/.gitignore index 9ab0062..96fac67 100644 --- a/.gitignore +++ b/.gitignore @@ -46,3 +46,7 @@ configs/web.yaml # Ansible DKIM keys (contain private keys) ansible/dkim_keys/ + +# Agent state +.nemo/ +ansible/inventory/hosts.yml diff --git a/CHANGELOG.md b/CHANGELOG.md index e0c23db..7946231 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,9 +6,18 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] +- builder: replaceTextVars now supports dotted placeholder paths (e.g. {{.User.Email}}) and optional whitespace inside braces ### Added - Tests: additional `dnssync` plan cases — DKIM skipped on empty `DNSRecord`, default selector `mail`, and quoted/whitespace-equivalent values treated as noop +- Unit test `TestStripDarkModeCSS` covering wrapper removal, custom dark-mode preservation, and no-op cases + +### Changed +- Extract duplicated dark-mode strip regex from email builder preview views into shared `preview-utils.js` helper + +### Fixed +- Email builder preview: `replaceHrefVars` now requires a whitespace boundary so it no longer rewrites `data-href` or `xlink:href` attributes +- Email builder preview: defer initial render until DOMContentLoaded so `SendryPreview` helper is loaded before use (avoids race with layout scripts) ## [0.4.17] - 2026-04-17 diff --git a/internal/web/handlers/templates.go b/internal/web/handlers/templates.go index 802ae45..c22d830 100644 --- a/internal/web/handlers/templates.go +++ b/internal/web/handlers/templates.go @@ -471,6 +471,8 @@ func (h *Handlers) TemplatePreview(w http.ResponseWriter, r *http.Request) { } } + html = stripDarkModeCSS(html) + data := map[string]any{ "Title": "Preview: " + t.Name, "Active": "templates", @@ -483,6 +485,17 @@ func (h *Handlers) TemplatePreview(w http.ResponseWriter, r *http.Request) { h.render(w, "template_preview", data) } +var darkModeBlockRe = regexp.MustCompile(`(?s)@media\s*\(prefers-color-scheme:\s*dark\)\s*\{.*?\}\s*\}`) + +func stripDarkModeCSS(html string) string { + return darkModeBlockRe.ReplaceAllStringFunc(html, func(m string) string { + if strings.Contains(m, ".force-page-bg") { + return "" + } + return m + }) +} + // TemplateExportData represents template data for export/import type TemplateExportData struct { Name string `json:"name"` diff --git a/internal/web/handlers/templates_test.go b/internal/web/handlers/templates_test.go index 4aadf0c..e8a09e3 100644 --- a/internal/web/handlers/templates_test.go +++ b/internal/web/handlers/templates_test.go @@ -1,6 +1,9 @@ package handlers -import "testing" +import ( + "strings" + "testing" +) func TestConvertToGoTemplate(t *testing.T) { tests := []struct { @@ -89,3 +92,61 @@ func TestConvertToGoTemplate(t *testing.T) { }) } } + +func TestStripDarkModeCSS(t *testing.T) { + wrapperBlock := `@media (prefers-color-scheme: dark) { + body, .force-page-bg { + background-color: #1C1C1E !important; + } + .email-container { + background-color: #2C2C2E !important; + } + }` + + tests := []struct { + name string + input string + wantContain []string + wantMissing []string + }{ + { + name: "removes wrapper dark-mode block", + input: "", + wantContain: []string{".a{color:red;}", ".b{color:blue;}"}, + wantMissing: []string{"force-page-bg", "prefers-color-scheme"}, + }, + { + name: "preserves other dark-mode blocks without wrapper marker", + input: ``, + wantContain: []string{"prefers-color-scheme", ".custom-block"}, + }, + { + name: "no dark-mode block leaves html untouched", + input: "", + wantContain: []string{""}, + }, + { + name: "empty input", + input: "", + wantContain: []string{}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := stripDarkModeCSS(tt.input) + for _, s := range tt.wantContain { + if !strings.Contains(got, s) { + t.Errorf("expected result to contain %q, got %q", s, got) + } + } + for _, s := range tt.wantMissing { + if strings.Contains(got, s) { + t.Errorf("expected result to NOT contain %q, got %q", s, got) + } + } + }) + } +} diff --git a/internal/web/static/js/builder.js b/internal/web/static/js/builder.js index a8bc06d..026c457 100644 --- a/internal/web/static/js/builder.js +++ b/internal/web/static/js/builder.js @@ -375,8 +375,18 @@ }); } + function replaceHrefVars(html) { + // Only match a standalone `href` attribute (preceded by whitespace inside + // a tag), not longer attribute names like `data-href` or `xlink:href`. + return html.replace(/(\s)href\s*=\s*(['"])\s*\{\{\s*\.\w+(?:\.\w+)*\s*\}\}\s*\2/gi, '$1href=$2#$2'); + } + function replaceTextVars(html) { - return html.replace(/\{\{\.(\w+)\}\}/g, function(match, varName) { + // Support nested/dotted paths like {{.User.Email}} and optional + // internal whitespace like {{ .Name }}. Tags are skipped to avoid + // wrapping placeholders that live inside attributes. + return html.replace(/<[^>]*>|\{\{\s*\.(\w+(?:\.\w+)*)\s*\}\}/g, function(match, varName) { + if (match[0] === '<') return match; // a tag — leave untouched return '' + varName + ''; }); } @@ -407,6 +417,7 @@ function previewHTML(html) { html = applyTestValues(html); html = replaceImageVars(html); + html = replaceHrefVars(html); html = replaceTextVars(html); return html; } @@ -432,39 +443,87 @@ }, true); } + function bindEditable(el, original, item, previewEl, srcIndex) { + el.setAttribute('contenteditable', 'true'); + el.setAttribute('spellcheck', 'false'); + el.dataset.originalText = original; + if (srcIndex !== undefined && srcIndex >= 0) el.dataset.sourceIndex = String(srcIndex); + el.addEventListener('mousedown', function(e) { e.stopPropagation(); }); + el.addEventListener('focus', function() { + var blockEl = el.closest('.builder-block'); + if (blockEl) blockEl.draggable = false; + }); + el.addEventListener('input', function() { scheduleInlineSave(item, previewEl); }); + el.addEventListener('blur', function() { + var blockEl = el.closest('.builder-block'); + if (blockEl) blockEl.draggable = true; + scheduleInlineSave(item, previewEl, true); + }); + el.addEventListener('keydown', function(e) { + if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); el.blur(); } + }); + } + function enableInlineEdit(previewEl, item) { + var cursor = 0; + var locate = function(text) { + var at = item.html.indexOf(text, cursor); + if (at === -1) return -1; + cursor = at + text.length; + return at; + }; previewEl.querySelectorAll('td, th, span, p, h1, h2, h3, h4, h5, h6, li, a, b, strong, i, em').forEach(function(node) { if (node.dataset && node.dataset.placeholder) return; + if (node.dataset && node.dataset.inlineText) return; + if (node.dataset && node.dataset.inlineDone) return; + // Skip nodes whose ancestor was already turned into a contenteditable + // region (e.g. an inside a ): making a descendant editable + // too would create overlapping edit zones and break source-index + // mapping during saveInlineEdit. + if (node.parentNode && node.parentNode.closest && node.parentNode.closest('[contenteditable="true"]')) return; + var mixed = false; var blocksParent = false; for (var c = 0; c < node.children.length; c++) { var ch = node.children[c]; if (ch.tagName === 'BR') continue; + if (ch.tagName === 'IMG' || ch.tagName === 'A') { mixed = true; continue; } if (ch.dataset && ch.dataset.placeholder) continue; blocksParent = true; break; } if (blocksParent) return; + + if (mixed) { + Array.prototype.slice.call(node.childNodes).forEach(function(ch) { + if (ch.nodeType === 3) { + var raw = ch.nodeValue; + var trimmed = raw && raw.trim(); + if (!trimmed) return; + var at = locate(trimmed); + if (at === -1) return; + var span = document.createElement('span'); + span.dataset.inlineText = '1'; + span.textContent = raw; + ch.parentNode.replaceChild(span, ch); + bindEditable(span, trimmed, item, previewEl, at); + } else if (ch.nodeType === 1 && ch.tagName === 'A') { + var aText = canonicalText(ch).trim(); + if (!aText) return; + var aAt = locate(aText); + if (aAt === -1) return; + bindEditable(ch, aText, item, previewEl, aAt); + ch.dataset.inlineDone = '1'; + } + }); + return; + } + var canonical = canonicalText(node); var trimmed = canonical.trim(); if (!trimmed) return; - if (item.html.indexOf(trimmed) === -1) return; - node.setAttribute('contenteditable', 'true'); - node.setAttribute('spellcheck', 'false'); - node.dataset.originalText = trimmed; - node.addEventListener('mousedown', function(e) { e.stopPropagation(); }); - node.addEventListener('focus', function() { - var blockEl = node.closest('.builder-block'); - if (blockEl) blockEl.draggable = false; - }); - node.addEventListener('input', function() { scheduleInlineSave(item, previewEl); }); - node.addEventListener('blur', function() { - var blockEl = node.closest('.builder-block'); - if (blockEl) blockEl.draggable = true; - scheduleInlineSave(item, previewEl, true); - }); - node.addEventListener('keydown', function(e) { - if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); node.blur(); } - }); + var at = locate(trimmed); + if (at === -1) return; + bindEditable(node, trimmed, item, previewEl, at); }); } @@ -479,6 +538,8 @@ var newSourceHTML = item.html; var changes = 0; var diagnostics = []; + + var edits = []; previewEl.querySelectorAll('[contenteditable="true"]').forEach(function(node) { var original = node.dataset.originalText; var current = canonicalText(node).trim(); @@ -489,15 +550,41 @@ diagnostics.push('placeholder count mismatch in ' + JSON.stringify(original.slice(0, 30))); return; } - var idx = newSourceHTML.indexOf(original); + var idx = -1; + if (node.dataset.sourceIndex !== undefined) { + var si = parseInt(node.dataset.sourceIndex, 10); + if (si >= 0 && item.html.substr(si, original.length) === original) idx = si; + } + if (idx === -1) idx = item.html.indexOf(original); if (idx === -1) { diagnostics.push('original text not found in source: ' + JSON.stringify(original.slice(0, 30))); return; } - newSourceHTML = newSourceHTML.slice(0, idx) + current + newSourceHTML.slice(idx + original.length); - node.dataset.originalText = current; + edits.push({ idx: idx, original: original, current: current, node: node }); + }); + + edits.sort(function(a, b) { return a.idx - b.idx; }); + var offset = 0; + edits.forEach(function(e) { + var pos = e.idx + offset; + newSourceHTML = newSourceHTML.slice(0, pos) + e.current + newSourceHTML.slice(pos + e.original.length); + offset += e.current.length - e.original.length; + e.node.dataset.originalText = e.current; changes++; }); + + if (changes > 0) { + var recur = 0; + previewEl.querySelectorAll('[contenteditable="true"]').forEach(function(node) { + if (node.dataset.sourceIndex === undefined) return; + var t = node.dataset.originalText; + if (t === undefined) return; + var at = newSourceHTML.indexOf(t, recur); + if (at === -1) return; + node.dataset.sourceIndex = String(at); + recur = at + t.length; + }); + } console.log('[inline-edit] save called', { blockId: item.blockId, sourceLen: item.html.length, diff --git a/internal/web/static/js/preview-utils.js b/internal/web/static/js/preview-utils.js new file mode 100644 index 0000000..5353854 --- /dev/null +++ b/internal/web/static/js/preview-utils.js @@ -0,0 +1,18 @@ +// Shared preview helpers for the email builder. +// Strips only the wrapper's dark-mode block from preview HTML so previews +// stay readable on a forced white background. Wrapper block is identified +// by the .force-page-bg selector; block/template-specific dark-mode CSS +// is preserved. Keep this in sync with internal/web/handlers/templates.go +// (stripDarkModeCSS) — both must match the wrapper CSS in +// internal/web/blocks/wrapper.html. +(function(global) { + var darkBlockRe = /@media\s*\(prefers-color-scheme:\s*dark\)\s*\{[\s\S]*?\}\s*\}/g; + function stripWrapperDarkModeCSS(html) { + if (typeof html !== 'string' || html.indexOf('@media') === -1) return html; + return html.replace(darkBlockRe, function(m) { + return m.indexOf('.force-page-bg') !== -1 ? '' : m; + }); + } + global.SendryPreview = global.SendryPreview || {}; + global.SendryPreview.stripWrapperDarkModeCSS = stripWrapperDarkModeCSS; +})(window); diff --git a/internal/web/views/block_form.html b/internal/web/views/block_form.html index 98f6f9f..75a1b76 100644 --- a/internal/web/views/block_form.html +++ b/internal/web/views/block_form.html @@ -321,6 +321,7 @@

Template Variables

if (!r.ok) return r.text().then(function(t) { throw new Error(t); }); return r.text(); }).then(function(html) { + html = SendryPreview.stripWrapperDarkModeCSS(html); frame.srcdoc = html; status.textContent = 'OK — block rendered with the JSON above.'; status.style.color = 'var(--text-muted)'; @@ -331,7 +332,11 @@

Template Variables

} btn.addEventListener('click', render); - render(); + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', render); + } else { + render(); + } var shell = document.getElementById('livepreview-shell'); document.querySelectorAll('[data-livepreview-width]').forEach(function(b) { b.addEventListener('click', function() { diff --git a/internal/web/views/block_view.html b/internal/web/views/block_view.html index 5318bb7..5c39a75 100644 --- a/internal/web/views/block_view.html +++ b/internal/web/views/block_view.html @@ -171,6 +171,7 @@

Appearance

if (!r.ok) return r.text().then(function(t) { throw new Error(t); }); return r.text(); }).then(function(html) { + html = SendryPreview.stripWrapperDarkModeCSS(html); frame.srcdoc = html; status.textContent = 'OK'; status.style.color = 'var(--text-muted)'; @@ -180,7 +181,11 @@

Appearance

}); } - render(); + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', render); + } else { + render(); + } // Appearance inputs: debounced auto-save to /blocks/{id}/appearance, // re-render preview on success so radius/padding changes show up diff --git a/internal/web/views/layout.html b/internal/web/views/layout.html index 9071b41..c3b521a 100644 --- a/internal/web/views/layout.html +++ b/internal/web/views/layout.html @@ -63,6 +63,7 @@ +