fix(preview): static-notice, simplify transform, HTML-safe rendering

- Add static-notice banner: 'Gmail/Outlook simulation is approximate.
  Use dev mode for accurate rendering.' Hidden in dev mode via
  WebSocket try/catch, auto-shown in file:// mode on WS failure.
- Simplify transform(): Gmail/Outlook modes now return same HTML as
  Modern — the dev server with Juice provides actual client sim.
- Add markSafeHTML(): converts RenderedNote/Body/RenderedContent
  from plain string to template.HTML so markdown-rendered HTML
  content is not escaped by Go's html/template engine.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
KenanZhuandClaude Opus 4.8 committed 2026-06-04 13:51:43 +08:00
1 parent a0cd5e32f4
commit 13c49d2087
2 files changed
+78 -15

No files matched your search

+48 -15
View File
@@ -68,6 +68,7 @@ body{font-family:system-ui,-apple-system,sans-serif;background:var(--bg);color:v
.loading-wrap{display:flex;align-items:center;justify-content:center;height:100%;color:var(--muted);font-size:13px;flex-direction:column;gap:8px} .loading-wrap{display:flex;align-items:center;justify-content:center;height:100%;color:var(--muted);font-size:13px;flex-direction:column;gap:8px}
.spinner{width:20px;height:20px;border:2px solid var(--border);border-top-color:var(--accent);border-radius:50%;animation:spin .6s linear infinite} .spinner{width:20px;height:20px;border:2px solid var(--border);border-top-color:var(--accent);border-radius:50%;animation:spin .6s linear infinite}
@keyframes spin{to{transform:rotate(360deg)}} @keyframes spin{to{transform:rotate(360deg)}}
#static-notice{display:none;background:rgba(251,191,36,0.08);border-bottom:1px solid rgba(251,191,36,0.15);padding:6px 16px;font-size:11px;color:rgba(251,191,36,0.7);text-align:center;flex-shrink:0}
#panel::-webkit-scrollbar{width:4px} #panel::-webkit-scrollbar{width:4px}
#panel::-webkit-scrollbar-track{background:transparent} #panel::-webkit-scrollbar-track{background:transparent}
@@ -100,6 +101,8 @@ body{font-family:system-ui,-apple-system,sans-serif;background:var(--bg);color:v
<span class="hint">keys: d/m &nbsp; &larr;&rarr;</span> <span class="hint">keys: d/m &nbsp; &larr;&rarr;</span>
</nav> </nav>
<div id="static-notice">Gmail / Outlook simulation is approximate. For accurate rendering with CSS inlining, use <code style="background:rgba(251,191,36,0.1);padding:1px 6px;border-radius:3px;">cd tools && go run . dev</code></div>
<div id="main"> <div id="main">
<div id="preview-area"> <div id="preview-area">
<div id="viewport" class="desktop"> <div id="viewport" class="desktop">
@@ -153,25 +156,14 @@ let rendered = null; // window.__RENDERED__
// Client transforms // Client transforms
// //
function transform(html, mode) { function transform(html, mode) {
if(mode==='modern') return html;
if(mode==='raw') { if(mode==='raw') {
const e = html.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;'); const e = html.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
return '<!DOCTYPE html><html><head><style>body{background:#0d1117;color:#c9d1d9;font-family:Consolas,monospace;padding:16px;margin:0}pre{white-space:pre-wrap;word-break:break-all;font-size:11px;line-height:1.6;margin:0}</style></head><body><pre>'+e+'</pre></body></html>'; return '<!DOCTYPE html><html><head><style>body{background:#0d1117;color:#c9d1d9;font-family:Consolas,monospace;padding:16px;margin:0}pre{white-space:pre-wrap;word-break:break-all;font-size:11px;line-height:1.6;margin:0}</style></head><body><pre>'+e+'</pre></body></html>';
} }
let out = html.replace(/<style[^>]*>[\s\S]*?<\/style>/gi, ''); // Modern, Gmail, Outlook — all render the same raw HTML.
if(mode==='gmail') { // Use the dev server (go run . dev) with Juice CSS inlining
out = out.replace(/font-family:'Poppins'/gi,'font-family:Arial,sans-serif'); // for accurate email client simulation.
out = out.replace(/font-family:'JetBrains Mono'/gi,'font-family:Consolas,monospace'); return html;
}
if(mode==='outlook') {
const strip=[/border-radius:[^;"']+/gi,/box-shadow:[^;"']+/gi,/linear-gradient\([^)]*\)/gi,/transition:[^;"']+/gi,/letter-spacing:[^;"']+/gi,/font-variant:small-caps/gi,/-webkit-background-clip:text/gi,/-webkit-text-fill-color:transparent/gi,/overflow:hidden/gi];
strip.forEach(r=>{out=out.replace(r,'')});
out=out.replace(/background:linear-gradient\([^)]*\)/gi,'background:#f0f0f0');
out=out.replace(/style="[^"]*"/g,m=>m.replace(/;\s*;/g,';'));
out=out.replace(/font-family:Georgia/gi,'font-family:Georgia,serif');
out=out.replace(/font-family:'Charter'/gi,'font-family:Georgia,serif');
}
return out;
} }
// //
@@ -303,6 +295,47 @@ document.addEventListener('keydown',e=>{
} }
render(); render();
})(); })();
// ── Dev-server live-reload (WebSocket) ──
(function(){
const notice = document.getElementById('static-notice');
let ws;
try {
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
ws = new WebSocket(proto + '//' + location.host + '/ws');
} catch(e) {
// static mode (file://) — WebSocket constructor throws
if (notice) notice.style.display = '';
return;
}
ws.onmessage = function(e) {
try {
const msg = JSON.parse(e.data);
if (msg.type === 'reload') {
const script = document.createElement('script');
script.src = 'rendered.js?v=' + Date.now();
script.onload = function() {
if (window.__RENDERED__) rendered = window.__RENDERED__;
if (window.__REGISTRY__) REGISTRY = window.__REGISTRY__;
if (window.__PARAMS__) PARAMS = window.__PARAMS__;
render();
};
document.head.appendChild(script);
}
} catch(_) {}
};
ws.onopen = function() {
if (notice) notice.style.display = 'none';
console.log('[dev] live-reload connected');
};
ws.onclose = function() {
if (notice) notice.style.display = '';
console.log('[dev] live-reload disconnected');
};
ws.onerror = function() {
if (notice) notice.style.display = '';
};
})();
</script> </script>
</body> </body>
</html> </html>
+30
View File
@@ -165,6 +165,11 @@ func renderTheme(themeDir, themeName string, cfg *config.TemplatesConfig) ThemeS
} }
data["locale"] = Locale{} data["locale"] = Locale{}
// Convert HTML-safe fields from string to template.HTML so Go's
// html/template renders them raw instead of escaping. Matches Gitea's
// behavior where RenderedNote / Body / RenderedContent are template.HTML.
markSafeHTML(data)
// Parse and execute // Parse and execute
tmpl, err := template.New(tplID).Funcs(TemplateFuncs).Parse(string(tmplContent)) tmpl, err := template.New(tplID).Funcs(TemplateFuncs).Parse(string(tmplContent))
if err != nil { if err != nil {
@@ -276,6 +281,31 @@ func PrintDetailedSummary(result *PreviewResult, themesDir string, cfg *config.T
fmt.Println() fmt.Println()
} }
// markSafeHTML recursively converts known HTML-containing string fields to
// template.HTML so Go's html/template renders them raw instead of escaping.
// These fields mirror Gitea's data model where rendered markdown is typed as
// template.HTML: Release.RenderedNote, Body (comment content), and
// ReviewComments[].RenderedContent.
func markSafeHTML(data map[string]any) {
for k, v := range data {
switch val := v.(type) {
case string:
if k == "RenderedNote" || k == "Body" || k == "RenderedContent" {
data[k] = template.HTML(val)
}
case map[string]any:
markSafeHTML(val)
case []any:
for i, item := range val {
if m, ok := item.(map[string]any); ok {
markSafeHTML(m)
val[i] = m
}
}
}
}
}
func formatSize(bytes int64) string { func formatSize(bytes int64) string {
const unit = 1024 const unit = 1024
if bytes < unit { if bytes < unit {