1use super::template::ExportOptions;
10use tracing::debug;
11
12pub struct ScriptBundle {
14 pub inline_js: String,
16}
17
18pub fn generate_scripts(options: &ExportOptions) -> ScriptBundle {
20 let mut scripts = Vec::new();
21
22 scripts.push(generate_core_utils());
24
25 if options.include_search {
27 scripts.push(generate_search_js());
28 }
29
30 if options.include_theme_toggle {
32 scripts.push(generate_theme_js());
33 }
34
35 if options.show_tool_calls {
37 scripts.push(generate_tool_toggle_js());
38 }
39
40 if options.encrypt {
42 scripts.push(generate_decryption_js());
43 }
44
45 scripts.push(generate_world_class_js());
47
48 scripts.push(generate_init_js(options));
50
51 let inline_js = scripts.join("\n\n");
52 debug!(
53 component = "scripts",
54 operation = "generate",
55 include_search = options.include_search,
56 include_theme_toggle = options.include_theme_toggle,
57 show_tool_calls = options.show_tool_calls,
58 encrypt = options.encrypt,
59 inline_bytes = inline_js.len(),
60 "Generated inline scripts"
61 );
62
63 ScriptBundle { inline_js }
64}
65
66fn generate_core_utils() -> String {
67 r#"// Core utilities
68const $ = (sel) => document.querySelector(sel);
69const $$ = (sel) => document.querySelectorAll(sel);
70
71// Toast notifications
72const Toast = {
73 container: null,
74
75 init() {
76 this.container = document.createElement('div');
77 this.container.id = 'toast-container';
78 this.container.style.cssText = 'position:fixed;bottom:1rem;right:1rem;z-index:9999;display:flex;flex-direction:column;gap:0.5rem;';
79 document.body.appendChild(this.container);
80 },
81
82 show(message, type = 'info') {
83 if (!this.container) this.init();
84 const toast = document.createElement('div');
85 toast.className = 'toast toast-' + type;
86 toast.style.cssText = 'padding:0.75rem 1rem;background:var(--bg-surface);border:1px solid var(--border);border-radius:6px;color:var(--text-primary);box-shadow:0 4px 12px rgba(0,0,0,0.3);transform:translateX(100%);transition:transform 0.3s ease;';
87 toast.textContent = message;
88 this.container.appendChild(toast);
89 requestAnimationFrame(() => toast.style.transform = 'translateX(0)');
90 setTimeout(() => {
91 toast.style.transform = 'translateX(100%)';
92 setTimeout(() => toast.remove(), 300);
93 }, 3000);
94 }
95};
96
97// Copy to clipboard
98async function copyToClipboard(text) {
99 try {
100 await navigator.clipboard.writeText(text);
101 Toast.show('Copied to clipboard', 'success');
102 return true;
103 } catch (e) {
104 // Fallback for older browsers
105 const textarea = document.createElement('textarea');
106 textarea.value = text;
107 textarea.style.position = 'fixed';
108 textarea.style.opacity = '0';
109 document.body.appendChild(textarea);
110 textarea.select();
111 let ok = false;
112 try {
113 ok = document.execCommand('copy');
114 } catch (e2) {
115 // execCommand threw — ok stays false
116 }
117 textarea.remove();
118 if (ok) {
119 Toast.show('Copied to clipboard', 'success');
120 return true;
121 }
122 Toast.show('Copy failed', 'error');
123 }
124 return false;
125}
126
127// Copy code block
128async function copyCodeBlock(btn) {
129 const pre = btn.closest('pre');
130 const code = pre.querySelector('code');
131 const ok = await copyToClipboard(code ? code.textContent : pre.textContent);
132 if (ok) {
133 btn.classList.add('copied');
134 setTimeout(() => btn.classList.remove('copied'), 1500);
135 }
136}
137
138// Print handler
139function printConversation() {
140 // Expand all collapsed sections before print
141 $$('details, .tool-call').forEach(el => {
142 if (el.tagName === 'DETAILS') el.open = true;
143 else el.classList.add('expanded');
144 });
145 window.print();
146}"#
147 .to_string()
148}
149
150fn generate_search_js() -> String {
151 r#"// Search functionality
152const Search = {
153 input: null,
154 countEl: null,
155 matches: [],
156 currentIndex: -1,
157 _initialized: false,
158
159 init() {
160 this.input = $('#search-input');
161 this.countEl = $('#search-count');
162 if (!this.input) return;
163
164 if (!this.countEl && this.input.parentNode) {
165 const count = document.createElement('span');
166 count.id = 'search-count';
167 count.className = 'search-count';
168 count.hidden = true;
169 this.input.parentNode.appendChild(count);
170 this.countEl = count;
171 }
172 if (!this.countEl) return;
173
174 if (!this._initialized) {
175 this.input.addEventListener('input', () => this.search());
176 this.input.addEventListener('keydown', (e) => {
177 if (e.key === 'Enter') {
178 e.preventDefault();
179 if (e.shiftKey) {
180 this.prev();
181 } else {
182 this.next();
183 }
184 } else if (e.key === 'Escape') {
185 this.clear();
186 this.input.blur();
187 }
188 });
189
190 // Keyboard shortcut: Ctrl/Cmd + F for search
191 document.addEventListener('keydown', (e) => {
192 if ((e.ctrlKey || e.metaKey) && e.key === 'f') {
193 e.preventDefault();
194 this.input.focus();
195 this.input.select();
196 }
197 });
198 this._initialized = true;
199 }
200 },
201
202 search() {
203 this.clearHighlights();
204 $$('.message.search-hit').forEach((el) => el.classList.remove('search-hit'));
205 const query = this.input.value.trim().toLowerCase();
206 if (!query) {
207 this.countEl.hidden = true;
208 return;
209 }
210
211 this.matches = [];
212 const hitMessages = new Set();
213 let searchRoots = $$('.message');
214 if (!searchRoots || searchRoots.length === 0) {
215 searchRoots = $$('.message-content');
216 }
217 searchRoots.forEach((el) => {
218 const messageEl = el.classList && el.classList.contains('message') ? el : el.closest('.message');
219 const walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT);
220 let node;
221 while ((node = walker.nextNode())) {
222 const text = node.textContent.toLowerCase();
223 let index = text.indexOf(query);
224 while (index !== -1) {
225 this.matches.push({ node, index, length: query.length });
226 if (messageEl) hitMessages.add(messageEl);
227 index = text.indexOf(query, index + 1);
228 }
229 }
230 });
231
232 hitMessages.forEach((el) => el.classList.add('search-hit'));
233 this.highlightAll();
234 this.updateCount();
235
236 if (this.matches.length > 0) {
237 this.currentIndex = 0;
238 this.scrollToCurrent();
239 }
240 },
241
242 highlightAll() {
243 // Process in reverse to preserve indices
244 for (let i = this.matches.length - 1; i >= 0; i--) {
245 const match = this.matches[i];
246 const range = document.createRange();
247 try {
248 range.setStart(match.node, match.index);
249 range.setEnd(match.node, match.index + match.length);
250 const span = document.createElement('span');
251 span.className = 'search-highlight';
252 span.dataset.matchIndex = i;
253 range.surroundContents(span);
254 } catch (e) {
255 // Skip invalid ranges
256 }
257 }
258 },
259
260 clearHighlights() {
261 const parents = new Set();
262 $$('.search-highlight').forEach((el) => {
263 const parent = el.parentNode;
264 while (el.firstChild) {
265 parent.insertBefore(el.firstChild, el);
266 }
267 parent.removeChild(el);
268 parents.add(parent);
269 });
270 // Merge adjacent text nodes so subsequent searches work correctly
271 parents.forEach((p) => p.normalize());
272 this.matches = [];
273 this.currentIndex = -1;
274 },
275
276 updateCount() {
277 if (this.matches.length > 0) {
278 this.countEl.textContent = `${this.currentIndex + 1}/${this.matches.length}`;
279 this.countEl.hidden = false;
280 } else {
281 this.countEl.textContent = 'No results';
282 this.countEl.hidden = false;
283 }
284 },
285
286 scrollToCurrent() {
287 $$('.search-current').forEach((el) => el.classList.remove('search-current'));
288 if (this.currentIndex >= 0 && this.currentIndex < this.matches.length) {
289 const highlight = $(`[data-match-index="${this.currentIndex}"]`);
290 if (highlight) {
291 highlight.classList.add('search-current');
292 highlight.scrollIntoView({ behavior: 'smooth', block: 'center' });
293 }
294 }
295 this.updateCount();
296 },
297
298 next() {
299 if (this.matches.length === 0) return;
300 this.currentIndex = (this.currentIndex + 1) % this.matches.length;
301 this.scrollToCurrent();
302 },
303
304 prev() {
305 if (this.matches.length === 0) return;
306 this.currentIndex = (this.currentIndex - 1 + this.matches.length) % this.matches.length;
307 this.scrollToCurrent();
308 },
309
310 clear() {
311 this.input.value = '';
312 this.clearHighlights();
313 this.countEl.hidden = true;
314 }
315};"#
316 .to_string()
317}
318
319fn generate_theme_js() -> String {
320 r#"// Theme toggle
321const Theme = {
322 toggle: null,
323
324 init() {
325 this.toggle = $('#theme-toggle');
326 if (!this.toggle) return;
327
328 // Load saved preference or system preference
329 const saved = localStorage.getItem('cass-theme');
330 const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
331 const theme = saved || (prefersDark ? 'dark' : 'light');
332 document.documentElement.setAttribute('data-theme', theme);
333
334 this.toggle.addEventListener('click', () => this.toggleTheme());
335
336 // Listen for system theme changes
337 window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', (e) => {
338 if (!localStorage.getItem('cass-theme')) {
339 document.documentElement.setAttribute('data-theme', e.matches ? 'dark' : 'light');
340 }
341 });
342 },
343
344 toggleTheme() {
345 const current = document.documentElement.getAttribute('data-theme');
346 const next = current === 'dark' ? 'light' : 'dark';
347 document.documentElement.setAttribute('data-theme', next);
348 localStorage.setItem('cass-theme', next);
349 }
350};"#
351 .to_string()
352}
353
354fn generate_tool_toggle_js() -> String {
355 r#"// Tool call expand/collapse
356const ToolCalls = {
357 init() {
358 $$('.tool-call-header').forEach((header) => {
359 if (header.dataset.toolToggleBound === 'true') return;
360 header.dataset.toolToggleBound = 'true';
361 header.addEventListener('click', () => {
362 const toolCall = header.closest('.tool-call');
363 toolCall.classList.toggle('expanded');
364 });
365 });
366 }
367};
368
369// Tool badge popover controller
370const ToolPopovers = {
371 activePopover: null,
372 activeBadge: null,
373 _outsideClickBound: false,
374
375 init() {
376 this.initBadges();
377 this.initOverflowBadges();
378 this.initOutsideClick();
379 },
380
381 initBadges() {
382 $$('.tool-badge:not(.tool-overflow)').forEach(badge => {
383 if (badge.dataset.popoverBound === 'true') return;
384 badge.dataset.popoverBound = 'true';
385 // Helper to get popover - looks up fresh each time since popover may be built dynamically
386 const getPopover = () => badge.querySelector('.tool-popover');
387
388 // Show on hover (desktop)
389 badge.addEventListener('mouseenter', () => this.show(badge, getPopover()));
390 badge.addEventListener('mouseleave', () => this.hide(badge, getPopover()));
391
392 // Show on focus (keyboard accessibility)
393 badge.addEventListener('focus', () => this.show(badge, getPopover()));
394 badge.addEventListener('blur', (e) => {
395 // Don't hide if focus moves within the popover
396 const popover = getPopover();
397 if (!popover || !popover.contains(e.relatedTarget)) {
398 this.hide(badge, popover);
399 }
400 });
401
402 // Toggle on click (mobile support)
403 badge.addEventListener('click', (e) => {
404 e.preventDefault();
405 e.stopPropagation();
406 this.toggle(badge, getPopover());
407 });
408
409 // Keyboard support
410 badge.addEventListener('keydown', (e) => {
411 if (e.key === 'Enter' || e.key === ' ') {
412 e.preventDefault();
413 this.toggle(badge, getPopover());
414 } else if (e.key === 'Escape') {
415 this.hide(badge, getPopover());
416 badge.focus();
417 }
418 });
419 });
420 },
421
422 initOverflowBadges() {
423 $$('.tool-overflow').forEach(btn => {
424 if (btn.dataset.overflowBound === 'true') return;
425 btn.dataset.overflowBound = 'true';
426 // Store original text
427 btn.dataset.originalText = btn.textContent.trim();
428
429 btn.addEventListener('click', (e) => {
430 e.preventDefault();
431 e.stopPropagation();
432 const container = btn.closest('.message-header-right');
433 if (!container) return;
434
435 const isExpanded = container.classList.toggle('expanded');
436 btn.textContent = isExpanded ? 'Less' : btn.dataset.originalText;
437 btn.setAttribute('aria-expanded', isExpanded);
438 });
439 });
440 },
441
442 initOutsideClick() {
443 if (this._outsideClickBound) return;
444 this._outsideClickBound = true;
445 document.addEventListener('click', (e) => {
446 if (!e.target.closest('.tool-badge')) {
447 this.hideAll();
448 }
449 });
450 },
451
452 show(badge, popover) {
453 if (!popover) {
454 // Build popover from data attributes if not present
455 popover = this.buildPopover(badge);
456 if (!popover) return;
457 }
458
459 // Hide any other active popover first
460 if (this.activeBadge && this.activeBadge !== badge) {
461 this.hide(this.activeBadge, this.activePopover);
462 }
463
464 popover.classList.add('visible');
465 badge.setAttribute('aria-expanded', 'true');
466 this.position(badge, popover);
467
468 this.activePopover = popover;
469 this.activeBadge = badge;
470 },
471
472 hide(badge, popover) {
473 if (popover) {
474 popover.classList.remove('visible');
475 popover.style.position = '';
476 popover.style.top = '';
477 popover.style.left = '';
478 }
479 if (badge) {
480 badge.setAttribute('aria-expanded', 'false');
481 }
482 if (this.activeBadge === badge) {
483 this.activePopover = null;
484 this.activeBadge = null;
485 }
486 },
487
488 hideAll() {
489 $$('.tool-popover.visible').forEach(p => {
490 p.classList.remove('visible');
491 });
492 $$('.tool-badge[aria-expanded="true"]').forEach(b => {
493 b.setAttribute('aria-expanded', 'false');
494 });
495 this.activePopover = null;
496 this.activeBadge = null;
497 },
498
499 toggle(badge, popover) {
500 const isVisible = popover && popover.classList.contains('visible');
501 if (isVisible) {
502 this.hide(badge, popover);
503 } else {
504 this.show(badge, popover);
505 }
506 },
507
508 buildPopover(badge) {
509 // Build a popover from data attributes if no inline popover exists
510 const name = badge.dataset.toolName;
511 const input = badge.dataset.toolInput;
512 const output = badge.dataset.toolOutput;
513
514 if (!name) return null;
515
516 const popover = document.createElement('div');
517 popover.className = 'tool-popover';
518 popover.setAttribute('role', 'tooltip');
519
520 let html = '<div class="tool-popover-header"><strong>' + this.escapeHtml(name) + '</strong></div>';
521
522 if (input && input.trim()) {
523 html += '<div class="tool-popover-section"><span class="tool-popover-label">Input</span><pre><code>' + this.escapeHtml(input) + '</code></pre></div>';
524 }
525
526 if (output && output.trim()) {
527 html += '<div class="tool-popover-section"><span class="tool-popover-label">Output</span><pre><code>' + this.escapeHtml(output) + '</code></pre></div>';
528 }
529
530 popover.innerHTML = html;
531 badge.appendChild(popover);
532 return popover;
533 },
534
535 escapeHtml(text) {
536 const div = document.createElement('div');
537 div.textContent = text;
538 return div.innerHTML;
539 },
540
541 position(badge, popover) {
542 // Skip positioning on mobile - CSS handles bottom sheet style
543 if (window.innerWidth < 768) {
544 return;
545 }
546
547 popover.style.position = 'fixed';
548
549 // Use fixed positioning relative to viewport
550 const badgeRect = badge.getBoundingClientRect();
551 const viewportWidth = window.innerWidth;
552 const viewportHeight = window.innerHeight;
553 const margin = 8;
554
555 // Measure popover dimensions (temporarily make visible for measurement)
556 popover.style.visibility = 'hidden';
557 popover.style.display = 'block';
558 const popoverRect = popover.getBoundingClientRect();
559 popover.style.display = '';
560 popover.style.visibility = '';
561
562 // Default: position below and align left edge with badge
563 let top = badgeRect.bottom + margin;
564 let left = badgeRect.left;
565
566 // Flip up if would overflow bottom
567 if (top + popoverRect.height > viewportHeight - margin) {
568 top = badgeRect.top - popoverRect.height - margin;
569 popover.classList.add('popover-above');
570 } else {
571 popover.classList.remove('popover-above');
572 }
573
574 // Flip to align right edge if would overflow right
575 if (left + popoverRect.width > viewportWidth - margin) {
576 left = Math.max(margin, badgeRect.right - popoverRect.width);
577 }
578
579 // Ensure not off left edge
580 left = Math.max(margin, left);
581
582 // Ensure not off top edge
583 top = Math.max(margin, top);
584
585 popover.style.top = top + 'px';
586 popover.style.left = left + 'px';
587 }
588};"#
589 .to_string()
590}
591
592fn generate_world_class_js() -> String {
593 r#"// World-class UI/UX enhancements
594const WorldClass = {
595 scrollProgress: null,
596 floatingNav: null,
597 gradientMesh: null,
598 lastScrollY: 0,
599 ticking: false,
600 currentMessageIndex: -1,
601 messages: [],
602 _initialized: false,
603
604 init() {
605 this.messages = Array.from($$('.message'));
606 this.scrollProgress = $('#scroll-progress');
607 this.floatingNav = $('#floating-nav');
608 this.initFloatingNav();
609 this.initIntersectionObserver();
610 this.initMessageLinks();
611 // Bind document/window-level handlers only once to avoid duplicates
612 // after decryption re-init (these targets survive innerHTML replacement)
613 if (!this._initialized) {
614 this.initKeyboardNav();
615 this.initScrollHandler();
616 this.initShareButton();
617 this._initialized = true;
618 }
619 },
620
621 initFloatingNav() {
622 if (!this.floatingNav) return;
623
624 const scrollTopBtn = $('#scroll-top');
625 if (scrollTopBtn) {
626 scrollTopBtn.onclick = () => {
627 window.scrollTo({ top: 0, behavior: 'smooth' });
628 };
629 }
630 },
631
632 initScrollHandler() {
633 const toolbar = $('.toolbar');
634 let lastScrollY = window.scrollY;
635 let scrollDirection = 'up';
636
637 const updateScroll = () => {
638 const scrollY = window.scrollY;
639 const scrollHeight = document.documentElement.scrollHeight - window.innerHeight;
640 const progress = scrollHeight > 0 ? (scrollY / scrollHeight) * 100 : 0;
641
642 // Update progress bar
643 if (this.scrollProgress) {
644 this.scrollProgress.style.width = `${progress}%`;
645 }
646
647 // Show/hide floating nav
648 if (this.floatingNav) {
649 if (scrollY > 300) {
650 this.floatingNav.classList.add('visible');
651 } else {
652 this.floatingNav.classList.remove('visible');
653 }
654 }
655
656 // Mobile: hide toolbar on scroll down (only if wide enough scroll)
657 if (toolbar && window.innerWidth < 768) {
658 scrollDirection = scrollY > lastScrollY ? 'down' : 'up';
659 if (scrollDirection === 'down' && scrollY > 200) {
660 toolbar.classList.add('toolbar-hidden');
661 } else {
662 toolbar.classList.remove('toolbar-hidden');
663 }
664 }
665
666 lastScrollY = scrollY;
667 this.ticking = false;
668 };
669
670 window.addEventListener('scroll', () => {
671 if (!this.ticking) {
672 requestAnimationFrame(updateScroll);
673 this.ticking = true;
674 }
675 }, { passive: true });
676 },
677
678 initIntersectionObserver() {
679 if (!('IntersectionObserver' in window)) return;
680
681 const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
682 if (reduceMotion) {
683 this.messages.forEach((msg) => {
684 msg.style.opacity = '1';
685 msg.style.transform = 'none';
686 msg.classList.add('in-view');
687 });
688 return;
689 }
690
691 const observer = new IntersectionObserver((entries) => {
692 entries.forEach(entry => {
693 if (entry.isIntersecting) {
694 entry.target.classList.add('in-view');
695 observer.unobserve(entry.target);
696 }
697 });
698 }, {
699 threshold: 0.1,
700 rootMargin: '0px 0px -50px 0px'
701 });
702
703 // Initially hide messages for animation
704 // Must match CSS @keyframes messageReveal 'from' state exactly
705 this.messages.forEach((msg, i) => {
706 msg.style.opacity = '0';
707 msg.style.transform = 'translateY(24px) scale(0.97)';
708 setTimeout(() => observer.observe(msg), i * 30);
709 });
710 },
711
712 initKeyboardNav() {
713 document.addEventListener('keydown', (e) => {
714 // Ignore if in input/textarea
715 if (e.target.matches('input, textarea')) return;
716
717 switch(e.key) {
718 case 'j':
719 e.preventDefault();
720 this.navigateMessage(1);
721 break;
722 case 'k':
723 e.preventDefault();
724 this.navigateMessage(-1);
725 break;
726 case 'g':
727 e.preventDefault();
728 this.navigateToMessage(0);
729 break;
730 case 'G':
731 e.preventDefault();
732 this.navigateToMessage(this.messages.length - 1);
733 break;
734 case '/':
735 if (!e.ctrlKey && !e.metaKey) {
736 e.preventDefault();
737 const searchInput = $('#search-input');
738 if (searchInput) {
739 searchInput.focus();
740 searchInput.select();
741 }
742 }
743 break;
744 case '?':
745 e.preventDefault();
746 this.showShortcutsHint();
747 break;
748 }
749 });
750 },
751
752 navigateMessage(direction) {
753 const newIndex = Math.max(0, Math.min(this.messages.length - 1, this.currentMessageIndex + direction));
754 this.navigateToMessage(newIndex);
755 },
756
757 navigateToMessage(index) {
758 // Remove focus from current
759 if (this.currentMessageIndex >= 0 && this.messages[this.currentMessageIndex]) {
760 this.messages[this.currentMessageIndex].classList.remove('keyboard-focus');
761 }
762
763 this.currentMessageIndex = index;
764 const msg = this.messages[index];
765 if (msg) {
766 msg.classList.add('keyboard-focus');
767 msg.scrollIntoView({ behavior: 'smooth', block: 'center' });
768 }
769 },
770
771 showShortcutsHint() {
772 let hint = $('.shortcuts-hint');
773 if (!hint) {
774 hint = document.createElement('div');
775 hint.className = 'shortcuts-hint';
776 hint.innerHTML = '<kbd>j</kbd>/<kbd>k</kbd> navigate • <kbd>g</kbd> first • <kbd>G</kbd> last • <kbd>/</kbd> search • <kbd>?</kbd> help';
777 document.body.appendChild(hint);
778 }
779 hint.classList.add('visible');
780 setTimeout(() => hint.classList.remove('visible'), 3000);
781 },
782
783 initMessageLinks() {
784 this.messages.forEach((msg, i) => {
785 if (msg.querySelector('.message-link')) return;
786 const btn = document.createElement('button');
787 btn.className = 'message-link';
788 btn.title = 'Copy link to message';
789 btn.setAttribute('aria-label', 'Copy link to message');
790 btn.innerHTML = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M10 13a5 5 0 007.54.54l3-3a5 5 0 00-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 00-7.54-.54l-3 3a5 5 0 007.07 7.07l1.71-1.71"/></svg>';
791 btn.onclick = (e) => {
792 e.stopPropagation();
793 const id = msg.id || `msg-${i}`;
794 if (!msg.id) msg.id = id;
795 const url = `${window.location.href.split('#')[0]}#${id}`;
796 copyToClipboard(url);
797 btn.classList.add('copied');
798 setTimeout(() => btn.classList.remove('copied'), 1500);
799 };
800 msg.appendChild(btn);
801 });
802 },
803
804 initShareButton() {
805 if (!navigator.share) return;
806
807 const toolbar = $('.toolbar');
808 if (!toolbar) return;
809
810 const shareBtn = document.createElement('button');
811 shareBtn.className = 'toolbar-btn';
812 shareBtn.title = 'Share';
813 shareBtn.setAttribute('aria-label', 'Share');
814 shareBtn.innerHTML = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4 12v8a2 2 0 002 2h12a2 2 0 002-2v-8"/><polyline points="16,6 12,2 8,6"/><line x1="12" y1="2" x2="12" y2="15"/></svg>';
815 shareBtn.onclick = async () => {
816 try {
817 await navigator.share({
818 title: document.title,
819 url: window.location.href
820 });
821 } catch (e) {
822 if (e.name !== 'AbortError') {
823 Toast.show('Share failed', 'error');
824 }
825 }
826 };
827 toolbar.appendChild(shareBtn);
828 }
829};
830
831// Touch ripple effect for mobile
832function createRipple(event) {
833 const button = event.currentTarget;
834 const rect = button.getBoundingClientRect();
835 const ripple = document.createElement('span');
836 const size = Math.max(rect.width, rect.height);
837 ripple.style.width = ripple.style.height = `${size}px`;
838 ripple.style.left = `${event.clientX - rect.left - size/2}px`;
839 ripple.style.top = `${event.clientY - rect.top - size/2}px`;
840 ripple.className = 'ripple';
841 button.appendChild(ripple);
842 setTimeout(() => ripple.remove(), 600);
843}
844
845// Add ripple to touch devices
846if ('ontouchstart' in window) {
847 document.addEventListener('DOMContentLoaded', () => {
848 $$('.toolbar button, .floating-btn').forEach(btn => {
849 btn.addEventListener('touchstart', createRipple);
850 });
851 });
852}"#
853 .to_string()
854}
855
856fn generate_decryption_js() -> String {
857 r#"// Decryption using Web Crypto API
858const Crypto = {
859 modal: null,
860 form: null,
861 errorEl: null,
862
863 init() {
864 this.modal = $('#password-modal');
865 this.form = $('#password-form');
866 this.errorEl = $('#decrypt-error');
867
868 if (!this.modal || !this.form) return;
869
870 this.form.addEventListener('submit', (e) => {
871 e.preventDefault();
872 this.decrypt();
873 });
874 },
875
876 async decrypt() {
877 const passphrase = $('#password-input').value;
878 if (!passphrase) return;
879
880 try {
881 this.errorEl.hidden = true;
882
883 // Get encrypted content
884 const encryptedEl = $('#encrypted-content');
885 if (!encryptedEl) throw new Error('No encrypted content found');
886
887 const encryptedData = JSON.parse(encryptedEl.textContent);
888 const { salt, iv, ciphertext, iterations } = encryptedData;
889 if (!salt || !iv || !ciphertext || !Number.isInteger(iterations) || iterations <= 0) {
890 throw new Error('Invalid encryption parameters');
891 }
892
893 // Derive key from password
894 const enc = new TextEncoder();
895 const keyMaterial = await crypto.subtle.importKey(
896 'raw',
897 enc.encode(passphrase),
898 'PBKDF2',
899 false,
900 ['deriveBits', 'deriveKey']
901 );
902
903 const key = await crypto.subtle.deriveKey(
904 {
905 name: 'PBKDF2',
906 salt: this.base64ToBytes(salt),
907 iterations: iterations,
908 hash: 'SHA-256'
909 },
910 keyMaterial,
911 { name: 'AES-GCM', length: 256 },
912 false,
913 ['decrypt']
914 );
915
916 // Decrypt
917 const decrypted = await crypto.subtle.decrypt(
918 {
919 name: 'AES-GCM',
920 iv: this.base64ToBytes(iv)
921 },
922 key,
923 this.base64ToBytes(ciphertext)
924 );
925
926 // Replace content
927 const dec = new TextDecoder();
928 const plaintext = dec.decode(decrypted);
929 const conversation = $('#conversation');
930 conversation.innerHTML = plaintext;
931
932 // Hide modal
933 this.modal.hidden = true;
934 this.form.reset();
935
936 // Re-initialize tool calls and popovers
937 if (typeof ToolCalls !== 'undefined') {
938 ToolCalls.init();
939 }
940 if (typeof ToolPopovers !== 'undefined') {
941 ToolPopovers.init();
942 }
943 if (typeof Search !== 'undefined') {
944 Search.init();
945 }
946 if (typeof WorldClass !== 'undefined') {
947 WorldClass.init();
948 }
949 if (typeof __cassAttachCodeCopyButtons === 'function') {
950 __cassAttachCodeCopyButtons();
951 }
952
953 } catch (e) {
954 this.errorEl.textContent = 'Decryption failed. Wrong password?';
955 this.errorEl.hidden = false;
956 }
957 },
958
959 base64ToBytes(base64) {
960 const binary = atob(base64);
961 const bytes = new Uint8Array(binary.length);
962 for (let i = 0; i < binary.length; i++) {
963 bytes[i] = binary.charCodeAt(i);
964 }
965 return bytes;
966 }
967};"#
968 .to_string()
969}
970
971fn generate_init_js(options: &ExportOptions) -> String {
972 let mut inits = Vec::new();
973
974 if options.include_search {
975 inits.push("try { Search.init(); } catch (e) { console.error('Search init failed', e); }");
976 }
977
978 if options.include_theme_toggle {
979 inits.push("try { Theme.init(); } catch (e) { console.error('Theme init failed', e); }");
980 }
981
982 if options.show_tool_calls {
983 inits.push(
984 "try { ToolCalls.init(); } catch (e) { console.error('ToolCalls init failed', e); }",
985 );
986 inits.push("try { ToolPopovers.init(); } catch (e) { console.error('ToolPopovers init failed', e); }");
987 }
988
989 if options.encrypt {
990 inits.push("try { Crypto.init(); } catch (e) { console.error('Crypto init failed', e); }");
991 }
992
993 inits.push(
995 "try { WorldClass.init(); } catch (e) { console.error('WorldClass init failed', e); }",
996 );
997
998 inits.push(
1000 "try { __cassAttachCodeCopyButtons(); } catch (e) { console.error('Code copy init failed', e); }",
1001 );
1002
1003 let copy_button_helpers = r#"// Add copy buttons to code blocks
1004// Idempotent so encrypted exports can re-run this after decrypting content.
1005const __cassAttachCodeCopyButtons = () => {
1006 $$('pre code').forEach((code) => {
1007 const pre = code.parentNode;
1008 if (!pre || pre.querySelector('.copy-code-btn')) return;
1009 const btn = document.createElement('button');
1010 btn.className = 'copy-code-btn';
1011 btn.innerHTML = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg>';
1012 btn.title = 'Copy code';
1013 btn.setAttribute('aria-label', 'Copy code');
1014 btn.onclick = () => copyCodeBlock(btn);
1015 btn.style.cssText = 'position:absolute;top:0.5rem;right:0.5rem;padding:0.25rem;background:var(--bg-surface);border:1px solid var(--border);border-radius:4px;color:var(--text-muted);cursor:pointer;transition:opacity 0.2s;';
1016 pre.style.position = 'relative';
1017 pre.appendChild(btn);
1018 });
1019};"#;
1020
1021 inits.push(
1022 r#"
1023 // Print button handler
1024 const printBtn = $('#print-btn');
1025 if (printBtn) printBtn.addEventListener('click', printConversation);
1026
1027 // Global keyboard shortcut: Ctrl/Cmd + P for print
1028 document.addEventListener('keydown', (e) => {
1029 if ((e.ctrlKey || e.metaKey) && e.key === 'p') {
1030 e.preventDefault();
1031 printConversation();
1032 }
1033 });"#,
1034 );
1035
1036 format!(
1037 r#"{}
1038
1039// Initialize after DOM is ready (or immediately if already ready)
1040const __cassInitAll = () => {{
1041 {}
1042}};
1043
1044if (document.readyState === 'loading') {{
1045 document.addEventListener('DOMContentLoaded', __cassInitAll);
1046}} else {{
1047 __cassInitAll();
1048}}"#,
1049 copy_button_helpers,
1050 inits.join("\n ")
1051 )
1052}
1053
1054#[cfg(test)]
1055mod tests {
1056 use super::*;
1057
1058 macro_rules! assert_inline_js_contains {
1059 ($bundle:expr, $needle:literal) => {
1060 assert!($bundle.inline_js.contains($needle));
1061 };
1062 }
1063
1064 #[test]
1065 fn test_generate_scripts_includes_search() {
1066 let opts = ExportOptions {
1067 include_search: true,
1068 ..Default::default()
1069 };
1070 let bundle = generate_scripts(&opts);
1071
1072 assert_inline_js_contains!(bundle, "const Search");
1073 assert_inline_js_contains!(bundle, "Search.init()");
1074 }
1075
1076 #[test]
1077 fn test_search_init_is_idempotent_for_decryption_reinit() {
1078 let opts = ExportOptions {
1079 encrypt: true,
1080 include_search: true,
1081 ..Default::default()
1082 };
1083 let bundle = generate_scripts(&opts);
1084
1085 assert_inline_js_contains!(bundle, "currentIndex: -1,\n _initialized: false");
1086 assert_inline_js_contains!(
1087 bundle,
1088 "if (!this._initialized) {\n this.input.addEventListener('input'"
1089 );
1090 assert_inline_js_contains!(bundle, "this._initialized = true;");
1091 assert_inline_js_contains!(bundle, "Search.init()");
1092 }
1093
1094 #[test]
1095 fn test_generate_scripts_excludes_search_when_disabled() {
1096 let opts = ExportOptions {
1097 include_search: false,
1098 ..Default::default()
1099 };
1100 let bundle = generate_scripts(&opts);
1101
1102 assert!(!bundle.inline_js.contains("const Search"));
1103 }
1104
1105 #[test]
1106 fn test_generate_scripts_includes_theme_toggle() {
1107 let opts = ExportOptions {
1108 include_theme_toggle: true,
1109 ..Default::default()
1110 };
1111 let bundle = generate_scripts(&opts);
1112
1113 assert_inline_js_contains!(bundle, "const Theme");
1114 assert_inline_js_contains!(bundle, "localStorage.getItem");
1115 }
1116
1117 #[test]
1118 fn test_generate_scripts_includes_encryption() {
1119 let opts = ExportOptions {
1120 encrypt: true,
1121 ..Default::default()
1122 };
1123 let bundle = generate_scripts(&opts);
1124
1125 assert_inline_js_contains!(bundle, "const Crypto");
1126 assert_inline_js_contains!(bundle, "crypto.subtle");
1127 }
1128
1129 #[test]
1130 fn test_generate_scripts_includes_toast_and_copy() {
1131 let opts = ExportOptions::default();
1132 let bundle = generate_scripts(&opts);
1133
1134 assert_inline_js_contains!(bundle, "const Toast");
1136 assert_inline_js_contains!(bundle, "Toast.show");
1137
1138 assert_inline_js_contains!(bundle, "copyToClipboard");
1140 assert_inline_js_contains!(bundle, "navigator.clipboard");
1141
1142 assert_inline_js_contains!(bundle, "execCommand");
1144 }
1145
1146 #[test]
1147 fn test_generate_scripts_includes_print_handler() {
1148 let opts = ExportOptions::default();
1149 let bundle = generate_scripts(&opts);
1150
1151 assert_inline_js_contains!(bundle, "printConversation");
1152 assert_inline_js_contains!(bundle, "window.print");
1153 }
1154
1155 #[test]
1156 fn test_generate_scripts_includes_keyboard_shortcuts() {
1157 let opts = ExportOptions {
1158 include_search: true,
1159 ..Default::default()
1160 };
1161 let bundle = generate_scripts(&opts);
1162
1163 assert_inline_js_contains!(bundle, "e.key === 'f'");
1165 assert_inline_js_contains!(bundle, "e.key === 'p'");
1167 assert_inline_js_contains!(bundle, "'Escape'");
1169 }
1170
1171 #[test]
1172 fn test_generate_scripts_includes_copy_code_buttons() {
1173 let opts = ExportOptions::default();
1174 let bundle = generate_scripts(&opts);
1175
1176 assert_inline_js_contains!(bundle, "copy-code-btn");
1177 assert_inline_js_contains!(bundle, "copyCodeBlock");
1178 }
1179
1180 #[test]
1181 fn test_generate_scripts_includes_world_class_enhancements() {
1182 let opts = ExportOptions::default();
1183 let bundle = generate_scripts(&opts);
1184
1185 assert_inline_js_contains!(bundle, "const WorldClass");
1187 assert_inline_js_contains!(bundle, "WorldClass.init()");
1188
1189 assert_inline_js_contains!(bundle, "scroll-progress");
1191
1192 assert_inline_js_contains!(bundle, "initFloatingNav");
1194 assert_inline_js_contains!(bundle, "scroll-top");
1195
1196 assert_inline_js_contains!(bundle, "initKeyboardNav");
1198 assert_inline_js_contains!(bundle, "case 'j':");
1199 assert_inline_js_contains!(bundle, "case 'k':");
1200
1201 assert_inline_js_contains!(bundle, "initMessageLinks");
1203 assert_inline_js_contains!(bundle, "message-link");
1204 assert_inline_js_contains!(bundle, "msg.querySelector('.message-link')");
1205
1206 assert_inline_js_contains!(bundle, "IntersectionObserver");
1208 assert_inline_js_contains!(bundle, "in-view");
1209
1210 assert_inline_js_contains!(bundle, "navigator.share");
1212
1213 assert_inline_js_contains!(bundle, "createRipple");
1215 }
1216
1217 #[test]
1218 fn test_world_class_keyboard_shortcuts() {
1219 let opts = ExportOptions::default();
1220 let bundle = generate_scripts(&opts);
1221
1222 assert_inline_js_contains!(bundle, "navigateMessage(1)"); assert_inline_js_contains!(bundle, "navigateMessage(-1)"); assert_inline_js_contains!(bundle, "case 'g':");
1228
1229 assert_inline_js_contains!(bundle, "case '/':");
1231
1232 assert_inline_js_contains!(bundle, "case '?':");
1234 assert_inline_js_contains!(bundle, "showShortcutsHint");
1235 }
1236
1237 #[test]
1238 fn test_tool_popovers_functionality() {
1239 let opts = ExportOptions {
1240 show_tool_calls: true,
1241 ..Default::default()
1242 };
1243 let bundle = generate_scripts(&opts);
1244
1245 assert_inline_js_contains!(bundle, "const ToolPopovers");
1247 assert_inline_js_contains!(bundle, "ToolPopovers.init()");
1248
1249 assert_inline_js_contains!(bundle, "mouseenter");
1251 assert_inline_js_contains!(bundle, "mouseleave");
1252
1253 assert_inline_js_contains!(bundle, "addEventListener('focus'");
1255 assert_inline_js_contains!(bundle, "addEventListener('blur'");
1256
1257 assert!(
1259 bundle
1260 .inline_js
1261 .contains("this.toggle(badge, getPopover())")
1262 );
1263
1264 assert_inline_js_contains!(bundle, "e.key === 'Escape'");
1266
1267 assert_inline_js_contains!(bundle, "setAttribute('aria-expanded'");
1269
1270 assert_inline_js_contains!(bundle, "getBoundingClientRect");
1272 assert_inline_js_contains!(bundle, "viewportWidth");
1273 assert_inline_js_contains!(bundle, "viewportHeight");
1274
1275 assert_inline_js_contains!(bundle, "initOverflowBadges");
1277 assert_inline_js_contains!(bundle, "tool-overflow");
1278 assert_inline_js_contains!(bundle, "btn.dataset.overflowBound");
1279
1280 assert_inline_js_contains!(bundle, "initOutsideClick");
1282 assert_inline_js_contains!(bundle, "hideAll");
1283 assert_inline_js_contains!(bundle, "_outsideClickBound");
1284
1285 assert_inline_js_contains!(bundle, "header.dataset.toolToggleBound");
1287 }
1288
1289 #[test]
1290 fn test_tool_popovers_reinit_after_decryption() {
1291 let opts = ExportOptions {
1292 encrypt: true,
1293 show_tool_calls: true,
1294 ..Default::default()
1295 };
1296 let bundle = generate_scripts(&opts);
1297
1298 assert_inline_js_contains!(bundle, "ToolCalls.init()");
1300 assert_inline_js_contains!(bundle, "ToolPopovers.init()");
1301 assert_inline_js_contains!(bundle, "__cassAttachCodeCopyButtons();");
1302 assert_inline_js_contains!(bundle, "const __cassAttachCodeCopyButtons");
1303 assert_inline_js_contains!(bundle, "pre.querySelector('.copy-code-btn')");
1304 }
1305}