typstify_generator/
static_assets.rs

1//! Static asset generation for CSS and JavaScript.
2//!
3//! Generates external CSS and JS files that can be cached by browsers,
4//! improving page load performance significantly.
5
6use std::{fs, path::Path};
7
8use thiserror::Error;
9
10/// Static asset generation errors.
11#[derive(Debug, Error)]
12pub enum StaticAssetError {
13    /// IO error.
14    #[error("IO error: {0}")]
15    Io(#[from] std::io::Error),
16}
17
18/// Result type for static asset operations.
19pub type Result<T> = std::result::Result<T, StaticAssetError>;
20
21/// Generate static CSS and JS files in the output directory.
22///
23/// These files are referenced by the HTML templates and cached by browsers.
24pub fn generate_static_assets(output_dir: &Path) -> Result<()> {
25    // Create assets directory
26    let assets_dir = output_dir.join("assets");
27    fs::create_dir_all(&assets_dir)?;
28
29    // Write CSS file
30    fs::write(assets_dir.join("style.css"), DEFAULT_CSS)?;
31
32    // Write JS file
33    fs::write(assets_dir.join("main.js"), DEFAULT_JS)?;
34
35    Ok(())
36}
37
38/// Default CSS styles.
39/// Extracted from the inline styles in template.rs for better caching.
40pub 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
802/// Default JavaScript for theme toggle, search, and language switcher.
803/// Extracted from inline scripts for better caching.
804pub 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        // Check CSS file exists
977        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        // Check JS file exists
984        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}