1use std::collections::HashMap;
2use std::fs;
3use std::path::Path;
4
5use crate::i18n::I18n;
6
7pub type CookieMap = HashMap<String, String>;
8
9pub struct PrintfileData<'a> {
12 pub label: &'a str,
14 pub subject: &'a str,
16 pub dir: &'a str,
18 pub name: Option<&'a str>,
20 pub email: Option<&'a str>,
22 pub msgid: Option<&'a str>,
24 pub charset: Option<&'a str>,
26 pub date: Option<&'a str>,
28 pub display_date: Option<&'a str>,
30 pub filename: Option<&'a str>,
32 pub archives: Option<&'a str>,
34 pub about: Option<&'a str>,
36 pub mailto: Option<&'a str>,
38 pub language: &'a str,
40 pub rel_path_to_top: &'a str,
42}
43
44const HMURL: &str = "https://www.hypermail-project.org/";
45const PROGNAME: &str = "hypermail-rs";
46const VERSION: &str = env!("CARGO_PKG_VERSION");
47
48pub fn substitute_printfile(template: &str, data: &PrintfileData<'_>) -> String {
74 let mut result = String::with_capacity(template.len() + 256);
75 let bytes = template.as_bytes();
76 let mut i = 0;
77
78 while i < bytes.len() {
79 let b = bytes[i];
80 if b == b'\\' && i + 1 < bytes.len() {
81 match bytes[i + 1] {
82 b'n' => {
83 result.push('\n');
84 i += 2;
85 },
86 b't' => {
87 result.push('\t');
88 i += 2;
89 },
90 _ => {
91 result.push(b as char);
92 i += 1;
93 },
94 }
95 } else if b == b'%' && i + 1 < bytes.len() {
96 let next = bytes[i + 1] as char;
97 match next {
98 '%' => {
99 result.push('%');
100 i += 2;
101 },
102 '~' => {
103 result.push_str(data.dir);
104 i += 2;
105 },
106 'a' => {
107 if let Some(a) = data.archives {
108 result.push_str(a);
109 }
110 i += 2;
111 },
112 'b' => {
113 if let Some(b_url) = data.about {
114 result.push_str(b_url);
115 }
116 i += 2;
117 },
118 'c' => {
119 if let Some(cs) = data.charset {
120 if !cs.is_empty() {
121 result.push_str(&format!(
122 "<meta http-equiv=\"Content-Type\" content=\"text/html; charset={}\" />\n",
123 cs
124 ));
125 }
126 }
127 i += 2;
128 },
129 'D' => {
130 if let Some(date) = data.date {
131 result.push_str(&format!("<meta name=\"Date\" content=\"{}\" />", date));
132 }
133 i += 2;
134 },
135 'e' => {
136 if let Some(email) = data.email {
137 result.push_str(email);
138 }
139 i += 2;
140 },
141 'f' => {
142 if let Some(fname) = data.filename {
143 result.push_str(fname);
144 }
145 i += 2;
146 },
147 'g' => {
148 result.push_str(&get_local_time_str());
149 i += 2;
150 },
151 'G' => {
152 result.push_str(data.language);
153 i += 2;
154 },
155 'h' => {
156 result.push_str(HMURL);
157 i += 2;
158 },
159 'i' => {
160 if let Some(msgid) = data.msgid {
161 result.push_str(msgid);
162 }
163 i += 2;
164 },
165 'l' => {
166 result.push_str(data.label);
167 i += 2;
168 },
169 'm' => {
170 if let Some(mailto) = data.mailto {
171 result.push_str(mailto);
172 }
173 i += 2;
174 },
175 'p' => {
176 result.push_str(PROGNAME);
177 i += 2;
178 },
179 's' => {
180 result.push_str(&crate::txt2html::escape_html(data.subject));
181 i += 2;
182 },
183 'S' => {
184 result.push_str(&format!(
185 "<meta name=\"Subject\" content=\"{}\" />",
186 crate::txt2html::escape_html(data.subject)
187 ));
188 i += 2;
189 },
190 'A' => {
191 if let (Some(name), Some(email)) = (data.name, data.email) {
192 result.push_str(&format!(
193 "<meta name=\"Author\" content=\"{} ({})\" />",
194 crate::txt2html::escape_html(name),
195 email
196 ));
197 }
198 i += 2;
199 },
200 'n' => {
202 if let Some(name) = data.name {
203 result.push_str(&crate::txt2html::escape_html(name));
204 }
205 i += 2;
206 },
207 'd' => {
209 if let Some(date) = data.display_date {
210 result.push_str(&crate::txt2html::escape_html(date));
211 }
212 i += 2;
213 },
214 'y' => {
216 let i18n = I18n::new(data.language);
217 result.push_str(i18n.get("Author:"));
218 i += 2;
219 },
220 'N' => {
222 match (data.name, data.email) {
223 (Some(name), Some(addr)) if !addr.is_empty() => {
224 let esc_name = crate::txt2html::escape_html(name);
225 let esc_addr = crate::txt2html::escape_html(addr);
226 result.push_str(&format!(
227 "{} <<a href=\"mailto:{}\">{}</a>>",
228 esc_name, esc_addr, esc_addr
229 ));
230 },
231 (Some(name), _) => {
232 result.push_str(&crate::txt2html::escape_html(name));
233 },
234 _ => {},
235 }
236 i += 2;
237 },
238 't' => {
239 result.push_str(data.rel_path_to_top);
240 i += 2;
241 },
242 'v' => {
243 result.push_str(VERSION);
244 i += 2;
245 },
246 'u' => {
247 result.push_str(&format!("<a href=\"{}\">{} {}</a>", HMURL, PROGNAME, VERSION));
248 i += 2;
249 },
250 _ => {
251 result.push('%');
252 result.push(next);
253 i += 2;
254 },
255 }
256 } else {
257 let ch = template[i..].chars().next().unwrap_or('\u{FFFD}');
260 result.push(ch);
261 i += ch.len_utf8();
262 }
263 }
264
265 result
266}
267
268fn get_local_time_str() -> String {
269 use std::time::{SystemTime, UNIX_EPOCH};
270 let secs = SystemTime::now()
271 .duration_since(UNIX_EPOCH)
272 .map(|d| d.as_secs() as i64)
273 .unwrap_or(0);
274 crate::date::get_date_str(secs, None, false, false, false, "en")
275}
276
277fn escape_attr(s: &str) -> String {
279 let mut result = String::with_capacity(s.len());
280 for c in s.chars() {
281 match c {
282 '&' => result.push_str("&"),
283 '"' => result.push_str("""),
284 '<' => result.push_str("<"),
285 '>' => result.push_str(">"),
286 '\'' => result.push_str("'"),
287 c => result.push(c),
288 }
289 }
290 result
291}
292
293pub fn substitute_cookies(template: &str, cookies: &CookieMap) -> String {
299 let mut result = String::with_capacity(template.len() * 2);
303 let chars: Vec<char> = template.chars().collect();
304 let mut i = 0;
305 while i < chars.len() {
306 if chars[i] == '%' {
307 let start = i + 1;
309 let mut j = start;
310 while j < chars.len() && chars[j] != '%' && chars[j] != '\n' {
311 j += 1;
312 }
313 if j < chars.len() && chars[j] == '%' && j > start {
314 let key: String = chars[start..j].iter().collect();
315 if let Some(val) = cookies.get(key.as_str()) {
316 result.push_str(val);
317 i = j + 1;
318 continue;
319 }
320 }
321 }
322 result.push(chars[i]);
323 i += 1;
324 }
325 result
326}
327
328pub fn set_cookie(cookies: &mut CookieMap, key: &str, value: &str) {
330 cookies.insert(key.to_string(), value.to_string());
331}
332
333pub fn unset_cookie(cookies: &mut CookieMap, key: &str) {
335 cookies.remove(key);
336}
337
338pub fn default_header_template() -> &'static str {
341 "<!DOCTYPE html>
342<html lang=\"%LANG%\" %HTMLATTRS%>
343<head>
344<meta charset=\"utf-8\">
345<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">
346<meta http-equiv=\"Content-Security-Policy\" content=\"default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; img-src 'self' data:; font-src 'self' https://fonts.gstatic.com; frame-src 'none'; object-src 'none'; connect-src 'self' https://fonts.googleapis.com https://fonts.gstatic.com\">
347<link rel=\"preconnect\" href=\"https://fonts.googleapis.com\">
348<link rel=\"preconnect\" href=\"https://fonts.gstatic.com\" crossorigin>
349<link rel=\"stylesheet\" href=\"https://fonts.googleapis.com/css2?family=Noto+Sans:ital,wght@0,300..900;1,300..900&display=swap\">
350<title>%TITLE%</title>
351<meta name=\"generator\" content=\"hypermail-rs\">
352%STYLESHEET%
353<style>
354:root{--font-body:\"Noto Sans\",\"Inter\",system-ui,-apple-system,BlinkMacSystemFont,\"Segoe UI\",\"Helvetica Neue\",Roboto,\"Liberation Sans\",Arial,sans-serif,\"Apple Color Emoji\",\"Segoe UI Emoji\",\"Noto Color Emoji\";--font-mono:ui-monospace,\"Cascadia Code\",\"Source Code Pro\",Menlo,Consolas,\"DejaVu Sans Mono\",\"Liberation Mono\",\"Courier New\",monospace;--font-size-sm:.85rem;--font-size-base:clamp(.95rem,1rem + .25vw,1.15rem);--font-size-lg:1rem;--line-height:1.5;--max-width:68rem;--border-radius:6px;--color-bg:#000;--color-text:#f1f5f9;--color-link:#60a5fa;--color-link-visited:#a78bfa;--color-link-hover:#93c5fd;--color-muted:#94a3b8;--color-border:#334155;--color-bg-subtle:#111827;--color-bg-code:#1e293b;--color-deleted:#f87171;--color-quote-border-1:#475569;--color-quote-border-2:#475569;--color-quote-border-3:#334155;--color-skip-bg:var(--color-link);--color-skip-text:#000}
355@media(prefers-color-scheme:light){:root:not([data-theme]){--color-bg:#fff;--color-text:#111827;--color-link:#2563eb;--color-link-visited:#7c3aed;--color-link-hover:#1d4ed8;--color-muted:#475569;--color-border:#e2e8f0;--color-bg-subtle:#f8fafc;--color-bg-code:#f1f5f9;--color-deleted:#dc2626;--color-quote-border-1:#cbd5e1;--color-quote-border-2:#94a3b8;--color-quote-border-3:#64748b;--color-skip-bg:var(--color-link);--color-skip-text:#fff}}
356[data-theme=\"light\"]{--color-bg:#fff;--color-text:#111827;--color-link:#2563eb;--color-link-visited:#7c3aed;--color-link-hover:#1d4ed8;--color-muted:#475569;--color-border:#e2e8f0;--color-bg-subtle:#f8fafc;--color-bg-code:#f1f5f9;--color-deleted:#dc2626;--color-quote-border-1:#cbd5e1;--color-quote-border-2:#94a3b8;--color-quote-border-3:#64748b;--color-skip-bg:var(--color-link);--color-skip-text:#fff}
357[data-theme=\"dark\"]{--color-bg:#000;--color-text:#f1f5f9;--color-link:#60a5fa;--color-link-visited:#a78bfa;--color-link-hover:#93c5fd;--color-muted:#94a3b8;--color-border:#334155;--color-bg-subtle:#111827;--color-bg-code:#1e293b;--color-deleted:#f87171;--color-quote-border-1:#475569;--color-quote-border-2:#475569;--color-quote-border-3:#334155;--color-skip-bg:var(--color-link);--color-skip-text:#000}
358@media(prefers-contrast:more){:root{--color-muted:var(--color-text);--color-border:var(--color-text)}[data-theme=\"light\"],:root:not([data-theme]){--color-link:inherit;--color-link-visited:inherit}}
359*,*::before,*::after{box-sizing:border-box}
360@media(prefers-reduced-motion:no-preference){html{scroll-behavior:smooth}}
361body{font-family:var(--font-body);font-size:var(--font-size-base);line-height:var(--line-height);color:var(--color-text);background:var(--color-bg);margin:0;padding:0;-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale;text-rendering:optimizeLegibility}
362main#content{max-width:var(--max-width);margin:0 auto;padding:0 1.25rem}
363a{color:var(--color-link);transition:color .15s ease}
364a:focus-visible{outline:2px solid var(--color-link);outline-offset:2px;border-radius:2px}
365a:hover{text-decoration:underline;color:var(--color-link-hover)}
366a:visited{color:var(--color-link-visited)}
367img{max-width:100%;height:auto;display:block}
368hr{border:none;border-top:1px solid var(--color-border);margin:1.25rem 0}
369pre,code{font-family:var(--font-mono);font-size:.875em;background:var(--color-bg-code);padding:.2em .4em;border-radius:var(--border-radius)}
370pre{padding:1em;overflow-x:auto;-webkit-overflow-scrolling:touch;border:1px solid var(--color-border);background:var(--color-bg-code);line-height:1.5}
371pre code{background:none;padding:0;border:none;font-size:1em}
372kbd{padding:.15em .4em;border:1px solid var(--color-border);border-radius:var(--border-radius);font-size:.875em;font-family:var(--font-mono)}
373samp,tt{font-family:var(--font-mono);font-size:.875em}
374blockquote{margin:.5rem .75rem;padding:.25rem .75rem;border-left:3px solid var(--color-quote-border-2);background:var(--color-bg-subtle);border-radius:0 var(--border-radius) var(--border-radius) 0}
375blockquote blockquote{margin-left:0}
376.hm-skip{position:absolute;top:-100px;left:0;background:var(--color-skip-bg);color:var(--color-skip-text);padding:.5rem 1rem;z-index:100;text-decoration:none;border-radius:0 0 var(--border-radius) 0}
377.hm-skip:focus{top:0;color:var(--color-skip-text)}.hm-nav{background:var(--color-bg-subtle);border-bottom:1px solid var(--color-border);padding:.75rem 1rem}
378.hm-nav a{margin-right:1rem;font-size:var(--font-size-sm);display:inline-block;padding:.2rem 0}
379.hm-nav a:not(:last-child)::after{content:\"\";display:none}
380.hm-msg-header{width:100%;border-collapse:collapse;margin:1.25rem 0}
381.hm-msg-header th,.hm-msg-header td{padding:.45rem .75rem;text-align:left;vertical-align:top;border-bottom:1px solid var(--color-border)}
382.hm-msg-header th{white-space:nowrap;color:var(--color-muted);font-size:var(--font-size-sm);width:8rem;font-weight:600;text-transform:uppercase;letter-spacing:.025em}
383.hm-msg-header td{font-size:var(--font-size-lg);word-break:break-word}
384.hm-msg-sep{border:none;border-top:2px solid var(--color-border);margin:1.5rem 0}
385.hm-pg{padding:0;font-family:var(--font-body);font-size:1em;line-height:var(--line-height);white-space:pre-wrap;word-wrap:break-word}
386.hm-blank{display:none}
387.hm-quote-1,.hm-quote-2,.hm-quote-3,.hm-quote-4,.hm-quote-5,.hm-quote-6,.hm-quote-7,.hm-quote-8,.hm-quote-9{padding:.2rem .75rem;font-family:var(--font-body);font-size:.95em;line-height:var(--line-height);white-space:pre-wrap;word-wrap:break-word;font-style:italic}
388.hm-sig-text{padding:.1rem .75rem;font-family:var(--font-mono);font-size:.85em;line-height:1.4;white-space:pre-wrap;word-wrap:break-word;color:var(--color-muted)}
389.hm-quote-1{background:var(--color-bg-subtle);border-left:3px solid var(--color-quote-border-1)}
390.hm-quote-2{background:var(--color-bg-subtle);border-left:3px solid var(--color-quote-border-2)}
391.hm-quote-3{background:var(--color-bg-subtle);border-left:3px solid var(--color-quote-border-3)}
392.hm-quote-4,.hm-quote-5,.hm-quote-6,.hm-quote-7,.hm-quote-8,.hm-quote-9{background:var(--color-bg-subtle);border-left:3px solid var(--color-quote-border-1)}
393.hm-sig{border:none;border-top:1px solid var(--color-border);margin:1rem .75rem;width:4rem}
394.hm-deleted{color:var(--color-deleted);font-style:italic;padding:.5rem .75rem}
395.hm-attachment{margin:.5rem .75rem;padding:.5rem;background:var(--color-bg-subtle);border:1px solid var(--color-border);border-radius:var(--border-radius)}
396.hm-attachment summary{font-weight:600;cursor:pointer;color:var(--color-muted);padding:.25rem}
397.hm-attachment summary:hover{color:var(--color-text)}
398.hm-index{padding:0 .75rem}
399.hm-index li{margin:0;line-height:1.4}
400.hm-index a,.hm-thread-list a{display:inline-block;padding:0}
401.hm-reply-list{list-style:none;padding:0 .75rem;margin:.25rem 0}
402.hm-reply-list li{margin:0;font-size:var(--font-size-sm);line-height:1.4}
403.hm-reply-list li::before{content:\"\\21AA\";margin-right:.35rem;color:var(--color-muted)}
404.hm-thread-children{list-style:disc;padding-left:1.5rem;margin:.15rem 0;border-left:2px solid var(--color-border)}
405.hm-thread-children li{margin:0;line-height:1.4}
406.hm-hdrlabel{font-weight:600}
407.hm-breadcrumb{font-size:var(--font-size-sm);color:var(--color-muted);padding:.5rem 0}
408.hm-breadcrumb a{color:var(--color-link)}
409.hm-generator{text-align:center;font-size:.7rem;color:var(--color-muted);margin:1rem 0}
410.hm-sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0}
411@media(max-width:640px){main#content{padding:0 .5rem}.hm-msg-header,.hm-msg-header tbody,.hm-msg-header tr,.hm-msg-header th,.hm-msg-header td{display:block;width:auto}.hm-msg-header th{width:auto;background:var(--color-bg-subtle)}.hm-msg-header td{padding-left:.75rem}.hm-nav{padding:.5rem}.hm-nav a{display:inline-block;margin:.25rem .5rem}}
412@media print{body{font-size:10pt;color:#000;background:#fff;--color-link:#000;--color-link-visited:#000;--color-muted:#000;--color-border:#ccc}a{color:#000;text-decoration:underline}.hm-nav,.hm-skip,.hm-reply-list,.hm-theme-toggle,.hm-a11y-trigger,.hm-a11y-bar{display:none}.hm-msg-header th{color:#000}pre,code{background:#f5f5f5;border:1px solid #ccc}blockquote{border-left-color:#ccc}}
413.hm-theme-toggle{position:fixed;bottom:1rem;right:1rem;background:var(--color-bg-subtle);border:1px solid var(--color-border);border-radius:var(--border-radius);padding:.4rem .6rem;cursor:pointer;font-size:1.1rem;color:var(--color-text);z-index:50;line-height:1}
414.hm-theme-toggle:hover{background:var(--color-border)}
415.hm-theme-toggle:focus-visible{outline:2px solid var(--color-link);outline-offset:2px}
416.hm-a11y-bar{position:fixed;bottom:1rem;right:3.5rem;display:flex;gap:.25rem;align-items:center;background:var(--color-bg-subtle);border:1px solid var(--color-border);border-radius:var(--border-radius);padding:.3rem .5rem;z-index:50;opacity:0;pointer-events:none;transition:opacity .2s}
417.hm-a11y-bar.open{opacity:1;pointer-events:auto}
418.hm-a11y-bar button{background:none;border:1px solid var(--color-border);border-radius:var(--border-radius);padding:.25rem .45rem;cursor:pointer;font-size:.85rem;color:var(--color-text);line-height:1}
419.hm-a11y-bar button:hover{background:var(--color-border)}
420.hm-a11y-bar button:focus-visible{outline:2px solid var(--color-link);outline-offset:1px}
421.hm-a11y-bar button[aria-pressed=\"true\"]{background:var(--color-link);color:var(--color-bg);border-color:var(--color-link)}
422.hm-a11y-trigger{position:fixed;bottom:1rem;right:3.5rem;background:var(--color-bg-subtle);border:1px solid var(--color-border);border-radius:var(--border-radius);padding:.4rem .6rem;cursor:pointer;font-size:.9rem;color:var(--color-text);z-index:50;line-height:1}
423.hm-a11y-trigger:hover{background:var(--color-border)}
424.hm-a11y-trigger:focus-visible{outline:2px solid var(--color-link);outline-offset:2px}
425[data-font-size=\"small\"]{--font-size-base:.9rem;--font-size-sm:.8rem;--font-size-lg:.9rem}
426[data-font-size=\"large\"]{--font-size-base:1.2rem;--font-size-sm:1rem;--font-size-lg:1.15rem}
427[data-font-size=\"xlarge\"]{--font-size-base:1.4rem;--font-size-sm:1.15rem;--font-size-lg:1.3rem}
428[data-line-height=\"compact\"]{--line-height:1.3}
429[data-line-height=\"relaxed\"]{--line-height:1.9}
430[data-dyslexic] body{font-family:\"OpenDyslexic\",\"Comic Sans MS\",var(--font-body);letter-spacing:.05em;word-spacing:.15em}
431</style>
432<script>
433(function(){var d=document.documentElement,k='hm-theme',b;function t(){var c=d.getAttribute('data-theme');var n=c==='dark'?'light':'dark';d.setAttribute('data-theme',n);try{localStorage.setItem(k,n)}catch(e){}if(b)b.setAttribute('aria-label','Switch to '+(n==='dark'?'light':'dark')+' mode')}try{var s=localStorage.getItem(k);if(s)d.setAttribute('data-theme',s)}catch(e){}try{var fs=localStorage.getItem('hm-font-size');if(fs)d.setAttribute('data-font-size',fs);var lh=localStorage.getItem('hm-line-height');if(lh)d.setAttribute('data-line-height',lh);if(localStorage.getItem('hm-dyslexic')==='1')d.setAttribute('data-dyslexic','')}catch(e){}document.addEventListener('DOMContentLoaded',function(){b=document.querySelector('.hm-theme-toggle');if(b)b.addEventListener('click',t);var trig=document.querySelector('.hm-a11y-trigger'),bar=document.querySelector('.hm-a11y-bar');if(trig&&bar){trig.addEventListener('click',function(){bar.classList.toggle('open');trig.setAttribute('aria-expanded',bar.classList.contains('open'))})}var sizes=['small','','large','xlarge'],si=sizes.indexOf(d.getAttribute('data-font-size')||'');if(si<0)si=1;document.querySelector('[data-a11y=\"size-up\"]')&&document.querySelector('[data-a11y=\"size-up\"]').addEventListener('click',function(){si=Math.min(si+1,3);var v=sizes[si];if(v){d.setAttribute('data-font-size',v);try{localStorage.setItem('hm-font-size',v)}catch(e){}}else{d.removeAttribute('data-font-size');try{localStorage.removeItem('hm-font-size')}catch(e){}}});document.querySelector('[data-a11y=\"size-down\"]')&&document.querySelector('[data-a11y=\"size-down\"]').addEventListener('click',function(){si=Math.max(si-1,0);var v=sizes[si];if(v){d.setAttribute('data-font-size',v);try{localStorage.setItem('hm-font-size',v)}catch(e){}}else{d.removeAttribute('data-font-size');try{localStorage.removeItem('hm-font-size')}catch(e){}}});var lhStates=['compact','','relaxed'],li=lhStates.indexOf(d.getAttribute('data-line-height')||'');if(li<0)li=1;var lhBtn=document.querySelector('[data-a11y=\"line-height\"]');function updLh(){if(lhBtn)lhBtn.setAttribute('aria-pressed',li!==1?'true':'false')}updLh();lhBtn&&lhBtn.addEventListener('click',function(){li=(li+1)%3;var v=lhStates[li];if(v){d.setAttribute('data-line-height',v);try{localStorage.setItem('hm-line-height',v)}catch(e){}}else{d.removeAttribute('data-line-height');try{localStorage.removeItem('hm-line-height')}catch(e){}}updLh()});var dyBtn=document.querySelector('[data-a11y=\"dyslexic\"]');function updDy(){if(dyBtn)dyBtn.setAttribute('aria-pressed',d.hasAttribute('data-dyslexic')?'true':'false')}updDy();dyBtn&&dyBtn.addEventListener('click',function(){if(d.hasAttribute('data-dyslexic')){d.removeAttribute('data-dyslexic');try{localStorage.removeItem('hm-dyslexic')}catch(e){}}else{d.setAttribute('data-dyslexic','');try{localStorage.setItem('hm-dyslexic','1')}catch(e){}}updDy()});var resetBtn=document.querySelector('[data-a11y=\"reset\"]');resetBtn&&resetBtn.addEventListener('click',function(){si=1;li=1;d.removeAttribute('data-font-size');d.removeAttribute('data-line-height');d.removeAttribute('data-dyslexic');try{localStorage.removeItem('hm-font-size');localStorage.removeItem('hm-line-height');localStorage.removeItem('hm-dyslexic')}catch(e){}updLh();updDy()})})})();
434</script>
435%METADATA%
436</head>
437<body>
438<a class=\"hm-skip\" href=\"#content\">Skip to content</a>
439<button class=\"hm-theme-toggle\" aria-label=\"Switch to light mode\" title=\"Toggle dark/light mode\">◑</button>
440<button class=\"hm-a11y-trigger\" aria-label=\"Accessibility options\" aria-expanded=\"false\" title=\"Accessibility\">Aa</button>
441<div class=\"hm-a11y-bar\" role=\"toolbar\" aria-label=\"Accessibility controls\"><button data-a11y=\"size-down\" aria-label=\"Decrease font size\" title=\"Smaller\">A↓</button><button data-a11y=\"size-up\" aria-label=\"Increase font size\" title=\"Larger\">A↑</button><button data-a11y=\"line-height\" aria-label=\"Toggle line spacing\" aria-pressed=\"false\" title=\"Line spacing\">☰</button><button data-a11y=\"dyslexic\" aria-label=\"Toggle dyslexia-friendly font\" aria-pressed=\"false\" title=\"Dyslexia font\">Dy</button><button data-a11y=\"reset\" aria-label=\"Reset accessibility settings\" title=\"Reset\">↺</button></div>
442%BODYHEADER%
443%BODYHEADEREND%
444%NAVIGATION%
445<main id=\"content\">
446"
447}
448
449pub fn default_footer_template() -> &'static str {
451 "
452</main>
453%NAVIGATION%
454<footer role=\"contentinfo\">
455%BODYFOOTER%
456%GENERATOR%
457</footer>
458</body>
459</html>"
460}
461
462pub fn default_article_template() -> &'static str {
464 "%ARTICLE%
465"
466}
467
468pub fn load_article_template(dir: &str) -> String {
470 let path = Path::new(dir).join(".hm_article");
471 fs::read_to_string(&path).unwrap_or_else(|_| default_article_template().to_string())
472}
473
474pub fn load_header_template(dir: &str) -> String {
476 let path = Path::new(dir).join(".hm_header");
477 fs::read_to_string(&path).unwrap_or_else(|_| default_header_template().to_string())
478}
479
480pub fn load_footer_template(dir: &str) -> String {
482 let path = Path::new(dir).join(".hm_footer");
483 fs::read_to_string(&path).unwrap_or_else(|_| default_footer_template().to_string())
484}
485
486pub fn load_template(path: &str) -> Option<String> {
488 std::fs::read_to_string(path).ok()
489}
490
491pub fn get_header_cookies(config: &crate::config::Config, title: &str) -> CookieMap {
493 let mut cookies = CookieMap::new();
494 set_cookie(&mut cookies, "TITLE", title);
495
496 let stylesheet = if let Some(ref css) = config.css {
497 let css_path = if !css.starts_with("http") && !css.starts_with('/') {
498 config.css_path()
499 } else {
500 css.clone()
501 };
502 let mut tag = String::from("<link rel=\"stylesheet\" type=\"text/css\" href=\"");
503 tag.push_str(&escape_attr(&css_path));
504 tag.push_str("\">\n");
505 tag
506 } else {
507 String::new()
508 };
509 set_cookie(&mut cookies, "STYLESHEET", &stylesheet);
510
511 let metadata = if let Some(ref desc) = config.description {
512 let mut tag = String::from("<meta name=\"description\" content=\"");
513 tag.push_str(&crate::txt2html::escape_html(desc));
514 tag.push_str("\">\n");
515 tag
516 } else {
517 String::new()
518 };
519 set_cookie(&mut cookies, "METADATA", &metadata);
520
521 let bodyheader = config.bodyheader.as_deref().unwrap_or("");
523 if bodyheader.to_ascii_lowercase().contains("<script") {
524 log::warn!("bodyheader contains <script> — ensure this is intentional");
525 }
526 set_cookie(&mut cookies, "BODYHEADER", bodyheader);
527
528 let bodyheaderend = config.bodyheaderend.as_deref().unwrap_or("");
529 if bodyheaderend.to_ascii_lowercase().contains("<script") {
530 log::warn!("bodyheaderend contains <script> — ensure this is intentional");
531 }
532 set_cookie(&mut cookies, "BODYHEADEREND", bodyheaderend);
533
534 set_cookie(&mut cookies, "NAVIGATION", "");
535
536 let bodyfooter = config.bodyfooter.as_deref().unwrap_or("");
538 if bodyfooter.to_ascii_lowercase().contains("<script") {
539 log::warn!("bodyfooter contains <script> — ensure this is intentional");
540 }
541 set_cookie(&mut cookies, "BODYFOOTER", bodyfooter);
542
543 let generator = if config.showgenerator {
545 "<p class=\"hm-generator\">Generated by <a href=\"https://hypermail-rs.github.io\">hypermail-rs</a></p>"
546 } else {
547 ""
548 };
549 set_cookie(&mut cookies, "GENERATOR", generator);
550
551 let lang = if config.language.is_empty() {
553 "en"
554 } else {
555 &config.language
556 };
557 set_cookie(&mut cookies, "LANG", lang);
558
559 let htmlattrs = match config.theme.as_deref() {
560 Some("light") => "data-theme=\"light\"",
561 Some("dark") => "data-theme=\"dark\"",
562 _ => "",
563 };
564 set_cookie(&mut cookies, "HTMLATTRS", htmlattrs);
565
566 cookies
567}
568
569#[cfg(test)]
570mod tests {
571 use super::*;
572
573 #[test]
574 fn test_basic_substitution() {
575 let mut cookies = CookieMap::new();
576 set_cookie(&mut cookies, "TITLE", "Test Title");
577 let result = substitute_cookies("<h1>%TITLE%</h1>", &cookies);
578 assert_eq!(result, "<h1>Test Title</h1>");
579 }
580
581 #[test]
582 fn test_multiple_cookies() {
583 let mut cookies = CookieMap::new();
584 set_cookie(&mut cookies, "TITLE", "My List");
585 set_cookie(&mut cookies, "NAV", "Home");
586 let result = substitute_cookies("%NAV% - %TITLE%", &cookies);
587 assert_eq!(result, "Home - My List");
588 }
589
590 #[test]
591 fn test_unknown_cookie() {
592 let cookies = CookieMap::new();
593 let result = substitute_cookies("%UNKNOWN%", &cookies);
594 assert_eq!(result, "%UNKNOWN%");
595 }
596
597 #[test]
598 fn test_unset_cookie() {
599 let mut cookies = CookieMap::new();
600 set_cookie(&mut cookies, "TITLE", "test");
601 unset_cookie(&mut cookies, "TITLE");
602 assert!(!cookies.contains_key("TITLE"));
603 }
604
605 #[test]
606 fn test_header_cookies() {
607 let config = crate::config::Config::default();
608 let cookies = get_header_cookies(&config, "Test Archive");
609 assert_eq!(cookies.get("TITLE").unwrap(), "Test Archive");
610 }
611
612 #[test]
613 fn test_description_meta_escaped() {
614 let mut config = crate::config::Config::default();
615 config.description = Some("<script>alert('xss')</script>".to_string());
616 let cookies = get_header_cookies(&config, "Test");
617 let meta = cookies.get("METADATA").unwrap();
618 assert!(meta.contains("<script>"));
619 assert!(!meta.contains("<script>"));
620 }
621
622 #[test]
623 fn test_description_meta_safe() {
624 let mut config = crate::config::Config::default();
625 config.description = Some("Hello & welcome".to_string());
626 let cookies = get_header_cookies(&config, "Test");
627 let meta = cookies.get("METADATA").unwrap();
628 assert!(meta.contains("Hello & welcome"));
629 }
630
631 #[test]
632 fn test_no_description_no_meta() {
633 let config = crate::config::Config::default();
634 let cookies = get_header_cookies(&config, "Test");
635 let meta = cookies.get("METADATA").unwrap();
636 assert!(meta.is_empty());
637 }
638}