chore: migrate mail themes to shared framework and locked upstream inputs
Release / Validate Templates (push) Canceled after 0s
Release / Package & Release (push) Canceled after 0s
Release / Update Latest Release Documentation (push) Canceled after 0s

This commit is contained in:
KenanZhu committed 2026-10-09 18:34:36 +08:00
1 parent 5c0589f6f6
commit fec3ace600
225 files changed
+4718 -15807

No files matched your search

+121 -50
View File
@@ -239,6 +239,11 @@ html,body,#preview-area,#panel-body{
<select id="sel-template" onchange="switchTemplate(this.value)"></select>
</span>
<span class="sep"></span>
<span class="ctl-group">
<label>Language</label>
<select id="sel-locale" onchange="switchLocale(this.value)"></select>
</span>
<span class="sep"></span>
<span class="ctl-group">
<label>View</label>
<select id="sel-client" onchange="switchClient(this.value)">
@@ -323,10 +328,14 @@ html,body,#preview-area,#panel-body{
//
// State — REGISTRY and PARAMS are loaded from rendered.js (single source of truth)
//
const state = { theme:'', template:'', client:'modern', viewport:'desktop' };
const state = { theme:'', template:'', client:'modern', viewport:'desktop', locale:'en-US' };
let REGISTRY = {};
let PARAMS = {};
let rendered = null; // window.__RENDERED__
let rendered = null;
let manifest = null;
let localeRequest = 0;
let renderRequest = 0, renderTimer;
const localeLoads = new Map();
let devMode = false; // true once SSE connects
//
@@ -338,20 +347,31 @@ function transform(html, mode) {
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 html;
// Gitea sends status icons as CID attachments. Preview has no MIME message,
// so display equivalent local SVG icons without changing install templates.
const doc = new DOMParser().parseFromString(html, 'text/html');
// Static preview has no deployed instance; use the generated official asset.
// Installable mail templates keep the instance's {{AppUrl}} image URL.
doc.querySelectorAll('img.email-logo').forEach(img => { if(manifest?.logo) img.src=manifest.logo; });
doc.querySelectorAll('img[src^="cid:"]').forEach(img => {
const color = img.classList.contains('status-success') ? '#2da44e' : '#697077';
const svg = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 18 18"><circle cx="9" cy="9" r="7" fill="'+color+'"/><path d="M5 9l3 3 5-6" fill="none" stroke="white" stroke-width="2"/></svg>';
img.src = 'data:image/svg+xml,' + encodeURIComponent(svg);
});
return '<!DOCTYPE html>' + doc.documentElement.outerHTML;
}
//
// Render preview
//
function render() {
const request=++renderRequest;
clearTimeout(renderTimer);
const frame = document.getElementById('preview-frame');
const loading = document.getElementById('loading');
if(!rendered) return;
if (location.protocol !== 'file:') {
frame.setAttribute('sandbox', '');
}
frame.setAttribute('sandbox', '');
frame.style.display = 'none';
loading.style.display = 'flex';
@@ -363,14 +383,15 @@ function render() {
let done = false;
function finish() {
if (done) return; done = true;
if (done || request!==renderRequest) return; done = true;
clearTimeout(renderTimer);
loading.style.display = 'none';
frame.style.display = 'block';
frame.onload = null;
}
frame.onload = finish;
frame.srcdoc = content;
setTimeout(finish, 5000);
renderTimer=setTimeout(finish, 5000);
updatePanel();
}
@@ -391,10 +412,11 @@ function toggleSection(id) {
// Panel content updates
//
function updatePanel() {
const escape = v => String(v).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
const r = REGISTRY[state.template];
document.getElementById('panel-name').textContent = r ? r.name : state.template;
document.getElementById('panel-desc').textContent = r ? r.desc : '';
document.getElementById('panel-path').textContent = r ? 'themes/'+state.theme+'/'+r.path : '-';
document.getElementById('panel-path').textContent = r ? 'build/themes/'+state.theme+'/'+r.path+' (official snapshot + theme overrides)' : '-';
// Template stats
const themes = rendered ? Object.keys(rendered).length : 0;
@@ -407,13 +429,13 @@ function updatePanel() {
}
}
document.getElementById('panel-stats').textContent =
themes + ' themes loaded · ' + totalTemplates + ' templates (' + (totalTemplates - totalFailed) + ' ok, ' + totalFailed + ' failed)';
manifest.upstream + ' · ' + state.locale + ' · ' + themes + ' themes · ' + totalTemplates + ' templates (' + (totalTemplates - totalFailed) + ' ok, ' + totalFailed + ' failed)';
const p = PARAMS[state.template] || {};
let rows = '';
for(const [k,v] of Object.entries(p)){
const isReq = !k.includes('RenderedNote') && !k.includes('Attachments') && !k.includes('DisableDownload') && !k.includes('IsMention') && !k.includes('IsPull') && !k.includes('IsForcePush') && !k.includes('ReviewComments') && !k.includes('Commits') && !k.includes('BaseBranch');
rows += '<tr><td>.'+k+'</td><td>'+String(v)+'</td><td><span class="badge '+(isReq?'badge-req':'badge-opt')+'">'+(isReq?'required':'optional')+'</span></td></tr>';
rows += '<tr><td>.'+escape(k)+'</td><td>'+escape(v)+'</td><td><span class="badge '+(isReq?'badge-req':'badge-opt')+'">'+(isReq?'required':'optional')+'</span></td></tr>';
}
document.getElementById('panel-params').innerHTML = rows || '<tr><td colspan="3" style="color:var(--muted)">-</td></tr>';
}
@@ -422,7 +444,7 @@ function updatePanel() {
// Events
//
function switchTheme(v) { state.theme=v; render(); }
function switchTemplate(v) { state.template=v; render(); }
function switchTemplate(v) { state.template=v; switchLocale(state.locale); }
function switchClient(v) { state.client=v; render(); }
function setViewport(v) {
state.viewport=v;
@@ -443,7 +465,7 @@ document.addEventListener('keydown',e=>{
if(e.key==='p'&&!e.ctrlKey&&!e.metaKey&&!e.altKey){ togglePanel(); return; }
// ← → cycle focus between Theme / Template / Client selects
const selects=['sel-theme','sel-template','sel-client'];
const selects=['sel-theme','sel-template','sel-locale','sel-client'];
const activeId=document.activeElement?.id;
const cur=selects.indexOf(activeId);
@@ -460,38 +482,85 @@ document.addEventListener('keydown',e=>{
});
//
// Init — load everything from rendered.js (single source of truth)
//
(function(){
if(window.__REGISTRY__) REGISTRY = window.__REGISTRY__;
if(window.__PARAMS__) PARAMS = window.__PARAMS__;
if(window.__RENDERED__){
rendered = window.__RENDERED__;
const themeKeys = Object.keys(rendered).sort();
const selTheme = document.getElementById('sel-theme');
themeKeys.forEach(t => {
const o = document.createElement('option');
o.value = t;
o.textContent = t.charAt(0).toUpperCase() + t.slice(1);
selTheme.appendChild(o);
});
state.theme = themeKeys[0] || '';
const selTpl = document.getElementById('sel-template');
for(const [id, t] of Object.entries(REGISTRY)){
const o = document.createElement('option');
o.value = id;
o.textContent = t.name;
selTpl.appendChild(o);
}
state.template = Object.keys(REGISTRY)[0] || '';
} else {
document.getElementById('loading').style.display='none';
showMessage('rendered.js not found — run <code>cd tools &amp;&amp; go run . preview all</code> to generate', 'error');
// Manifest and language bundles
function fillSelect(id, entries, selected) {
const select = document.getElementById(id);
select.replaceChildren();
for (const [value,label] of entries) {
const option = document.createElement('option');
option.value=value; option.textContent=label; select.appendChild(option);
}
select.value=selected;
}
function applyManifest(data) {
if (!data || data.schema !== 1 || !data.languages.length || !data.themes.length) {
throw new Error('Invalid preview manifest; regenerate with go run . preview all');
}
manifest=data; REGISTRY=data.registry; PARAMS=data.params;
if (!data.themes.includes(state.theme)) state.theme=data.themes[0];
if (!REGISTRY[state.template]) state.template=Object.keys(REGISTRY)[0];
if (!data.languages.includes(state.locale)) state.locale=data.languages.includes('en-US') ? 'en-US' : data.languages[0];
fillSelect('sel-theme',data.themes.map(t=>[t,t.charAt(0).toUpperCase()+t.slice(1)]),state.theme);
fillSelect('sel-template',Object.entries(REGISTRY).map(([id,t])=>[id,t.name]),state.template);
let names; try { names=new Intl.DisplayNames([navigator.language],{type:'language'}); } catch (_) {}
fillSelect('sel-locale',data.languages.map(l=>[l,names ? l+' — '+names.of(l) : l]),state.locale);
}
function loadLocale(lang) {
if (window.__MAIL_PREVIEW_DATA__?.[lang]) return Promise.resolve(window.__MAIL_PREVIEW_DATA__[lang]);
if (localeLoads.has(lang)) return localeLoads.get(lang);
const promise=new Promise((resolve,reject)=>{
const script=document.createElement('script');
script.src='rendered/'+encodeURIComponent(lang)+'.js'+(devMode ? '?v='+Date.now() : '');
script.onload=()=>{
script.remove();
const data=window.__MAIL_PREVIEW_DATA__?.[lang];
if (data) resolve(data); else reject(new Error('Invalid language bundle '+lang));
};
script.onerror=()=>{ script.remove(); reject(new Error('Language bundle missing: '+lang+'; regenerate preview data')); };
document.head.appendChild(script);
});
localeLoads.set(lang,promise);
promise.catch(()=>localeLoads.delete(lang));
return promise;
}
async function switchLocale(lang) {
if (!manifest?.languages.includes(lang)) return;
state.locale=lang; rendered=null;
const request=++localeRequest;
document.getElementById('preview-frame').style.display='none';
document.getElementById('loading').style.display='flex';
try {
const data=await loadLocale(lang);
if (request!==localeRequest) return;
rendered=data;
try { localStorage.setItem('mail-preview-locale',lang); } catch (_) {}
document.getElementById('locale-message')?.remove();
const warnings=manifest.warnings?.[lang]?.[state.template] || [];
const fallback=manifest.fallback?.[lang] || [];
if (fallback.length || warnings.length) {
const el=showMessage('',warnings.length ? 'warn' : 'info'); el.id='locale-message';
el.textContent=warnings.join(' ') || '[FALLBACK] '+lang+': '+fallback.length+' referenced translation keys use en-US';
}
render();
} catch (error) {
if (request!==localeRequest) return;
document.getElementById('loading').style.display='none';
showMessage('', 'error').textContent = String(error);
}
}
(function(){
try {
try { state.locale=localStorage.getItem('mail-preview-locale') || 'en-US'; } catch (_) {}
applyManifest(window.__MAIL_PREVIEW__);
switchLocale(state.locale);
} catch (error) {
document.getElementById('loading').style.display='none';
showMessage('Preview data unavailable — run <code>cd tools &amp;&amp; go run . preview all</code> to generate','error');
}
render();
})();
// Message helpers
@@ -512,7 +581,7 @@ function setDevMode(on) {
if (on) {
clearMessages();
showMessage(
'Live reload active — changes to .tmpl files will auto-refresh the preview.',
'Live reload active — theme CSS, shared controls, lock/cache and fixtures auto-refresh the preview.',
'info'
).id = 'msg-dev-disclaimer';
}
@@ -520,6 +589,7 @@ function setDevMode(on) {
// Dev-server live-reload (SSE)
(function(){
if (location.protocol === 'file:') return;
let es;
try {
es = new EventSource('/events');
@@ -533,14 +603,15 @@ function setDevMode(on) {
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();
script.remove();
window.__MAIL_PREVIEW_DATA__ = {};
localeLoads.clear();
applyManifest(window.__MAIL_PREVIEW__);
switchLocale(state.locale);
};
document.head.appendChild(script);
} else if (msg.type === 'error') {
showMessage(msg.message, 'error');
showMessage('', 'error').textContent = msg.message;
}
} catch(_) {}
};