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
38pub const DEFAULT_CSS: &str = r#"
39:root {
40    --color-primary: #d4760a;
41    --color-primary-deep: #a85c08;
42    --color-accent: #1e5a8a;
43    --color-bg: #ffffff;
44    --color-surface: #f6f5f2;
45    --color-ink: #1a1a1e;
46    --color-muted: #6b6b73;
47    --color-border: #e2e1dd;
48    --color-primary-bg: #fdf3ea;
49    --color-header-bg: #ffffff;
50    --color-success: #2d8a4e;
51    --color-error: #c4382a;
52    --font-sans: -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, "Inter", Roboto, sans-serif;
53    --font-mono: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
54    color-scheme: light;
55}
56
57[data-theme="dark"] {
58    --color-bg: #131316;
59    --color-surface: #1e1e23;
60    --color-ink: #e8e7e3;
61    --color-muted: #b0b0b8;
62    --color-border: #2c2c32;
63    --color-primary-bg: #2a1f14;
64    --color-header-bg: #131316;
65    color-scheme: dark;
66}
67
68*, *::before, *::after {
69    box-sizing: border-box;
70    margin: 0;
71    padding: 0;
72}
73
74html {
75    font-size: 16px;
76    -webkit-font-smoothing: antialiased;
77    -moz-osx-font-smoothing: grayscale;
78    text-rendering: optimizeLegibility;
79}
80
81body {
82    font-family: var(--font-sans);
83    font-weight: 400;
84    line-height: 1.6;
85    color: var(--color-ink);
86    background-color: var(--color-bg);
87    min-height: 100vh;
88}
89
90a {
91    color: var(--color-primary);
92    text-decoration: none;
93}
94
95a:hover {
96    color: var(--color-primary-deep);
97}
98
99.container {
100    width: 100%;
101    max-width: 42rem;
102    margin: 0 auto;
103    padding: 0 1.25rem;
104}
105
106@media (min-width: 48rem) {
107    .container {
108        max-width: 48rem;
109        padding: 0 2rem;
110    }
111}
112
113header {
114    position: sticky;
115    top: 0;
116    z-index: 40;
117    background-color: var(--color-header-bg);
118    border-bottom: 1px solid var(--color-border);
119}
120
121nav {
122    display: flex;
123    align-items: center;
124    justify-content: space-between;
125    padding: 0.875rem 0;
126    gap: 0.75rem;
127    flex-wrap: wrap;
128}
129
130.site-title {
131    font-size: 1.125rem;
132    font-weight: 700;
133    color: var(--color-ink);
134    white-space: nowrap;
135}
136
137.site-title:hover {
138    color: var(--color-primary);
139}
140
141.nav-links {
142    display: flex;
143    align-items: center;
144    gap: 0.125rem;
145    flex-wrap: wrap;
146}
147
148.nav-links > a {
149    padding: 0.375rem 0.625rem;
150    font-size: 0.875rem;
151    font-weight: 500;
152    color: var(--color-muted);
153    border-radius: 0.375rem;
154}
155
156.nav-links > a:hover {
157    color: var(--color-ink);
158    background-color: var(--color-surface);
159}
160
161.nav-actions {
162    display: flex;
163    align-items: center;
164    gap: 0.375rem;
165    margin-left: auto;
166}
167
168.lang-switcher {
169    position: relative;
170    cursor: pointer;
171    user-select: none;
172}
173
174.lang-switcher > .lang-code {
175    display: flex;
176    align-items: center;
177    justify-content: center;
178    padding: 0.375rem 0.5rem;
179    font-size: 0.75rem;
180    font-weight: 600;
181    color: var(--color-muted);
182    border: 1px solid var(--color-border);
183    border-radius: 0.375rem;
184    background-color: var(--color-bg);
185    min-width: 2.125rem;
186    height: 2rem;
187}
188
189.lang-switcher:hover > .lang-code,
190.lang-switcher.open > .lang-code {
191    color: var(--color-ink);
192    border-color: var(--color-primary);
193}
194
195.lang-dropdown {
196    position: absolute;
197    top: calc(100% + 0.25rem);
198    right: 0;
199    min-width: 7.5rem;
200    background-color: var(--color-bg);
201    border: 1px solid var(--color-border);
202    border-radius: 0.5rem;
203    box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
204    padding: 0.25rem;
205    display: none;
206    z-index: 50;
207}
208
209.lang-switcher.open .lang-dropdown {
210    display: block;
211}
212
213.lang-option {
214    display: block;
215    padding: 0.4375rem 0.625rem;
216    font-size: 0.875rem;
217    color: var(--color-ink);
218    border-radius: 0.375rem;
219}
220
221.lang-option:hover {
222    background-color: var(--color-surface);
223}
224
225.lang-option.active {
226    color: var(--color-primary);
227    font-weight: 600;
228    background-color: var(--color-primary-bg);
229}
230
231.theme-toggle {
232    display: flex;
233    align-items: center;
234    justify-content: center;
235    width: 2rem;
236    height: 2rem;
237    padding: 0;
238    color: var(--color-muted);
239    background: var(--color-bg);
240    border: 1px solid var(--color-border);
241    border-radius: 0.375rem;
242    cursor: pointer;
243}
244
245.theme-toggle:hover {
246    color: var(--color-ink);
247    border-color: var(--color-primary);
248}
249
250.theme-toggle svg {
251    width: 1rem;
252    height: 1rem;
253}
254
255.theme-toggle .icon-sun { display: none; }
256.theme-toggle .icon-moon { display: block; }
257
258[data-theme="dark"] .theme-toggle .icon-sun { display: block; }
259[data-theme="dark"] .theme-toggle .icon-moon { display: none; }
260
261main {
262    padding: 2rem 0 3rem;
263    min-height: calc(100vh - 12rem);
264}
265
266article h1 {
267    font-size: 1.875rem;
268    font-weight: 700;
269    margin-bottom: 0.5rem;
270    line-height: 1.3;
271}
272
273article.page h1,
274section > h1 {
275    font-size: 1.875rem;
276    font-weight: 700;
277    margin-bottom: 2rem;
278    line-height: 1.3;
279}
280
281article.post header {
282    position: static;
283    background: none;
284    border: none;
285    backdrop-filter: none;
286    padding: 0;
287    margin-bottom: 2rem;
288}
289
290article.post header h1 {
291    margin-bottom: 0.5rem;
292}
293
294article.post header time {
295    display: block;
296    font-size: 0.875rem;
297    color: var(--color-muted);
298    margin-bottom: 0.75rem;
299}
300
301article .content,
302section .content {
303    font-size: 1rem;
304    line-height: 1.75;
305}
306
307article .content > *:last-child,
308section .content > *:last-child {
309    margin-bottom: 0;
310}
311
312footer {
313    border-top: 1px solid var(--color-border);
314    padding: 2rem 0;
315    color: var(--color-muted);
316    font-size: 0.875rem;
317    text-align: center;
318}
319
320footer a {
321    color: var(--color-muted);
322}
323
324footer a:hover {
325    color: var(--color-primary);
326}
327
328.post-list ul {
329    list-style: none;
330    padding: 0;
331    margin: 0;
332}
333
334.post-item {
335    padding: 1.25rem 0;
336    border-bottom: 1px solid var(--color-border);
337}
338
339.post-item:last-child {
340    border-bottom: none;
341}
342
343.post-item-header {
344    display: flex;
345    align-items: baseline;
346    gap: 1rem;
347    flex-wrap: wrap;
348    margin-bottom: 0.25rem;
349}
350
351.post-title {
352    font-size: 1.125rem;
353    font-weight: 600;
354    color: var(--color-ink);
355}
356
357.post-title:hover {
358    color: var(--color-primary);
359}
360
361.post-item time {
362    font-size: 0.8125rem;
363    color: var(--color-muted);
364    font-family: var(--font-mono);
365    white-space: nowrap;
366}
367
368.post-description {
369    color: var(--color-muted);
370    font-size: 0.9375rem;
371    margin: 0;
372    line-height: 1.6;
373}
374
375.tags {
376    display: flex;
377    flex-wrap: wrap;
378    gap: 0.5rem;
379    margin-top: 0.75rem;
380}
381
382.tags a {
383    font-size: 0.75rem;
384    padding: 0.2rem 0.6rem;
385    background-color: var(--color-surface);
386    color: var(--color-muted);
387    border-radius: 9999px;
388    transition: color 0.15s ease, background-color 0.15s ease;
389}
390
391.tags a:hover {
392    color: var(--color-primary);
393    background-color: var(--color-primary-bg);
394}
395
396.taxonomy-index h1 {
397    font-size: 1.5rem;
398    font-weight: 700;
399    margin-bottom: 1.5rem;
400}
401
402.tags-cloud {
403    display: flex;
404    flex-wrap: wrap;
405    gap: 0.625rem;
406}
407
408.tag-item {
409    display: inline-flex;
410    align-items: center;
411    gap: 0.375rem;
412    padding: 0.5rem 0.875rem;
413    background-color: var(--color-surface);
414    border-radius: 0.5rem;
415    font-size: 0.875rem;
416    color: var(--color-ink);
417}
418
419.tag-item:hover {
420    color: var(--color-primary);
421}
422
423.tag-name { font-weight: 500; }
424
425.tag-count {
426    font-size: 0.75rem;
427    color: var(--color-muted);
428    background-color: var(--color-bg);
429    padding: 0.1rem 0.4rem;
430    border-radius: 0.25rem;
431}
432
433.categories-list {
434    list-style: none;
435    padding: 0;
436    margin: 0;
437}
438
439.categories-list li {
440    padding: 0.75rem 0;
441    border-bottom: 1px solid var(--color-border);
442    font-size: 0.9375rem;
443}
444
445.categories-list li:last-child { border-bottom: none; }
446
447.categories-list .count {
448    color: var(--color-muted);
449    font-size: 0.875rem;
450}
451
452.archives h1 {
453    font-size: 1.5rem;
454    font-weight: 700;
455    margin-bottom: 2rem;
456}
457
458.archive-year h2 {
459    font-size: 1.125rem;
460    font-weight: 600;
461    color: var(--color-ink);
462    margin-top: 2rem;
463    margin-bottom: 0.5rem;
464    padding-bottom: 0.5rem;
465    border-bottom: 2px solid var(--color-primary);
466    display: inline-block;
467}
468
469.archive-year:first-child h2 { margin-top: 0; }
470
471.archive-year ul {
472    list-style: none;
473    padding: 0;
474    margin: 0;
475}
476
477.archive-year li {
478    display: flex;
479    align-items: center;
480    padding: 0.5rem 0;
481    gap: 0.75rem;
482    font-size: 0.9375rem;
483}
484
485.archive-date {
486    font-family: var(--font-mono);
487    font-size: 0.8125rem;
488    color: var(--color-muted);
489    min-width: 3.5rem;
490    flex-shrink: 0;
491}
492
493.archive-year li a {
494    color: var(--color-ink);
495    flex: 1;
496    min-width: 0;
497    overflow: hidden;
498    text-overflow: ellipsis;
499    white-space: nowrap;
500}
501
502.archive-year li a:hover { color: var(--color-primary); }
503
504.section-description {
505    color: var(--color-muted);
506    margin-bottom: 1.5rem;
507    font-size: 0.9375rem;
508}
509
510.shorts-section .short-list {
511    display: flex;
512    flex-direction: column;
513}
514
515.short-item {
516    padding: 1.5rem 0;
517    border-bottom: 1px solid var(--color-border);
518}
519
520.short-item:last-child { border-bottom: none; }
521
522.short-date {
523    font-size: 0.8125rem;
524    color: var(--color-muted);
525    font-family: var(--font-mono);
526    display: block;
527    margin-bottom: 0.75rem;
528}
529
530.short-content {
531    font-size: 0.9375rem;
532    line-height: 1.7;
533}
534
535.short-content > *:last-child { margin-bottom: 0; }
536
537.pagination {
538    display: flex;
539    justify-content: space-between;
540    align-items: center;
541    margin-top: 2rem;
542    padding-top: 1.5rem;
543    border-top: 1px solid var(--color-border);
544    font-size: 0.875rem;
545    color: var(--color-muted);
546}
547
548.pagination a {
549    padding: 0.5rem 1rem;
550    border: 1px solid var(--color-border);
551    border-radius: 0.375rem;
552    font-weight: 500;
553    color: var(--color-ink);
554}
555
556.pagination a:hover {
557    border-color: var(--color-primary);
558    color: var(--color-primary);
559}
560
561.search-wrapper {
562    position: relative;
563    width: 12rem;
564}
565
566.search-input {
567    width: 100%;
568    padding: 0.4rem 2rem 0.4rem 0.75rem;
569    font-size: 0.875rem;
570    border: 1px solid var(--color-border);
571    border-radius: 0.375rem;
572    background-color: var(--color-bg);
573    color: var(--color-ink);
574    font-family: var(--font-sans);
575}
576
577.search-input::placeholder { color: var(--color-muted); }
578
579.search-input:focus {
580    outline: none;
581    border-color: var(--color-primary);
582    box-shadow: 0 0 0 2px var(--color-primary-bg);
583}
584
585.search-btn {
586    position: absolute;
587    right: 0.5rem;
588    top: 50%;
589    transform: translateY(-50%);
590    padding: 0.25rem;
591    color: var(--color-muted);
592    cursor: pointer;
593    background: none;
594    border: none;
595    display: flex;
596    align-items: center;
597    justify-content: center;
598}
599
600.search-btn:hover { color: var(--color-ink); }
601
602.search-btn svg {
603    width: 1rem;
604    height: 1rem;
605    pointer-events: none;
606}
607
608.search-results {
609    position: absolute;
610    top: calc(100% + 0.25rem);
611    left: 0;
612    right: 0;
613    background-color: var(--color-bg);
614    border: 1px solid var(--color-border);
615    border-radius: 0.5rem;
616    box-shadow: 0 4px 16px rgba(0, 0, 0, 0.12);
617    max-height: 24rem;
618    overflow-y: auto;
619    z-index: 50;
620    display: none;
621}
622
623.search-results.visible { display: block; }
624
625.search-result-item {
626    display: block;
627    padding: 0.75rem 0.875rem;
628    font-size: 0.875rem;
629    color: var(--color-ink);
630    border-bottom: 1px solid var(--color-border);
631    cursor: pointer;
632}
633
634.search-result-item:last-child { border-bottom: none; }
635
636.search-result-item:hover,
637.search-result-item[data-active="true"] {
638    background-color: var(--color-surface);
639}
640
641.search-result-item-title {
642    font-weight: 500;
643    color: var(--color-ink);
644    line-height: 1.4;
645}
646
647.search-result-item-summary {
648    margin-top: 0.25rem;
649    font-size: 0.75rem;
650    color: var(--color-muted);
651    line-height: 1.5;
652    display: -webkit-box;
653    -webkit-line-clamp: 2;
654    -webkit-box-orient: vertical;
655    overflow: hidden;
656}
657
658.search-result-item-tags {
659    display: flex;
660    flex-wrap: wrap;
661    gap: 0.375rem;
662    margin-top: 0.5rem;
663}
664
665.search-result-tag {
666    display: inline-flex;
667    padding: 0.125rem 0.5rem;
668    font-size: 0.6875rem;
669    background-color: var(--color-surface);
670    color: var(--color-muted);
671    border-radius: 9999px;
672}
673
674.search-loading,
675.search-empty,
676.search-error {
677    padding: 1rem;
678    text-align: center;
679    font-size: 0.875rem;
680    color: var(--color-muted);
681}
682
683.search-error { color: var(--color-error); }
684
685.prose { max-width: none; }
686
687.prose h1 { font-size: 1.875rem; font-weight: 700; margin-bottom: 1rem; line-height: 1.3; }
688.prose h2 { font-size: 1.5rem; font-weight: 600; margin-top: 2rem; margin-bottom: 0.75rem; line-height: 1.35; }
689.prose h3 { font-size: 1.25rem; font-weight: 600; margin-top: 1.5rem; margin-bottom: 0.5rem; line-height: 1.4; }
690.prose p { margin-bottom: 1rem; line-height: 1.75; }
691
692.prose code {
693    background-color: var(--color-surface);
694    padding: 0.125rem 0.375rem;
695    font-family: var(--font-mono);
696    font-size: 0.875em;
697    border-radius: 0.25rem;
698}
699
700.prose pre {
701    background-color: #1a1a1e;
702    color: #e8e7e3;
703    padding: 1rem 1.25rem;
704    border-radius: 0.5rem;
705    overflow-x: auto;
706    margin-bottom: 1rem;
707    font-size: 0.875rem;
708    line-height: 1.6;
709}
710
711.prose pre code { background-color: transparent; padding: 0; font-size: inherit; }
712
713.prose a {
714    color: var(--color-primary);
715    text-decoration: underline;
716    text-underline-offset: 2px;
717}
718
719.prose a:hover { text-decoration: none; }
720
721.prose ul { list-style-type: disc; list-style-position: inside; margin-bottom: 1rem; padding-left: 0.5rem; }
722.prose ol { list-style-type: decimal; list-style-position: inside; margin-bottom: 1rem; padding-left: 0.5rem; }
723.prose li { margin-bottom: 0.25rem; }
724
725.prose blockquote {
726    border-left: 3px solid var(--color-primary);
727    padding-left: 1rem;
728    margin: 1rem 0;
729    color: var(--color-muted);
730    font-style: italic;
731}
732
733.prose img { max-width: 100%; height: auto; border-radius: 0.5rem; margin: 1rem 0; }
734
735.prose table {
736    width: 100%;
737    border-collapse: collapse;
738    margin-bottom: 1rem;
739    font-size: 0.9375rem;
740}
741
742.prose th, .prose td {
743    border: 1px solid var(--color-border);
744    padding: 0.5rem 0.75rem;
745    text-align: left;
746}
747
748.prose th { background-color: var(--color-surface); font-weight: 600; }
749.prose hr { border: none; border-top: 1px solid var(--color-border); margin: 2rem 0; }
750
751.tweet-timeline { max-width: 42rem; margin-left: auto; margin-right: auto; }
752.tweet-list { border-top: 1px solid var(--color-border); }
753
754.tweet-card {
755    display: flex;
756    gap: 0.75rem;
757    padding: 1rem;
758    border-bottom: 1px solid var(--color-border);
759    cursor: pointer;
760    transition: background-color 0.15s ease;
761}
762
763.tweet-card:hover { background-color: var(--color-surface); }
764.tweet-single { cursor: default; padding: 1.5rem; }
765.tweet-single:hover { background-color: transparent; }
766.tweet-avatar { flex-shrink: 0; }
767
768.avatar-initials {
769    display: flex;
770    align-items: center;
771    justify-content: center;
772    width: 2.5rem;
773    height: 2.5rem;
774    border-radius: 9999px;
775    background-color: var(--color-primary);
776    color: white;
777    font-size: 0.875rem;
778    font-weight: 600;
779}
780
781.tweet-main { flex: 1; min-width: 0; }
782.tweet-header { display: flex; align-items: center; gap: 0.5rem; font-size: 0.875rem; margin-bottom: 0.25rem; }
783.tweet-author { font-weight: 600; color: var(--color-ink); }
784.tweet-author:hover { text-decoration: underline; }
785.tweet-time { font-size: 0.875rem; color: var(--color-muted); }
786.tweet-content { color: var(--color-ink); line-height: 1.5; font-size: 0.9375rem; }
787.tweet-content p { margin-bottom: 0.5rem; }
788.tweet-content p:last-child { margin-bottom: 0; }
789.date-separator { margin: 1.5rem 0; border-top: 1px solid var(--color-border); }
790
791.archive-badge {
792    display: inline-flex;
793    align-items: center;
794    padding: 0.125rem 0.5rem;
795    margin-right: 0.5rem;
796    border-radius: 0.25rem;
797    font-size: 0.6875rem;
798    font-weight: 700;
799    text-transform: uppercase;
800    flex-shrink: 0;
801    color: white;
802}
803
804.badge-post { background-color: var(--color-success); }
805.badge-short { background-color: var(--color-accent); }
806
807@media (max-width: 48rem) {
808    .search-wrapper { width: 100%; }
809    .nav-links { width: 100%; justify-content: center; }
810    nav { padding-top: 0.75rem; padding-bottom: 0.75rem; }
811    .nav-actions { width: 100%; justify-content: center; margin-left: 0; margin-top: 0.5rem; }
812    .nav-links > a { padding: 0.35rem 0.5rem; font-size: 0.8125rem; }
813    main { padding-top: 1.5rem; padding-bottom: 2rem; }
814    .post-item-header { flex-direction: column; gap: 0.25rem; }
815    .archive-year li { flex-wrap: wrap; }
816}
817
818@media (prefers-reduced-motion: reduce) {
819    *, *::before, *::after {
820        animation-duration: 0.01ms !important;
821        animation-iteration-count: 1 !important;
822        transition-duration: 0.01ms !important;
823    }
824}
825"#;
826
827pub const DEFAULT_JS: &str = r#"
828(function() {
829    'use strict';
830
831    function initThemeToggle() {
832        var toggle = document.querySelector('.theme-toggle');
833        if (!toggle) return;
834        var html = document.documentElement;
835
836        toggle.addEventListener('click', function() {
837            var current = html.getAttribute('data-theme');
838            if (current !== 'dark' && current !== 'light') {
839                current = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
840            }
841            var next = current === 'dark' ? 'light' : 'dark';
842            html.setAttribute('data-theme', next);
843            try { localStorage.setItem('theme', next); } catch (e) {}
844        });
845
846        window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', function(e) {
847            try {
848                if (!localStorage.getItem('theme')) {
849                    html.setAttribute('data-theme', e.matches ? 'dark' : 'light');
850                }
851            } catch (e) {}
852        });
853    }
854
855    function initLangSwitcher() {
856        var switcher = document.querySelector('.lang-switcher');
857        if (!switcher) return;
858
859        switcher.addEventListener('click', function(e) {
860            e.stopPropagation();
861            var isOpen = switcher.classList.contains('open');
862            document.querySelectorAll('.lang-switcher.open').forEach(function(el) {
863                el.classList.remove('open');
864            });
865            if (!isOpen) {
866                switcher.classList.add('open');
867            }
868        });
869
870        switcher.addEventListener('keydown', function(e) {
871            if (e.key === 'Enter' || e.key === ' ') {
872                e.preventDefault();
873                switcher.click();
874            } else if (e.key === 'Escape') {
875                switcher.classList.remove('open');
876            }
877        });
878
879        document.addEventListener('click', function() {
880            switcher.classList.remove('open');
881        });
882    }
883
884    function initSearch() {
885        var input = document.getElementById('searchInput');
886        var results = document.getElementById('searchResults');
887        var wrapper = document.getElementById('searchWrapper');
888        var searchBtn = wrapper ? wrapper.querySelector('.search-btn') : null;
889        if (!input || !results || !wrapper) return;
890
891        if (searchBtn) {
892            searchBtn.addEventListener('click', function() {
893                input.focus();
894            });
895        }
896
897        var searchIndex = null;
898        var debounceTimer = null;
899        var activeIndex = -1;
900        var currentMatches = [];
901
902        function getBasePath() {
903            var path = window.location.pathname;
904            var segments = path.split('/').filter(Boolean);
905            if (segments.length > 0 && segments[0].length === 2) {
906                return '/' + segments[0];
907            }
908            return '';
909        }
910
911        function escapeHtml(text) {
912            var div = document.createElement('div');
913            div.appendChild(document.createTextNode(text));
914            return div.innerHTML;
915        }
916
917        function loadSearchIndex() {
918            if (searchIndex) return Promise.resolve(searchIndex);
919            var basePath = getBasePath();
920            var indexPath = basePath + '/search-index.json';
921            return fetch(indexPath)
922                .then(function(res) {
923                    if (!res.ok) throw new Error('not found');
924                    return res.json();
925                })
926                .then(function(data) {
927                    searchIndex = data;
928                    return data;
929                })
930                .catch(function() {
931                    return null;
932                });
933        }
934
935        function showResults(items) {
936            currentMatches = items;
937            activeIndex = -1;
938            if (!items || items.length === 0) {
939                results.innerHTML = '<div class="search-empty">No results found</div>';
940            } else {
941                results.innerHTML = items.map(function(doc, idx) {
942                    var tagsHtml = '';
943                    if (doc.tags && doc.tags.length > 0) {
944                        tagsHtml = '<div class="search-result-item-tags">' +
945                            doc.tags.map(function(t) {
946                                return '<span class="search-result-tag">' + escapeHtml(t) + '</span>';
947                            }).join('') + '</div>';
948                    }
949                    return '<a href="' + escapeHtml(doc.url) + '" class="search-result-item" data-index="' + idx + '">' +
950                        '<div class="search-result-item-title">' + escapeHtml(doc.title) + '</div>' +
951                        (doc.description ? '<div class="search-result-item-summary">' + escapeHtml(doc.description) + '</div>' : '') +
952                        tagsHtml + '</a>';
953                }).join('');
954            }
955            results.classList.add('visible');
956        }
957
958        function hideResults() {
959            results.classList.remove('visible');
960            currentMatches = [];
961            activeIndex = -1;
962        }
963
964        function updateActive() {
965            var items = results.querySelectorAll('.search-result-item');
966            items.forEach(function(el, idx) {
967                el.setAttribute('data-active', idx === activeIndex ? 'true' : 'false');
968            });
969        }
970
971        function performSearch(query) {
972            loadSearchIndex().then(function(index) {
973                if (!index || !index.documents) {
974                    results.innerHTML = '<div class="search-error">Search index not available</div>';
975                    results.classList.add('visible');
976                    return;
977                }
978                var q = query.toLowerCase();
979                var matches = index.documents.filter(function(doc) {
980                    var title = (doc.title || '').toLowerCase();
981                    var desc = (doc.description || '').toLowerCase();
982                    var tags = (doc.tags || []).join(' ').toLowerCase();
983                    if (title.indexOf(q) !== -1) return true;
984                    if (desc.indexOf(q) !== -1) return true;
985                    if (tags.indexOf(q) !== -1) return true;
986                    return false;
987                }).slice(0, 8);
988                showResults(matches);
989            });
990        }
991
992        input.addEventListener('focus', function() {
993            loadSearchIndex();
994            if (input.value.trim()) {
995                performSearch(input.value.trim());
996            }
997        });
998
999        input.addEventListener('input', function() {
1000            clearTimeout(debounceTimer);
1001            var query = input.value.trim();
1002            if (query.length === 0) {
1003                hideResults();
1004                return;
1005            }
1006            debounceTimer = setTimeout(function() {
1007                performSearch(query);
1008            }, 200);
1009        });
1010
1011        input.addEventListener('keydown', function(e) {
1012            var items = results.querySelectorAll('.search-result-item');
1013            if (e.key === 'ArrowDown') {
1014                e.preventDefault();
1015                if (items.length === 0) return;
1016                activeIndex = activeIndex < items.length - 1 ? activeIndex + 1 : 0;
1017                updateActive();
1018            } else if (e.key === 'ArrowUp') {
1019                e.preventDefault();
1020                if (items.length === 0) return;
1021                activeIndex = activeIndex > 0 ? activeIndex - 1 : items.length - 1;
1022                updateActive();
1023            } else if (e.key === 'Enter') {
1024                e.preventDefault();
1025                if (activeIndex >= 0 && currentMatches[activeIndex]) {
1026                    window.location.href = currentMatches[activeIndex].url;
1027                }
1028            } else if (e.key === 'Escape') {
1029                hideResults();
1030                input.blur();
1031            }
1032        });
1033
1034        document.addEventListener('click', function(e) {
1035            if (!wrapper.contains(e.target)) {
1036                hideResults();
1037            }
1038        });
1039    }
1040
1041    if (document.readyState === 'loading') {
1042        document.addEventListener('DOMContentLoaded', function() {
1043            initThemeToggle();
1044            initLangSwitcher();
1045            initSearch();
1046        });
1047    } else {
1048        initThemeToggle();
1049        initLangSwitcher();
1050        initSearch();
1051    }
1052})();
1053"#;
1054
1055#[cfg(test)]
1056mod tests {
1057    use tempfile::TempDir;
1058
1059    use super::*;
1060
1061    #[test]
1062    fn test_generate_static_assets() {
1063        let temp_dir = TempDir::new().unwrap();
1064        let output_dir = temp_dir.path();
1065
1066        generate_static_assets(output_dir).unwrap();
1067
1068        // Check CSS file exists
1069        let css_path = output_dir.join("assets/style.css");
1070        assert!(css_path.exists());
1071        let css_content = std::fs::read_to_string(&css_path).unwrap();
1072        assert!(css_content.contains(":root"));
1073        assert!(css_content.contains("--color-primary"));
1074
1075        // Check JS file exists
1076        let js_path = output_dir.join("assets/main.js");
1077        assert!(js_path.exists());
1078        let js_content = std::fs::read_to_string(&js_path).unwrap();
1079        assert!(js_content.contains("theme-toggle"));
1080        assert!(js_content.contains("searchIndex"));
1081    }
1082}