Skip to main content

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/* Shorts - Minimalist Layout */
626.shorts-section {
627    margin: 2rem 0;
628}
629
630.shorts-section h1 {
631    margin-bottom: 1rem;
632}
633
634.shorts-section .section-description {
635    color: var(--color-text-secondary);
636    margin-bottom: 2rem;
637}
638
639.short-list {
640    display: flex;
641    flex-direction: column;
642}
643
644.short-item {
645    padding: 1rem 0;
646    border-bottom: 1px solid var(--color-border);
647}
648
649.short-item:last-child {
650    border-bottom: none;
651}
652
653.short-date {
654    display: block;
655    font-size: 0.875rem;
656    color: var(--color-text-muted);
657    margin-bottom: 0.25rem;
658}
659
660.short-content {
661    font-size: 1rem;
662    color: var(--color-text-secondary);
663    line-height: 1.7;
664    white-space: pre-wrap;
665    word-wrap: break-word;
666}
667
668.short-content a {
669    color: var(--color-primary);
670    text-decoration: none;
671}
672
673.short-content a:hover {
674    text-decoration: underline;
675}
676
677/* Pagination */
678.pagination {
679    display: flex;
680    justify-content: center;
681    align-items: center;
682    gap: 0.5rem;
683    margin-top: 2rem;
684    padding-top: 1.5rem;
685    border-top: 1px solid var(--color-border);
686}
687
688.pagination a,
689.pagination span {
690    display: flex;
691    align-items: center;
692    justify-content: center;
693    min-width: 2.25rem;
694    height: 2.25rem;
695    padding: 0 0.75rem;
696    font-size: 0.875rem;
697    border-radius: 0.375rem;
698    text-decoration: none;
699    transition: all 0.15s ease;
700}
701
702.pagination a {
703    color: var(--color-text-secondary);
704    background-color: var(--color-bg-secondary);
705    border: 1px solid var(--color-border);
706}
707
708.pagination a:hover {
709    color: var(--color-primary);
710    border-color: var(--color-primary);
711}
712
713.pagination span.current {
714    color: white;
715    background-color: var(--color-primary);
716    border: 1px solid var(--color-primary);
717}
718
719.pagination span.ellipsis {
720    color: var(--color-text-muted);
721    border: none;
722    background: none;
723}
724
725/* Tags Cloud */
726.tags-cloud {
727    display: flex;
728    flex-wrap: wrap;
729    gap: 0.5rem;
730}
731
732.tags-cloud a {
733    display: inline-flex;
734    align-items: center;
735    gap: 0.375rem;
736    padding: 0.375rem 0.75rem;
737    font-size: 0.875rem;
738    color: var(--color-text);
739    background-color: var(--color-bg-secondary);
740    border: 1px solid var(--color-border);
741    border-radius: 9999px;
742    text-decoration: none;
743    transition: all 0.2s ease;
744}
745
746.tags-cloud a:hover {
747    color: var(--color-primary);
748    border-color: var(--color-primary);
749}
750
751.tags-cloud .count {
752    font-size: 0.75rem;
753    color: var(--color-text-muted);
754}
755
756/* Categories List */
757.categories-list {
758    list-style: none;
759    padding: 0;
760}
761
762.categories-list li {
763    padding: 0.75rem 0;
764    border-bottom: 1px solid var(--color-border);
765}
766
767.categories-list li:first-child {
768    padding-top: 0;
769}
770
771.categories-list .count {
772    color: var(--color-text-muted);
773    font-size: 0.875rem;
774}
775
776/* Archives */
777.archives h1 {
778    margin-bottom: 2rem;
779}
780
781.archive-year {
782    margin-bottom: 2rem;
783}
784
785.archive-year h2 {
786    font-size: 1.25rem;
787    margin-bottom: 0.75rem;
788    color: var(--color-primary);
789}
790
791.archive-year ul {
792    list-style: none;
793    padding: 0;
794}
795
796.archive-year li {
797    display: flex;
798    align-items: center;
799    gap: 0.5rem;
800    padding: 0.5rem 0;
801}
802
803.archive-badge {
804    display: inline-flex;
805    align-items: center;
806    padding: 0.125rem 0.5rem;
807    margin-right: 0.5rem;
808    border-radius: 0.25rem;
809    font-size: 0.75rem;
810    font-weight: 700;
811    text-transform: uppercase;
812    color: white;
813}
814
815.badge-post {
816    background-color: #16a34a;
817}
818
819[data-theme="dark"] .badge-post {
820    background-color: #22c55e;
821}
822
823.badge-short {
824    background-color: #2563eb;
825}
826
827[data-theme="dark"] .badge-short {
828    background-color: #3b82f6;
829}
830
831.archive-date {
832    flex-shrink: 0;
833    font-size: 0.8125rem;
834    color: var(--color-text-muted);
835    font-variant-numeric: tabular-nums;
836    min-width: 3rem;
837}
838
839.archive-year li a {
840    flex: 1;
841    color: var(--color-text);
842    text-decoration: none;
843    transition: color 0.15s ease;
844}
845
846.archive-year li a:hover {
847    color: var(--color-primary);
848}
849
850/* Section List */
851.section-list h1 {
852    margin-bottom: 0.5rem;
853}
854
855.section-description {
856    color: var(--color-text-muted);
857    margin-bottom: 1.5rem;
858}
859
860/* Responsive */
861@media (max-width: 640px) {
862    html { font-size: 15px; }
863    h1 { font-size: 1.75rem; }
864    h2 { font-size: 1.375rem; }
865    .container { padding: 0 1rem; }
866    main { padding: 2rem 0; }
867    .nav-links { gap: 0.75rem; }
868    .nav-links > a { font-size: 0.8125rem; }
869    .post-item-header { flex-direction: column; gap: 0.25rem; }
870    .nav-actions { gap: 0.375rem; }
871}
872
873/* Reduced Motion */
874@media (prefers-reduced-motion: reduce) {
875    *, *::before, *::after {
876        animation-duration: 0.01ms !important;
877        animation-iteration-count: 1 !important;
878        transition-duration: 0.01ms !important;
879    }
880}
881"#;
882
883/// Default JavaScript for theme toggle, search, and language switcher.
884/// Extracted from inline scripts for better caching.
885pub const DEFAULT_JS: &str = r#"// Prevent duplicate initialization
886if (window.__typstifyInit) { throw new Error('already initialized'); }
887window.__typstifyInit = true;
888
889// Global cleanup controller
890const cleanupController = new AbortController();
891const { signal } = cleanupController;
892
893// Cleanup on page unload to prevent memory leaks
894window.addEventListener('pagehide', () => cleanupController.abort());
895window.addEventListener('beforeunload', () => cleanupController.abort());
896
897// Theme toggle functionality
898(function() {
899    const toggle = document.querySelector('.theme-toggle');
900    if (!toggle) return;
901    const html = document.documentElement;
902
903    function getTheme() {
904        const saved = localStorage.getItem('theme');
905        if (saved) return saved;
906        return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
907    }
908
909    function setTheme(theme) {
910        html.setAttribute('data-theme', theme);
911        localStorage.setItem('theme', theme);
912    }
913
914    setTheme(getTheme());
915
916    toggle.addEventListener('click', () => {
917        const current = html.getAttribute('data-theme') || getTheme();
918        setTheme(current === 'dark' ? 'light' : 'dark');
919    }, { signal });
920
921    window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', (e) => {
922        if (!localStorage.getItem('theme')) {
923            setTheme(e.matches ? 'dark' : 'light');
924        }
925    }, { signal });
926})();
927
928// Search functionality
929(function() {
930    const wrapper = document.getElementById('searchWrapper');
931    const btn = document.getElementById('searchBtn');
932    const input = document.getElementById('searchInput');
933    const results = document.getElementById('searchResults');
934    if (!wrapper || !btn || !input || !results) return;
935
936    let searchIndex = null;
937    let isLoading = false;
938    let debounceTimer = null;
939
940    // Clear debounce on cleanup
941    signal.addEventListener('abort', () => clearTimeout(debounceTimer));
942
943    btn.addEventListener('click', (e) => {
944        e.stopPropagation();
945        if (wrapper.classList.contains('active')) {
946            if (input.value.trim()) {
947                performSearch(input.value.trim());
948            }
949        } else {
950            wrapper.classList.add('active');
951            input.focus();
952            loadSearchIndex();
953        }
954    }, { signal });
955
956    document.addEventListener('click', (e) => {
957        if (!wrapper.contains(e.target)) {
958            wrapper.classList.remove('active');
959            results.classList.remove('show');
960        }
961    }, { signal });
962
963    input.addEventListener('input', () => {
964        clearTimeout(debounceTimer);
965        debounceTimer = setTimeout(() => {
966            const query = input.value.trim();
967            if (query.length >= 1) {
968                performSearch(query);
969            } else {
970                results.classList.remove('show');
971            }
972        }, 150);
973    }, { signal });
974
975    input.addEventListener('keydown', (e) => {
976        if (e.key === 'Enter') {
977            e.preventDefault();
978            performSearch(input.value.trim());
979        } else if (e.key === 'Escape') {
980            wrapper.classList.remove('active');
981            results.classList.remove('show');
982        }
983    }, { signal });
984
985    async function loadSearchIndex() {
986        if (searchIndex || isLoading) return;
987        isLoading = true;
988        try {
989            const pathParts = window.location.pathname.split('/').filter(Boolean);
990            const langPrefix = pathParts.length > 0 && pathParts[0].length === 2 ? pathParts[0] : '';
991            const indexPath = langPrefix ? `/${langPrefix}/search-index.json` : '/search-index.json';
992            
993            const response = await fetch(indexPath, { signal });
994            if (response.ok) {
995                searchIndex = await response.json();
996            }
997        } catch (err) {
998            if (err.name !== 'AbortError') {
999                console.log('Search index not available');
1000            }
1001        }
1002        isLoading = false;
1003    }
1004
1005    function performSearch(query) {
1006        if (!searchIndex || !searchIndex.documents) {
1007            results.innerHTML = '<div class="search-no-results">Search is loading...</div>';
1008            results.classList.add('show');
1009            return;
1010        }
1011
1012        const q = query.toLowerCase();
1013        const matches = searchIndex.documents.filter(doc => {
1014            const title = doc.title.toLowerCase();
1015            const desc = (doc.description || '').toLowerCase();
1016            const terms = doc.terms || [];
1017            
1018            if (title.includes(q) || desc.includes(q)) return true;
1019            if (terms.some(t => t.includes(q) || q.includes(t))) return true;
1020            return false;
1021        }).slice(0, 10);
1022
1023        if (matches.length === 0) {
1024            results.innerHTML = '<div class="search-no-results">No results found</div>';
1025        } else {
1026            results.innerHTML = matches.map(doc => 
1027                `<a href="${doc.url}" class="search-result-item">
1028                    <div class="search-result-title">${escapeHtml(doc.title)}</div>
1029                    ${doc.description ? `<div class="search-result-snippet">${escapeHtml(doc.description)}</div>` : ''}
1030                </a>`
1031            ).join('');
1032        }
1033        results.classList.add('show');
1034    }
1035    
1036    function escapeHtml(text) {
1037        const div = document.createElement('div');
1038        div.textContent = text;
1039        return div.innerHTML;
1040    }
1041})();
1042"#;
1043
1044#[cfg(test)]
1045mod tests {
1046    use tempfile::TempDir;
1047
1048    use super::*;
1049
1050    #[test]
1051    fn test_generate_static_assets() {
1052        let temp_dir = TempDir::new().unwrap();
1053        let output_dir = temp_dir.path();
1054
1055        generate_static_assets(output_dir).unwrap();
1056
1057        // Check CSS file exists
1058        let css_path = output_dir.join("assets/style.css");
1059        assert!(css_path.exists());
1060        let css_content = std::fs::read_to_string(&css_path).unwrap();
1061        assert!(css_content.contains(":root"));
1062        assert!(css_content.contains("--color-primary"));
1063
1064        // Check JS file exists
1065        let js_path = output_dir.join("assets/main.js");
1066        assert!(js_path.exists());
1067        let js_content = std::fs::read_to_string(&js_path).unwrap();
1068        assert!(js_content.contains("theme-toggle"));
1069        assert!(js_content.contains("searchIndex"));
1070    }
1071}