Files
GiteaMailTemplates/tools/server/inliner.mjs
T
KenanZhu d8a714b2b5 feat(preview): cross-browser scrollbar, keyboard nav, dev-mode client simulation, docs overhaul
preview/index.html:
- Fix scrollbar CSS: scoped webkit pseudo-elements + standards-track fallback, removed deprecated overflow:overlay
- Keyboard navigation: arrows cycle focus between Theme/Template/Client selects, up/down select within focused dropdown, d/m toggle viewport
- Dev/static mode detection: delayed static warning only when WebSocket absent, WebSocket errors only on disconnect (not on initial fail)
- iframe sandbox: removed static sandbox attr, applied dynamically only in dev mode (file: protocol rejects sandboxed srcdoc)
- Loading fix: removed double-toggle between init and render(); added 5s safety timeout
- Transform clean: returns HTML as-is; CSS stripping moved to server-side
- Dev disclaimer: blue info banner shown on WebSocket connect

tools/server/inliner.mjs:
- Added stripGmail() / stripOutlook() — server-side CSS property stripping for email client simulation

tools/server/server.mjs:
- Fixed WebSocket upgrade: use app.listen() instead of createServer(app).listen()
- Juice post-processing now generates three rendered.js variants (modern/gmail/outlook)
- Initial startup runs juice-only pass (avoids duplicate Go compilation)

docs/ (all 6 languages — en, zh-CN, zh-TW, ja, ko, ru):
- Image size limits: max 50KiB, recommended 10-20KiB
- Dev disclaimer: simulation cannot 100% reproduce every client
- Changed 'accurate' to 'relatively accurate' across all static-mode warnings
- Converted all plain blockquotes to GitHub admonitions ([!WARNING] / [!NOTE])
2026-06-04 16:01:42 +08:00

116 lines
3.7 KiB
JavaScript

import juice from 'juice';
/** Juice CSS inlining options optimized for HTML email. */
const JUICE_OPTIONS = {
removeStyleTags: false, // keep <style> for modern clients that support it
preserveMediaQueries: true,
preserveFontFaces: true,
applyWidthAttributes: true,
applyHeightAttributes: true,
applyAttributesTableElements: true,
xmlMode: false,
};
/**
* Inline CSS from <style> blocks and <link> tags into inline style attributes.
* Produces HTML that renders consistently across Gmail, Outlook, Apple Mail.
*/
export function inlineCSS(html) {
return juice(html, JUICE_OPTIONS);
}
// Gmail / Outlook CSS stripping
const GMAIL_UNSUPPORTED = [
'border-radius','box-shadow','text-shadow',
'backdrop-filter','filter',
'animation','transition','transform',
'background-image\\s*:\\s*(linear|radial|conic)-gradient',
];
const OUTLOOK_UNSUPPORTED = GMAIL_UNSUPPORTED.concat([
'display\\s*:\\s*(flex|grid|inline-flex|inline-grid)',
'flex[^;]*','grid[^;]*','gap\\s*:','order\\s*:',
]);
function stripDeclarations(styleValue, patterns) {
let v = styleValue;
for (const p of patterns) {
v = v.replace(new RegExp('(^|;\\s*)' + p + '[^;]*(;|$)', 'gi'), (m) => m.endsWith(';') ? ';' : '');
v = v.replace(new RegExp(p + '[^;]*;', 'gi'), '');
}
return v.replace(/;;+/g, ';').replace(/^\s*;\s*/, '').replace(/\s*;\s*$/, '').trim();
}
/**
* Strip CSS properties unsupported by Gmail from inline styles and <style> blocks.
*/
export function stripGmail(html) {
return stripClientCSS(html, GMAIL_UNSUPPORTED, false);
}
/**
* Strip CSS properties unsupported by Outlook (more aggressive than Gmail).
*/
export function stripOutlook(html) {
return stripClientCSS(html, OUTLOOK_UNSUPPORTED, true);
}
function stripClientCSS(html, patterns, isOutlook) {
let result = html;
// 1. Strip from inline style="..." attributes
result = result.replace(/style="([^"]*)"/gi, (_, styles) => {
const cleaned = stripDeclarations(styles, patterns);
return cleaned ? 'style="' + cleaned + '"' : '';
});
// 2. Strip from <style> blocks
result = result.replace(/<style[^>]*>([\s\S]*?)<\/style>/gi, (_, cssContent) => {
let cleaned = cssContent;
for (const p of patterns) {
const re = new RegExp(p + '[^;{}]*[;}]?', 'gi');
cleaned = cleaned.replace(re, (m) => m.endsWith('}') ? '}' : '');
}
return '<style>' + cleaned + '</style>';
});
// 3. Outlook: replace CSS custom properties with initial
if (isOutlook) {
result = result.replace(/style="([^"]*)"/gi,
(_, s) => 'style="' + s.replace(/var\(--[^)]*\)/g, 'initial') + '"');
}
return result;
}
/**
* Apply juice inlining to a rendered.js-style HTML snippet and return the
* result. Handles escaped HTML (JSON-stringified) by first unescaping,
* inlining, then re-escaping for storage in rendered.js.
*/
export function inlineRenderedHTML(rawhtml) {
try {
// If this is already a standalone HTML document fragment
if (rawhtml.includes('<style') || rawhtml.includes('</style>')) {
return inlineCSS(rawhtml);
}
// Otherwise pass through unchanged (inline-only content)
return rawhtml;
} catch (err) {
console.error('Juice inline error:', err.message);
return rawhtml;
}
}
if (process.argv[1] && import.meta.url.endsWith(process.argv[1])) {
// CLI mode: juice <input> [output]
const { readFileSync, writeFileSync } = await import('fs');
const input = process.argv[2];
const output = process.argv[3];
if (!input) { console.error('Usage: node inliner.mjs <input.html> [output.html]'); process.exit(1); }
const html = readFileSync(input, 'utf8');
const result = inlineCSS(html);
if (output) { writeFileSync(output, result, 'utf8'); console.log(`Inlined → ${output}`); }
else { console.log(result); }
}