typstify_generator/
static_assets.rs1use std::{fs, path::Path};
7
8use thiserror::Error;
9
10#[derive(Debug, Error)]
12pub enum StaticAssetError {
13 #[error("IO error: {0}")]
15 Io(#[from] std::io::Error),
16}
17
18pub type Result<T> = std::result::Result<T, StaticAssetError>;
20
21pub fn generate_static_assets(output_dir: &Path) -> Result<()> {
25 let assets_dir = output_dir.join("assets");
27 fs::create_dir_all(&assets_dir)?;
28
29 fs::write(assets_dir.join("style.css"), DEFAULT_CSS)?;
31
32 fs::write(assets_dir.join("main.js"), DEFAULT_JS)?;
34
35 Ok(())
36}
37
38pub const DEFAULT_CSS: &str = r#"/* CSS Variables for Light/Dark Themes */
41:root {
42 --color-primary: #3B82F6;
43 --color-primary-hover: #2563EB;
44 --color-secondary: #60A5FA;
45 --color-cta: #F97316;
46 --color-cta-hover: #EA580C;
47 --color-bg: #F8FAFC;
48 --color-bg-secondary: #FFFFFF;
49 --color-text: #1E293B;
50 --color-text-secondary: #475569;
51 --color-text-muted: #64748B;
52 --color-border: #E2E8F0;
53 --color-code-bg: #F1F5F9;
54 --shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.05);
55 --shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1);
56 color-scheme: light;
57}
58
59[data-theme="dark"] {
60 --color-primary: #60A5FA;
61 --color-primary-hover: #93C5FD;
62 --color-secondary: #3B82F6;
63 --color-cta: #FB923C;
64 --color-cta-hover: #FDBA74;
65 --color-bg: #0F172A;
66 --color-bg-secondary: #1E293B;
67 --color-text: #F1F5F9;
68 --color-text-secondary: #CBD5E1;
69 --color-text-muted: #94A3B8;
70 --color-border: #334155;
71 --color-code-bg: #1E293B;
72 --shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.3);
73 --shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.4), 0 2px 4px -2px rgb(0 0 0 / 0.3);
74 color-scheme: dark;
75}
76
77@media (prefers-color-scheme: dark) {
78 :root:not([data-theme="light"]) {
79 --color-primary: #60A5FA;
80 --color-primary-hover: #93C5FD;
81 --color-secondary: #3B82F6;
82 --color-cta: #FB923C;
83 --color-cta-hover: #FDBA74;
84 --color-bg: #0F172A;
85 --color-bg-secondary: #1E293B;
86 --color-text: #F1F5F9;
87 --color-text-secondary: #CBD5E1;
88 --color-text-muted: #94A3B8;
89 --color-border: #334155;
90 --color-code-bg: #1E293B;
91 --shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.3);
92 --shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.4), 0 2px 4px -2px rgb(0 0 0 / 0.3);
93 color-scheme: dark;
94 }
95}
96
97/* Reset & Base */
98*, *::before, *::after { box-sizing: border-box; }
99* { margin: 0; padding: 0; }
100
101html {
102 font-size: 16px;
103 -webkit-font-smoothing: antialiased;
104 -moz-osx-font-smoothing: grayscale;
105}
106
107body {
108 font-family: 'Inter', system-ui, -apple-system, sans-serif;
109 font-weight: 400;
110 line-height: 1.7;
111 color: var(--color-text);
112 background-color: var(--color-bg);
113 min-height: 100vh;
114 display: flex;
115 flex-direction: column;
116 transition: background-color 0.2s ease, color 0.2s ease;
117}
118
119/* Layout */
120.container {
121 width: 100%;
122 max-width: 720px;
123 margin: 0 auto;
124 padding: 0 1.5rem;
125}
126
127/* Header */
128header {
129 position: sticky;
130 top: 0;
131 z-index: 50;
132 background-color: var(--color-bg);
133 border-bottom: 1px solid var(--color-border);
134 backdrop-filter: blur(8px);
135 -webkit-backdrop-filter: blur(8px);
136 background-color: rgba(248, 250, 252, 0.9);
137}
138
139[data-theme="dark"] header {
140 background-color: rgba(15, 23, 42, 0.9);
141}
142
143@media (prefers-color-scheme: dark) {
144 :root:not([data-theme="light"]) header {
145 background-color: rgba(15, 23, 42, 0.9);
146 }
147}
148
149header nav {
150 display: flex;
151 align-items: center;
152 justify-content: space-between;
153 padding: 1rem 0;
154}
155
156.site-title {
157 font-size: 1.125rem;
158 font-weight: 600;
159 color: var(--color-text);
160 text-decoration: none;
161 letter-spacing: -0.025em;
162 transition: color 0.2s ease;
163}
164
165.site-title:hover {
166 color: var(--color-primary);
167}
168
169.nav-links {
170 display: flex;
171 align-items: center;
172 gap: 1.5rem;
173}
174
175.nav-links a {
176 font-size: 0.875rem;
177 font-weight: 500;
178 color: var(--color-text-secondary);
179 text-decoration: none;
180 transition: color 0.2s ease;
181}
182
183.nav-links a:hover {
184 color: var(--color-primary);
185}
186
187/* Theme Toggle Button */
188.theme-toggle {
189 display: flex;
190 align-items: center;
191 justify-content: center;
192 width: 2.25rem;
193 height: 2.25rem;
194 border-radius: 0.5rem;
195 border: 1px solid var(--color-border);
196 background-color: var(--color-bg-secondary);
197 cursor: pointer;
198 transition: all 0.2s ease;
199}
200
201.theme-toggle:hover {
202 border-color: var(--color-primary);
203 background-color: var(--color-bg);
204}
205
206.theme-toggle svg {
207 width: 1.125rem;
208 height: 1.125rem;
209 color: var(--color-text-secondary);
210}
211
212.theme-toggle .icon-sun { display: none; }
213.theme-toggle .icon-moon { display: block; }
214
215[data-theme="dark"] .theme-toggle .icon-sun { display: block; }
216[data-theme="dark"] .theme-toggle .icon-moon { display: none; }
217
218@media (prefers-color-scheme: dark) {
219 :root:not([data-theme="light"]) .theme-toggle .icon-sun { display: block; }
220 :root:not([data-theme="light"]) .theme-toggle .icon-moon { display: none; }
221}
222
223/* Language Switcher */
224.lang-switcher {
225 position: relative;
226 display: flex;
227 align-items: center;
228 justify-content: center;
229 width: 2.25rem;
230 height: 2.25rem;
231 border-radius: 0.5rem;
232 border: 1px solid var(--color-border);
233 background-color: var(--color-bg-secondary);
234 cursor: pointer;
235 transition: all 0.2s ease;
236 outline: none;
237}
238
239.lang-switcher .lang-code {
240 font-size: 0.6875rem;
241 font-weight: 600;
242 color: var(--color-text-secondary);
243 text-transform: uppercase;
244 letter-spacing: 0.025em;
245}
246
247.lang-switcher:hover,
248.lang-switcher:focus {
249 border-color: var(--color-primary);
250 background-color: var(--color-bg);
251}
252
253.lang-switcher .lang-dropdown {
254 display: none;
255 position: absolute;
256 top: calc(100% + 0.5rem);
257 right: 0;
258 min-width: 8rem;
259 background-color: var(--color-bg-secondary);
260 border: 1px solid var(--color-border);
261 border-radius: 0.5rem;
262 box-shadow: var(--shadow-md);
263 overflow: hidden;
264 z-index: 100;
265}
266
267.lang-switcher:focus-within .lang-dropdown,
268.lang-switcher:hover .lang-dropdown {
269 display: block;
270}
271
272.lang-dropdown a {
273 display: flex;
274 align-items: center;
275 gap: 0.5rem;
276 padding: 0.625rem 0.875rem;
277 font-size: 0.8125rem;
278 color: var(--color-text);
279 text-decoration: none;
280 transition: background-color 0.15s ease;
281}
282
283.lang-dropdown a:hover {
284 background-color: var(--color-bg);
285}
286
287.lang-dropdown a.active {
288 color: var(--color-primary);
289 font-weight: 500;
290}
291
292.lang-dropdown .lang-name {
293 flex: 1;
294}
295
296.lang-dropdown .lang-flag {
297 font-size: 1rem;
298}
299
300/* Navigation Actions */
301.nav-actions {
302 display: flex;
303 align-items: center;
304 gap: 0.5rem;
305}
306
307/* Search Styles */
308.search-wrapper {
309 position: relative;
310 display: flex;
311 align-items: center;
312}
313
314.search-input {
315 width: 0;
316 padding: 0;
317 border: none;
318 background: transparent;
319 opacity: 0;
320 transition: all 0.2s ease;
321 font-size: 0.875rem;
322 color: var(--color-text);
323}
324
325.search-wrapper.active .search-input {
326 width: 150px;
327 padding: 0.5rem 0.75rem;
328 padding-right: 2.25rem;
329 opacity: 1;
330 border: 1px solid var(--color-border);
331 border-radius: 0.5rem;
332 background-color: var(--color-bg-secondary);
333}
334
335.search-wrapper.active .search-input:focus {
336 outline: none;
337 border-color: var(--color-primary);
338}
339
340.search-btn {
341 display: flex;
342 align-items: center;
343 justify-content: center;
344 width: 2.25rem;
345 height: 2.25rem;
346 border-radius: 0.5rem;
347 border: 1px solid var(--color-border);
348 background-color: var(--color-bg-secondary);
349 cursor: pointer;
350 transition: all 0.2s ease;
351 position: relative;
352 z-index: 1;
353}
354
355.search-wrapper.active .search-btn {
356 position: absolute;
357 right: 0;
358 border: none;
359 background: transparent;
360 width: 2rem;
361 height: 100%;
362}
363
364.search-btn:hover {
365 border-color: var(--color-primary);
366 background-color: var(--color-bg);
367}
368
369.search-wrapper.active .search-btn:hover {
370 border-color: transparent;
371 background-color: transparent;
372}
373
374.search-btn svg {
375 width: 1.125rem;
376 height: 1.125rem;
377 color: var(--color-text-secondary);
378}
379
380.search-results {
381 display: none;
382 position: absolute;
383 top: calc(100% + 0.5rem);
384 right: 0;
385 width: 300px;
386 max-height: 400px;
387 overflow-y: auto;
388 background-color: var(--color-bg-secondary);
389 border: 1px solid var(--color-border);
390 border-radius: 0.5rem;
391 box-shadow: var(--shadow-md);
392 z-index: 100;
393}
394
395.search-results.show {
396 display: block;
397}
398
399.search-result-item {
400 display: block;
401 padding: 0.75rem 1rem;
402 border-bottom: 1px solid var(--color-border);
403 text-decoration: none;
404 transition: background-color 0.15s ease;
405}
406
407.search-result-item:last-child {
408 border-bottom: none;
409}
410
411.search-result-item:hover {
412 background-color: var(--color-bg);
413}
414
415.search-result-title {
416 font-size: 0.875rem;
417 font-weight: 500;
418 color: var(--color-text);
419 margin-bottom: 0.25rem;
420}
421
422.search-result-snippet {
423 font-size: 0.75rem;
424 color: var(--color-text-muted);
425 line-height: 1.4;
426 display: -webkit-box;
427 -webkit-line-clamp: 2;
428 -webkit-box-orient: vertical;
429 overflow: hidden;
430}
431
432.search-no-results {
433 padding: 1rem;
434 text-align: center;
435 color: var(--color-text-muted);
436 font-size: 0.875rem;
437}
438
439/* Main Content */
440main {
441 flex: 1;
442 padding: 3rem 0;
443}
444
445/* Footer */
446footer {
447 padding: 2rem 0;
448 border-top: 1px solid var(--color-border);
449 color: var(--color-text-muted);
450 font-size: 0.875rem;
451}
452
453footer a {
454 color: var(--color-primary);
455 text-decoration: none;
456}
457
458footer a:hover {
459 text-decoration: underline;
460}
461
462/* Typography */
463h1, h2, h3, h4, h5, h6 {
464 font-weight: 600;
465 line-height: 1.3;
466 color: var(--color-text);
467}
468
469h1 { font-size: 2rem; letter-spacing: -0.025em; }
470h2 { font-size: 1.5rem; margin-top: 2rem; margin-bottom: 1rem; }
471h3 { font-size: 1.25rem; margin-top: 1.5rem; margin-bottom: 0.75rem; }
472
473p {
474 margin-bottom: 1.25rem;
475}
476
477a {
478 color: var(--color-primary);
479 transition: color 0.2s ease;
480}
481
482a:hover {
483 color: var(--color-primary-hover);
484}
485
486/* Code */
487code {
488 font-family: 'JetBrains Mono', 'Fira Code', monospace;
489 font-size: 0.875em;
490 background-color: var(--color-code-bg);
491 padding: 0.125rem 0.375rem;
492 border-radius: 0.25rem;
493}
494
495pre {
496 background-color: var(--color-code-bg);
497 padding: 1rem;
498 border-radius: 0.5rem;
499 overflow-x: auto;
500 margin: 1.5rem 0;
501}
502
503pre code {
504 background: none;
505 padding: 0;
506 font-size: 0.8125rem;
507 line-height: 1.6;
508}
509
510/* Article */
511article.post header,
512article.page h1 {
513 margin-bottom: 2rem;
514}
515
516article.post header h1 {
517 margin-bottom: 0.5rem;
518}
519
520article.post header time {
521 display: block;
522 color: var(--color-text-muted);
523 font-size: 0.875rem;
524}
525
526article .content {
527 font-size: 1rem;
528}
529
530article .content img {
531 max-width: 100%;
532 height: auto;
533 border-radius: 0.5rem;
534 margin: 1.5rem 0;
535}
536
537article .content blockquote {
538 border-left: 3px solid var(--color-primary);
539 padding-left: 1rem;
540 margin: 1.5rem 0;
541 color: var(--color-text-secondary);
542 font-style: italic;
543}
544
545article .content ul,
546article .content ol {
547 margin: 1rem 0 1.5rem 1.5rem;
548}
549
550article .content li {
551 margin-bottom: 0.5rem;
552}
553
554/* Tags */
555.tags {
556 display: flex;
557 flex-wrap: wrap;
558 gap: 0.5rem;
559 margin-top: 0.75rem;
560}
561
562.tag {
563 display: inline-flex;
564 align-items: center;
565 padding: 0.25rem 0.625rem;
566 font-size: 0.75rem;
567 font-weight: 500;
568 color: var(--color-primary);
569 background-color: color-mix(in srgb, var(--color-primary) 10%, transparent);
570 border-radius: 9999px;
571 text-decoration: none;
572 transition: all 0.2s ease;
573}
574
575.tag:hover {
576 background-color: color-mix(in srgb, var(--color-primary) 20%, transparent);
577}
578
579/* Post List */
580.post-list ul {
581 list-style: none;
582}
583
584.post-list li {
585 padding: 1.25rem 0;
586 border-bottom: 1px solid var(--color-border);
587}
588
589.post-list li:first-child {
590 padding-top: 0;
591}
592
593.post-item-header {
594 display: flex;
595 align-items: baseline;
596 justify-content: space-between;
597 gap: 1rem;
598 margin-bottom: 0.375rem;
599}
600
601.post-item-header a {
602 font-size: 1.0625rem;
603 font-weight: 500;
604 color: var(--color-text);
605 text-decoration: none;
606 transition: color 0.15s ease;
607}
608
609.post-item-header a:hover {
610 color: var(--color-primary);
611}
612
613.post-item-header time {
614 flex-shrink: 0;
615 font-size: 0.8125rem;
616 color: var(--color-text-muted);
617}
618
619.post-item-description {
620 font-size: 0.875rem;
621 color: var(--color-text-secondary);
622 line-height: 1.5;
623}
624
625/* Pagination */
626.pagination {
627 display: flex;
628 justify-content: center;
629 align-items: center;
630 gap: 0.5rem;
631 margin-top: 2rem;
632 padding-top: 1.5rem;
633 border-top: 1px solid var(--color-border);
634}
635
636.pagination a,
637.pagination span {
638 display: flex;
639 align-items: center;
640 justify-content: center;
641 min-width: 2.25rem;
642 height: 2.25rem;
643 padding: 0 0.75rem;
644 font-size: 0.875rem;
645 border-radius: 0.375rem;
646 text-decoration: none;
647 transition: all 0.15s ease;
648}
649
650.pagination a {
651 color: var(--color-text-secondary);
652 background-color: var(--color-bg-secondary);
653 border: 1px solid var(--color-border);
654}
655
656.pagination a:hover {
657 color: var(--color-primary);
658 border-color: var(--color-primary);
659}
660
661.pagination span.current {
662 color: white;
663 background-color: var(--color-primary);
664 border: 1px solid var(--color-primary);
665}
666
667.pagination span.ellipsis {
668 color: var(--color-text-muted);
669 border: none;
670 background: none;
671}
672
673/* Tags Cloud */
674.tags-cloud {
675 display: flex;
676 flex-wrap: wrap;
677 gap: 0.5rem;
678}
679
680.tags-cloud a {
681 display: inline-flex;
682 align-items: center;
683 gap: 0.375rem;
684 padding: 0.375rem 0.75rem;
685 font-size: 0.875rem;
686 color: var(--color-text);
687 background-color: var(--color-bg-secondary);
688 border: 1px solid var(--color-border);
689 border-radius: 9999px;
690 text-decoration: none;
691 transition: all 0.2s ease;
692}
693
694.tags-cloud a:hover {
695 color: var(--color-primary);
696 border-color: var(--color-primary);
697}
698
699.tags-cloud .count {
700 font-size: 0.75rem;
701 color: var(--color-text-muted);
702}
703
704/* Categories List */
705.categories-list {
706 list-style: none;
707 padding: 0;
708}
709
710.categories-list li {
711 padding: 0.75rem 0;
712 border-bottom: 1px solid var(--color-border);
713}
714
715.categories-list li:first-child {
716 padding-top: 0;
717}
718
719.categories-list .count {
720 color: var(--color-text-muted);
721 font-size: 0.875rem;
722}
723
724/* Archives */
725.archives h1 {
726 margin-bottom: 2rem;
727}
728
729.archive-year {
730 margin-bottom: 2rem;
731}
732
733.archive-year h2 {
734 font-size: 1.25rem;
735 margin-bottom: 0.75rem;
736 color: var(--color-primary);
737}
738
739.archive-year ul {
740 list-style: none;
741 padding: 0;
742}
743
744.archive-year li {
745 display: flex;
746 align-items: baseline;
747 gap: 1rem;
748 padding: 0.5rem 0;
749}
750
751.archive-date {
752 flex-shrink: 0;
753 font-size: 0.8125rem;
754 color: var(--color-text-muted);
755 font-variant-numeric: tabular-nums;
756 min-width: 3rem;
757}
758
759.archive-year li a {
760 color: var(--color-text);
761 text-decoration: none;
762 transition: color 0.15s ease;
763}
764
765.archive-year li a:hover {
766 color: var(--color-primary);
767}
768
769/* Section List */
770.section-list h1 {
771 margin-bottom: 0.5rem;
772}
773
774.section-description {
775 color: var(--color-text-muted);
776 margin-bottom: 1.5rem;
777}
778
779/* Responsive */
780@media (max-width: 640px) {
781 html { font-size: 15px; }
782 h1 { font-size: 1.75rem; }
783 h2 { font-size: 1.375rem; }
784 .container { padding: 0 1rem; }
785 main { padding: 2rem 0; }
786 .nav-links { gap: 0.75rem; }
787 .nav-links > a { font-size: 0.8125rem; }
788 .post-item-header { flex-direction: column; gap: 0.25rem; }
789 .nav-actions { gap: 0.375rem; }
790}
791
792/* Reduced Motion */
793@media (prefers-reduced-motion: reduce) {
794 *, *::before, *::after {
795 animation-duration: 0.01ms !important;
796 animation-iteration-count: 1 !important;
797 transition-duration: 0.01ms !important;
798 }
799}
800"#;
801
802pub const DEFAULT_JS: &str = r#"// Prevent duplicate initialization
805if (window.__typstifyInit) { throw new Error('already initialized'); }
806window.__typstifyInit = true;
807
808// Global cleanup controller
809const cleanupController = new AbortController();
810const { signal } = cleanupController;
811
812// Cleanup on page unload to prevent memory leaks
813window.addEventListener('pagehide', () => cleanupController.abort());
814window.addEventListener('beforeunload', () => cleanupController.abort());
815
816// Theme toggle functionality
817(function() {
818 const toggle = document.querySelector('.theme-toggle');
819 if (!toggle) return;
820 const html = document.documentElement;
821
822 function getTheme() {
823 const saved = localStorage.getItem('theme');
824 if (saved) return saved;
825 return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
826 }
827
828 function setTheme(theme) {
829 html.setAttribute('data-theme', theme);
830 localStorage.setItem('theme', theme);
831 }
832
833 setTheme(getTheme());
834
835 toggle.addEventListener('click', () => {
836 const current = html.getAttribute('data-theme') || getTheme();
837 setTheme(current === 'dark' ? 'light' : 'dark');
838 }, { signal });
839
840 window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', (e) => {
841 if (!localStorage.getItem('theme')) {
842 setTheme(e.matches ? 'dark' : 'light');
843 }
844 }, { signal });
845})();
846
847// Search functionality
848(function() {
849 const wrapper = document.getElementById('searchWrapper');
850 const btn = document.getElementById('searchBtn');
851 const input = document.getElementById('searchInput');
852 const results = document.getElementById('searchResults');
853 if (!wrapper || !btn || !input || !results) return;
854
855 let searchIndex = null;
856 let isLoading = false;
857 let debounceTimer = null;
858
859 // Clear debounce on cleanup
860 signal.addEventListener('abort', () => clearTimeout(debounceTimer));
861
862 btn.addEventListener('click', (e) => {
863 e.stopPropagation();
864 if (wrapper.classList.contains('active')) {
865 if (input.value.trim()) {
866 performSearch(input.value.trim());
867 }
868 } else {
869 wrapper.classList.add('active');
870 input.focus();
871 loadSearchIndex();
872 }
873 }, { signal });
874
875 document.addEventListener('click', (e) => {
876 if (!wrapper.contains(e.target)) {
877 wrapper.classList.remove('active');
878 results.classList.remove('show');
879 }
880 }, { signal });
881
882 input.addEventListener('input', () => {
883 clearTimeout(debounceTimer);
884 debounceTimer = setTimeout(() => {
885 const query = input.value.trim();
886 if (query.length >= 1) {
887 performSearch(query);
888 } else {
889 results.classList.remove('show');
890 }
891 }, 150);
892 }, { signal });
893
894 input.addEventListener('keydown', (e) => {
895 if (e.key === 'Enter') {
896 e.preventDefault();
897 performSearch(input.value.trim());
898 } else if (e.key === 'Escape') {
899 wrapper.classList.remove('active');
900 results.classList.remove('show');
901 }
902 }, { signal });
903
904 async function loadSearchIndex() {
905 if (searchIndex || isLoading) return;
906 isLoading = true;
907 try {
908 const pathParts = window.location.pathname.split('/').filter(Boolean);
909 const langPrefix = pathParts.length > 0 && pathParts[0].length === 2 ? pathParts[0] : '';
910 const indexPath = langPrefix ? `/${langPrefix}/search-index.json` : '/search-index.json';
911
912 const response = await fetch(indexPath, { signal });
913 if (response.ok) {
914 searchIndex = await response.json();
915 }
916 } catch (err) {
917 if (err.name !== 'AbortError') {
918 console.log('Search index not available');
919 }
920 }
921 isLoading = false;
922 }
923
924 function performSearch(query) {
925 if (!searchIndex || !searchIndex.documents) {
926 results.innerHTML = '<div class="search-no-results">Search is loading...</div>';
927 results.classList.add('show');
928 return;
929 }
930
931 const q = query.toLowerCase();
932 const matches = searchIndex.documents.filter(doc => {
933 const title = doc.title.toLowerCase();
934 const desc = (doc.description || '').toLowerCase();
935 const terms = doc.terms || [];
936
937 if (title.includes(q) || desc.includes(q)) return true;
938 if (terms.some(t => t.includes(q) || q.includes(t))) return true;
939 return false;
940 }).slice(0, 10);
941
942 if (matches.length === 0) {
943 results.innerHTML = '<div class="search-no-results">No results found</div>';
944 } else {
945 results.innerHTML = matches.map(doc =>
946 `<a href="${doc.url}" class="search-result-item">
947 <div class="search-result-title">${escapeHtml(doc.title)}</div>
948 ${doc.description ? `<div class="search-result-snippet">${escapeHtml(doc.description)}</div>` : ''}
949 </a>`
950 ).join('');
951 }
952 results.classList.add('show');
953 }
954
955 function escapeHtml(text) {
956 const div = document.createElement('div');
957 div.textContent = text;
958 return div.innerHTML;
959 }
960})();
961"#;
962
963#[cfg(test)]
964mod tests {
965 use tempfile::TempDir;
966
967 use super::*;
968
969 #[test]
970 fn test_generate_static_assets() {
971 let temp_dir = TempDir::new().unwrap();
972 let output_dir = temp_dir.path();
973
974 generate_static_assets(output_dir).unwrap();
975
976 let css_path = output_dir.join("assets/style.css");
978 assert!(css_path.exists());
979 let css_content = std::fs::read_to_string(&css_path).unwrap();
980 assert!(css_content.contains(":root"));
981 assert!(css_content.contains("--color-primary"));
982
983 let js_path = output_dir.join("assets/main.js");
985 assert!(js_path.exists());
986 let js_content = std::fs::read_to_string(&js_path).unwrap();
987 assert!(js_content.contains("theme-toggle"));
988 assert!(js_content.contains("searchIndex"));
989 }
990}