Jun 7, 2026 78.0 KB html
1<!DOCTYPE html>
2<html lang="cs">
3<head>
4<meta charset="UTF-8">
5<base href="./">
6<meta name="viewport" content="width=device-width, initial-scale=1.0">
7<title>Web</title>
8<style>
9/* ===== RESET & BASE ===== */
10*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
11
12:root {
13 --font-heading: 'Playfair Display', 'Georgia', serif;
14 --font-body: 'Source Serif 4', 'Georgia', serif;
15 --font-mono: 'JetBrains Mono', 'Courier New', monospace;
16 --font-ui: 'DM Sans', 'Helvetica Neue', sans-serif;
17
18 /* Palette defaults — overridden by JS */
19 --color-primary: #1a3a5c;
20 --color-primary-light: #2a5a8c;
21 --color-primary-dark: #0f2035;
22 --color-accent: #c9873a;
23 --color-accent-light: #e4a85a;
24 --color-bg: #fafaf7;
25 --color-bg-alt: #f0ede6;
26 --color-surface: #ffffff;
27 --color-border: #d8d3c8;
28 --color-text: #1c1a16;
29 --color-text-muted: #6b6457;
30 --color-text-inverse: #fafaf7;
31 --color-critic-ins-bg: #d4edda;
32 --color-critic-ins-fg: #155724;
33 --color-critic-del-bg: #f8d7da;
34 --color-critic-del-fg: #721c24;
35 --color-critic-mark-bg: #fff3cd;
36 --color-critic-mark-fg: #856404;
37 --color-critic-comment-bg: #d1ecf1;
38 --color-critic-comment-fg: #0c5460;
39
40 --sidebar-width: 280px;
41 --header-height: auto;
42 --radius: 6px;
43 --shadow-sm: 0 1px 3px rgba(0,0,0,.08), 0 1px 2px rgba(0,0,0,.06);
44 --shadow-md: 0 4px 12px rgba(0,0,0,.10), 0 2px 6px rgba(0,0,0,.07);
45 --shadow-lg: 0 10px 30px rgba(0,0,0,.12), 0 4px 12px rgba(0,0,0,.08);
46 --transition: 200ms ease;
47}
48
49/* ===== PALETTES ===== */
50[data-palette="blue"] {
51 --color-primary: #1a3a5c; --color-primary-light: #2a5a8c; --color-primary-dark: #0f2035;
52 --color-accent: #c9873a; --color-accent-light: #e4a85a;
53 --color-bg: #fafaf7; --color-bg-alt: #f0ede6; --color-surface: #fff;
54 --color-border: #d8d3c8; --color-text: #1c1a16; --color-text-muted: #6b6457;
55}
56[data-palette="green"] {
57 --color-primary: #1a4a2e; --color-primary-light: #2d7a4f; --color-primary-dark: #0e2a1a;
58 --color-accent: #b5802a; --color-accent-light: #d4a045;
59 --color-bg: #f7faf5; --color-bg-alt: #e8f0e4; --color-surface: #fff;
60 --color-border: #c8d8c0; --color-text: #161c14; --color-text-muted: #5a6e54;
61}
62[data-palette="red"] {
63 --color-primary: #5c1a1a; --color-primary-light: #8c2a2a; --color-primary-dark: #350f0f;
64 --color-accent: #c9873a; --color-accent-light: #e4a85a;
65 --color-bg: #faf7f7; --color-bg-alt: #f0e6e6; --color-surface: #fff;
66 --color-border: #d8c8c8; --color-text: #1c1616; --color-text-muted: #6b5454;
67}
68[data-palette="purple"] {
69 --color-primary: #3a1a5c; --color-primary-light: #5a2a8c; --color-primary-dark: #200f35;
70 --color-accent: #b58a2a; --color-accent-light: #d4aa45;
71 --color-bg: #f9f7fa; --color-bg-alt: #ece6f0; --color-surface: #fff;
72 --color-border: #d3c8d8; --color-text: #1a161c; --color-text-muted: #635468;
73}
74[data-palette="teal"] {
75 --color-primary: #1a4a4a; --color-primary-light: #2a7a7a; --color-primary-dark: #0e2a2a;
76 --color-accent: #c9873a; --color-accent-light: #e4a85a;
77 --color-bg: #f5fafa; --color-bg-alt: #e0ecec; --color-surface: #fff;
78 --color-border: #c0d8d8; --color-text: #141c1c; --color-text-muted: #4e6b6b;
79}
80[data-palette="orange"] {
81 --color-primary: #5c2e1a; --color-primary-light: #8c4a2a; --color-primary-dark: #35190f;
82 --color-accent: #d4822a; --color-accent-light: #e8a048;
83 --color-bg: #faf8f5; --color-bg-alt: #f0e8de; --color-surface: #fff;
84 --color-border: #d8ccc0; --color-text: #1c1814; --color-text-muted: #6b5e50;
85}
86[data-palette="dark"] {
87 --color-primary: #2a2a2a; --color-primary-light: #3d3d3d; --color-primary-dark: #111;
88 --color-accent: #e8a840; --color-accent-light: #f0c060;
89 --color-bg: #1a1a1a; --color-bg-alt: #242424; --color-surface: #2d2d2d;
90 --color-border: #3d3d3d; --color-text: #e8e4dc; --color-text-muted: #9a9490;
91 --color-critic-ins-bg: #1e3a25; --color-critic-ins-fg: #6dd67f;
92 --color-critic-del-bg: #3a1e1e; --color-critic-del-fg: #d6706d;
93 --color-critic-mark-bg: #3a2e10; --color-critic-mark-fg: #e8cc6a;
94 --color-critic-comment-bg: #1a2e35; --color-critic-comment-fg: #6abcd6;
95}
96[data-palette="slate"] {
97 --color-primary: #2a3550; --color-primary-light: #3d4e70; --color-primary-dark: #161d2e;
98 --color-accent: #6699cc; --color-accent-light: #88b4e0;
99 --color-bg: #f5f7fa; --color-bg-alt: #e5e8f0; --color-surface: #fff;
100 --color-border: #ccd0da; --color-text: #1a1d26; --color-text-muted: #545e78;
101}
102[data-palette="rose"] {
103 --color-primary: #5c1a30; --color-primary-light: #8c2a4a; --color-primary-dark: #350f1a;
104 --color-accent: #c97a5a; --color-accent-light: #e49a78;
105 --color-bg: #faf7f8; --color-bg-alt: #f0e4e8; --color-surface: #fff;
106 --color-border: #d8c0c8; --color-text: #1c1416; --color-text-muted: #6b4e58;
107}
108[data-palette="brown"] {
109 --color-primary: #4a2e1a; --color-primary-light: #7a4e2a; --color-primary-dark: #2a1a0e;
110 --color-accent: #c9a03a; --color-accent-light: #e4c05a;
111 --color-bg: #faf8f5; --color-bg-alt: #f0e8dc; --color-surface: #fff;
112 --color-border: #d8ccc0; --color-text: #1c1814; --color-text-muted: #6b5e50;
113}
114[data-palette="indigo"] {
115 --color-primary: #1e2a6a; --color-primary-light: #2e3e9a; --color-primary-dark: #10163e;
116 --color-accent: #d4882a; --color-accent-light: #e8a845;
117 --color-bg: #f5f6fa; --color-bg-alt: #e4e6f4; --color-surface: #fff;
118 --color-border: #c8cce0; --color-text: #141620; --color-text-muted: #5058a0;
119}
120[data-palette="gold"] {
121 --color-primary: #3a2a0a; --color-primary-light: #6a4e18; --color-primary-dark: #1e1505;
122 --color-accent: #c4880a; --color-accent-light: #e0a820;
123 --color-bg: #fdfaf0; --color-bg-alt: #f4f0d8; --color-surface: #fff;
124 --color-border: #dcd4a0; --color-text: #1a1808; --color-text-muted: #7a6820;
125}
126[data-palette="cyan"] {
127 --color-primary: #0e3a4a; --color-primary-light: #1a5a70; --color-primary-dark: #07202a;
128 --color-accent: #c88a30; --color-accent-light: #e0aa50;
129 --color-bg: #f2fafc; --color-bg-alt: #daeef5; --color-surface: #fff;
130 --color-border: #b8dce8; --color-text: #0c1a20; --color-text-muted: #3a7090;
131}
132[data-palette="forest"] {
133 --color-primary: #1e3820; --color-primary-light: #2e5c32; --color-primary-dark: #101e12;
134 --color-accent: #8a7a20; --color-accent-light: #b0a030;
135 --color-bg: #f5f8f2; --color-bg-alt: #e2ece0; --color-surface: #fff;
136 --color-border: #c0d4bc; --color-text: #141a14; --color-text-muted: #4a6848;
137}
138[data-palette="dusk"] {
139 --color-primary: #4a3050; --color-primary-light: #6e4878; --color-primary-dark: #28182e;
140 --color-accent: #c08040; --color-accent-light: #daa060;
141 --color-bg: #f8f5fa; --color-bg-alt: #ede5f2; --color-surface: #fff;
142 --color-border: #d4c8dc; --color-text: #1c1620; --color-text-muted: #705880;
143}
144[data-palette="mint"] {
145 --color-primary: #1a4040; --color-primary-light: #2a6868; --color-primary-dark: #0e2222;
146 --color-accent: #7aaa4a; --color-accent-light: #9acc68;
147 --color-bg: #f2faf8; --color-bg-alt: #daf0ea; --color-surface: #fff;
148 --color-border: #b8e0d4; --color-text: #141a18; --color-text-muted: #3a6858;
149}
150[data-palette="night"] {
151 --color-primary: #0a0e2a; --color-primary-light: #1a2050; --color-primary-dark: #040614;
152 --color-accent: #5090e0; --color-accent-light: #70b0f8;
153 --color-bg: #0e1228; --color-bg-alt: #161c38; --color-surface: #1a2040;
154 --color-border: #2a3060; --color-text: #c8d0f0; --color-text-muted: #7080b0;
155 --color-critic-ins-bg: #0e2e20; --color-critic-ins-fg: #50d080;
156 --color-critic-del-bg: #2e0e10; --color-critic-del-fg: #e05060;
157 --color-critic-mark-bg: #2e2a08; --color-critic-mark-fg: #e0c840;
158 --color-critic-comment-bg: #082028; --color-critic-comment-fg: #50b8d8;
159}
160[data-palette="warm"] {
161 --color-primary: #4a2a10; --color-primary-light: #7a4a20; --color-primary-dark: #281508;
162 --color-accent: #d4500a; --color-accent-light: #f07030;
163 --color-bg: #fdf8f0; --color-bg-alt: #f5ece0; --color-surface: #fff;
164 --color-border: #e0d0bc; --color-text: #1e1610; --color-text-muted: #7a5840;
165}
166[data-palette="cool"] {
167 --color-primary: #1a2a4a; --color-primary-light: #2a3e68; --color-primary-dark: #0e1628;
168 --color-accent: #3888cc; --color-accent-light: #58a8ec;
169 --color-bg: #f4f7fc; --color-bg-alt: #e0e8f4; --color-surface: #fff;
170 --color-border: #c0cce0; --color-text: #14181e; --color-text-muted: #405068;
171}
172[data-palette="earth"] {
173 --color-primary: #3a2e20; --color-primary-light: #5e4a30; --color-primary-dark: #1e1810;
174 --color-accent: #8a6020; --color-accent-light: #b07e30;
175 --color-bg: #faf5ee; --color-bg-alt: #f0e8d8; --color-surface: #fff;
176 --color-border: #ddd0b8; --color-text: #1a1810; --color-text-muted: #7a6848;
177}
178
179/* ===== LAYOUT ===== */
180body {
181 font-family: var(--font-body);
182 font-size: 1rem;
183 line-height: 1.7;
184 color: var(--color-text);
185 background: var(--color-bg);
186 min-height: 100vh;
187 display: flex;
188 flex-direction: column;
189}
190
191.skip-link {
192 position: absolute;
193 top: -100%;
194 left: 0;
195 background: var(--color-accent);
196 color: var(--color-text-inverse);
197 padding: .5rem 1rem;
198 z-index: 1000;
199 font-family: var(--font-ui);
200 text-decoration: none;
201 font-weight: 600;
202 border-radius: 0 0 var(--radius) 0;
203}
204.skip-link:focus { top: 0; }
205
206/* ===== HEADER ===== */
207#site-header {
208 background: var(--color-primary);
209 color: var(--color-text-inverse);
210 padding: 2rem 2rem 0;
211 position: relative;
212 overflow: hidden;
213}
214#site-header::before {
215 content: '';
216 position: absolute;
217 top: -60px; right: -80px;
218 width: 320px; height: 320px;
219 border-radius: 50%;
220 background: rgba(255,255,255,.04);
221 pointer-events: none;
222}
223#site-header::after {
224 content: '';
225 position: absolute;
226 bottom: -40px; left: 10%;
227 width: 200px; height: 200px;
228 border-radius: 50%;
229 background: rgba(255,255,255,.03);
230 pointer-events: none;
231}
232
233.site-branding {
234 position: relative;
235 z-index: 1;
236 margin-bottom: 1.5rem;
237}
238#site-title {
239 font-family: var(--font-heading);
240 font-size: clamp(1.8rem, 4vw, 2.8rem);
241 font-weight: 700;
242 line-height: 1.15;
243 letter-spacing: -.02em;
244 color: var(--color-text-inverse);
245 margin: 0 0 .4rem;
246}
247#site-tagline {
248 font-family: var(--font-ui);
249 font-size: .95rem;
250 opacity: .75;
251 font-style: italic;
252 letter-spacing: .01em;
253}
254
255/* ===== NAVIGATION ===== */
256#site-nav {
257 position: relative;
258 z-index: 1;
259 display: flex;
260 align-items: center;
261 gap: .25rem;
262 flex-wrap: wrap;
263 border-top: 1px solid rgba(255,255,255,.1);
264 padding: .5rem 0 0;
265 margin: 0 -2rem;
266 padding-left: 2rem;
267 padding-right: 2rem;
268 overflow-x: auto;
269}
270#site-nav a {
271 display: inline-block;
272 font-family: var(--font-ui);
273 font-size: .875rem;
274 font-weight: 500;
275 color: rgba(255,255,255,.8);
276 text-decoration: none;
277 padding: .6rem .9rem;
278 border-radius: var(--radius) var(--radius) 0 0;
279 transition: background var(--transition), color var(--transition);
280 white-space: nowrap;
281 letter-spacing: .01em;
282}
283#site-nav a:hover { background: rgba(255,255,255,.1); color: #fff; }
284#site-nav a[aria-current="page"] {
285 background: var(--color-bg);
286 color: var(--color-primary);
287 font-weight: 700;
288}
289.nav-section-links {
290 display: flex;
291 flex-wrap: wrap;
292 gap: .25rem;
293 align-items: center;
294 flex: 1;
295}
296
297/* Dropdown group */
298.nav-group {
299 position: relative;
300}
301.nav-group-btn {
302 display: inline-flex;
303 align-items: center;
304 gap: .35rem;
305 font-family: var(--font-ui);
306 font-size: .875rem;
307 font-weight: 500;
308 color: rgba(255,255,255,.8);
309 background: transparent;
310 border: none;
311 padding: .6rem .9rem;
312 border-radius: var(--radius) var(--radius) 0 0;
313 cursor: pointer;
314 transition: background var(--transition), color var(--transition);
315 white-space: nowrap;
316 letter-spacing: .01em;
317}
318.nav-group-btn::after {
319 content: '▾';
320 font-size: .75em;
321 transition: transform var(--transition);
322 display: inline-block;
323}
324.nav-group-btn[aria-expanded="true"] { background: rgba(255,255,255,.12); color: #fff; }
325.nav-group-btn[aria-expanded="true"]::after { transform: rotate(180deg); }
326.nav-group-btn:hover { background: rgba(255,255,255,.1); color: #fff; }
327.nav-group-btn:focus-visible { outline: 3px solid var(--color-accent); outline-offset: 2px; }
328
329/* Submenu panel */
330.nav-submenu {
331 position: absolute;
332 top: 100%;
333 left: 0;
334 min-width: 200px;
335 background: var(--color-surface);
336 border: 1px solid var(--color-border);
337 border-top: 3px solid var(--color-accent);
338 border-radius: 0 var(--radius) var(--radius) var(--radius);
339 box-shadow: var(--shadow-lg);
340 list-style: none;
341 padding: .35rem 0;
342 z-index: 500;
343 margin: 0;
344}
345.nav-submenu li { margin: 0; }
346.nav-submenu a {
347 display: block;
348 font-family: var(--font-ui);
349 font-size: .875rem;
350 color: var(--color-text);
351 text-decoration: none;
352 padding: .55rem 1.1rem;
353 transition: background var(--transition), color var(--transition);
354 white-space: nowrap;
355}
356.nav-submenu a:hover { background: var(--color-bg-alt); color: var(--color-primary); }
357.nav-submenu a[aria-current="page"] {
358 background: var(--color-bg-alt);
359 color: var(--color-accent);
360 font-weight: 700;
361}
362.nav-submenu a:focus-visible { outline: 3px solid var(--color-accent); outline-offset: -2px; }
363
364/* Mobile: submenu not absolute */
365@media (max-width: 900px) {
366 .nav-group { width: 100%; }
367 .nav-group-btn { width: 100%; border-radius: var(--radius); justify-content: space-between; }
368 .nav-submenu {
369 position: static;
370 border-top: none;
371 border-left: 3px solid var(--color-accent);
372 border-radius: 0 0 var(--radius) var(--radius);
373 box-shadow: none;
374 background: rgba(255,255,255,.05);
375 margin: 0 0 .25rem .5rem;
376 }
377 .nav-submenu a { color: rgba(255,255,255,.85); }
378 .nav-submenu a:hover { background: rgba(255,255,255,.08); color: #fff; }
379 .nav-submenu a[aria-current="page"] { background: rgba(255,255,255,.12); color: var(--color-accent-light); }
380}
381
382.nav-menu-links {
383 margin-left: auto;
384 display: flex;
385 align-items: center;
386 gap: .25rem;
387 flex-shrink: 0;
388}
389.nav-menu-links a {
390 color: rgba(255,255,255,.65) !important;
391 font-size: .8rem !important;
392}
393
394/* ===== TOOLBAR ===== */
395#toolbar {
396 background: var(--color-bg-alt);
397 border-bottom: 1px solid var(--color-border);
398 padding: .5rem 1.5rem;
399 display: flex;
400 align-items: center;
401 gap: .75rem;
402 flex-wrap: wrap;
403}
404#search-box {
405 display: flex;
406 align-items: center;
407 gap: .5rem;
408 flex: 1;
409 max-width: 400px;
410}
411#search-input {
412 flex: 1;
413 font-family: var(--font-ui);
414 font-size: .875rem;
415 padding: .4rem .75rem;
416 border: 1px solid var(--color-border);
417 border-radius: var(--radius);
418 background: var(--color-surface);
419 color: var(--color-text);
420 outline: none;
421 transition: border-color var(--transition);
422}
423#search-input:focus { border-color: var(--color-primary-light); box-shadow: 0 0 0 3px rgba(42,90,140,.15); }
424#search-btn {
425 font-family: var(--font-ui);
426 font-size: .8rem;
427 padding: .4rem .8rem;
428 background: var(--color-primary);
429 color: var(--color-text-inverse);
430 border: none;
431 border-radius: var(--radius);
432 cursor: pointer;
433 transition: background var(--transition);
434 white-space: nowrap;
435}
436#search-btn:hover { background: var(--color-primary-light); }
437#search-clear {
438 font-family: var(--font-ui);
439 font-size: .8rem;
440 padding: .4rem .8rem;
441 background: transparent;
442 color: var(--color-text-muted);
443 border: 1px solid var(--color-border);
444 border-radius: var(--radius);
445 cursor: pointer;
446 transition: all var(--transition);
447}
448#search-clear:hover { background: var(--color-bg); color: var(--color-text); }
449.toolbar-spacer { flex: 1; }
450#menu-toggle {
451 display: none;
452 font-family: var(--font-ui);
453 font-size: .875rem;
454 padding: .4rem .75rem;
455 background: var(--color-primary);
456 color: var(--color-text-inverse);
457 border: none;
458 border-radius: var(--radius);
459 cursor: pointer;
460}
461
462/* ===== SEARCH RESULTS ===== */
463#search-results {
464 display: none;
465 padding: 1.5rem 2rem;
466 max-width: 900px;
467 margin: 0 auto;
468 width: 100%;
469}
470#search-results.active { display: block; }
471#search-results h2 {
472 font-family: var(--font-heading);
473 font-size: 1.4rem;
474 color: var(--color-primary);
475 margin-bottom: 1rem;
476 padding-bottom: .5rem;
477 border-bottom: 2px solid var(--color-border);
478}
479.search-result-item {
480 background: var(--color-surface);
481 border: 1px solid var(--color-border);
482 border-radius: var(--radius);
483 padding: 1rem 1.25rem;
484 margin-bottom: .75rem;
485 box-shadow: var(--shadow-sm);
486}
487.search-result-item h3 { font-family: var(--font-heading); font-size: 1.1rem; margin-bottom: .25rem; }
488.search-result-item h3 a { color: var(--color-primary); text-decoration: none; }
489.search-result-item h3 a:hover { text-decoration: underline; }
490.search-result-item .result-section { font-family: var(--font-ui); font-size: .8rem; color: var(--color-text-muted); margin-bottom: .5rem; }
491.search-result-item .result-excerpt { font-size: .9rem; line-height: 1.5; }
492.search-result-item .result-excerpt mark { background: var(--color-critic-mark-bg); color: var(--color-critic-mark-fg); padding: 0 .15em; border-radius: 2px; }
493.search-no-results { color: var(--color-text-muted); font-style: italic; font-family: var(--font-ui); }
494
495/* ===== MAIN AREA ===== */
496#main-wrapper {
497 display: flex;
498 flex: 1;
499 align-items: flex-start;
500 max-width: 1400px;
501 margin: 0 auto;
502 width: 100%;
503 padding: 2rem 1.5rem;
504 gap: 2rem;
505}
506
507/* ===== SIDEBAR ===== */
508#sidebar {
509 width: var(--sidebar-width);
510 flex-shrink: 0;
511 position: sticky;
512 top: 1.5rem;
513 max-height: calc(100vh - 3rem);
514 overflow-y: auto;
515}
516.sidebar-inner {
517 background: var(--color-surface);
518 border: 1px solid var(--color-border);
519 border-radius: var(--radius);
520 box-shadow: var(--shadow-sm);
521 padding: 1.25rem;
522}
523.sidebar-section { margin-bottom: 1.5rem; }
524.sidebar-section:last-child { margin-bottom: 0; }
525.sidebar-heading {
526 font-family: var(--font-ui);
527 font-size: .7rem;
528 font-weight: 700;
529 text-transform: uppercase;
530 letter-spacing: .1em;
531 color: var(--color-text-muted);
532 margin-bottom: .75rem;
533 padding-bottom: .4rem;
534 border-bottom: 1px solid var(--color-border);
535}
536
537/* Sidebar TOC — nested lists */
538#toc-list { list-style: none; }
539#toc-list li { position: relative; }
540#toc-list ul { list-style: none; padding-left: .9rem; border-left: 2px solid var(--color-border); margin: .1rem 0 .1rem .3rem; }
541#toc-list ul ul { border-left-color: var(--color-bg-alt); }
542#toc-list a {
543 color: var(--color-text-muted);
544 text-decoration: none;
545 font-family: var(--font-ui);
546 display: block;
547 padding: .2rem 0;
548 transition: color var(--transition);
549 line-height: 1.3;
550 font-size: .875rem;
551}
552#toc-list > li > a { font-size: .9rem; font-weight: 600; color: var(--color-text); }
553#toc-list a:hover { color: var(--color-primary); }
554#toc-list a.active { color: var(--color-accent); font-weight: 600; }
555
556/* Sidebar comments list */
557#comments-list { list-style: none; counter-reset: comment-counter; }
558#comments-list li {
559 counter-increment: comment-counter;
560 position: relative;
561 padding: .3rem .3rem .3rem 1.6rem;
562 margin-bottom: .4rem;
563 font-family: var(--font-ui);
564 font-size: .82rem;
565 line-height: 1.4;
566 border-left: 2px solid var(--color-critic-comment-fg);
567 background: var(--color-critic-comment-bg);
568 border-radius: 0 3px 3px 0;
569}
570#comments-list li::before {
571 content: counter(comment-counter);
572 position: absolute;
573 left: .35rem;
574 top: .3rem;
575 font-size: .7rem;
576 font-weight: 700;
577 color: var(--color-critic-comment-fg);
578 line-height: 1;
579}
580#comments-list a {
581 color: var(--color-critic-comment-fg);
582 text-decoration: none;
583 display: block;
584}
585#comments-list a:hover { text-decoration: underline; }
586
587/* Critic comment anchors in text */
588.critic-comment-anchor { scroll-margin-top: 1.5rem; }
589
590/* Sidebar external links */
591#ext-links-list { list-style: none; }
592#ext-links-list li { margin-bottom: .25rem; }
593#ext-links-list a {
594 font-family: var(--font-ui);
595 font-size: .82rem;
596 color: var(--color-primary-light);
597 text-decoration: none;
598 display: block;
599 white-space: nowrap;
600 overflow: hidden;
601 text-overflow: ellipsis;
602 padding: .15rem 0;
603 transition: color var(--transition);
604}
605#ext-links-list a:hover { color: var(--color-accent); text-decoration: underline; }
606#ext-links-list a::before { content: '↗ '; font-size: .75rem; opacity: .6; }
607
608/* Sidebar custom content */
609#sidebar-content { font-size: .9rem; }
610#sidebar-content h2, #sidebar-content h3 {
611 font-family: var(--font-heading);
612 font-size: 1rem;
613 color: var(--color-primary);
614 margin: .75rem 0 .35rem;
615}
616#sidebar-content p { margin-bottom: .5rem; color: var(--color-text-muted); }
617#sidebar-content a { color: var(--color-primary-light); }
618#sidebar-content ul, #sidebar-content ol { padding-left: 1.25rem; margin-bottom: .5rem; }
619#sidebar-content li { margin-bottom: .2rem; }
620
621/* ===== CONTENT AREA ===== */
622#main-content {
623 flex: 1;
624 min-width: 0;
625}
626#page-content {
627 background: var(--color-surface);
628 border: 1px solid var(--color-border);
629 border-radius: var(--radius);
630 box-shadow: var(--shadow-sm);
631 padding: 2.5rem 3rem;
632}
633
634/* ===== TYPOGRAPHY ===== */
635.page-title {
636 font-family: var(--font-heading);
637 font-size: clamp(1.8rem, 3.5vw, 2.6rem);
638 font-weight: 700;
639 letter-spacing: -.03em;
640 color: var(--color-primary);
641 margin-bottom: .5rem;
642 line-height: 1.15;
643 padding-bottom: .75rem;
644 border-bottom: 3px solid var(--color-accent);
645}
646#page-content h2 {
647 font-family: var(--font-heading);
648 font-size: 1.65rem;
649 font-weight: 700;
650 color: var(--color-primary);
651 margin: 2.5rem 0 .75rem;
652 padding-bottom: .35rem;
653 border-bottom: 1px solid var(--color-border);
654 letter-spacing: -.02em;
655}
656#page-content h3 {
657 font-family: var(--font-heading);
658 font-size: 1.25rem;
659 font-weight: 600;
660 color: var(--color-primary-light);
661 margin: 1.75rem 0 .5rem;
662 letter-spacing: -.01em;
663}
664#page-content h4 {
665 font-family: var(--font-ui);
666 font-size: 1rem;
667 font-weight: 700;
668 color: var(--color-text);
669 margin: 1.25rem 0 .4rem;
670 text-transform: uppercase;
671 letter-spacing: .04em;
672}
673#page-content p { margin-bottom: 1rem; }
674#page-content a { color: var(--color-primary-light); }
675#page-content a:hover { color: var(--color-accent); }
676#page-content ul, #page-content ol { padding-left: 1.75rem; margin-bottom: 1rem; }
677#page-content li { margin-bottom: .3rem; }
678#page-content ul li::marker { color: var(--color-accent); }
679#page-content ol li::marker { color: var(--color-accent); font-weight: 700; }
680#page-content blockquote {
681 margin: 1.5rem 0;
682 padding: 1rem 1.25rem 1rem 1.5rem;
683 border-left: 4px solid var(--color-accent);
684 background: var(--color-bg-alt);
685 border-radius: 0 var(--radius) var(--radius) 0;
686 font-style: italic;
687 color: var(--color-text-muted);
688}
689#page-content blockquote p { margin: 0; }
690#page-content code {
691 font-family: var(--font-mono);
692 font-size: .875em;
693 background: var(--color-bg-alt);
694 color: var(--color-primary);
695 padding: .1em .4em;
696 border-radius: 3px;
697 border: 1px solid var(--color-border);
698}
699#page-content pre {
700 background: var(--color-primary-dark);
701 color: #e8e4dc;
702 padding: 1.25rem 1.5rem;
703 border-radius: var(--radius);
704 overflow-x: auto;
705 margin-bottom: 1.25rem;
706 font-family: var(--font-mono);
707 font-size: .875rem;
708 line-height: 1.6;
709 box-shadow: var(--shadow-md);
710}
711#page-content pre code {
712 background: none;
713 border: none;
714 padding: 0;
715 color: inherit;
716 font-size: inherit;
717}
718#page-content hr {
719 border: none;
720 border-top: 2px solid var(--color-border);
721 margin: 2rem 0;
722}
723#page-content img {
724 max-width: 100%;
725 height: auto;
726 border-radius: var(--radius);
727 box-shadow: var(--shadow-md);
728 margin: .5rem 0;
729}
730#page-content strong { font-weight: 700; color: var(--color-primary); }
731#page-content em { font-style: italic; }
732
733/* ===== TABLES ===== */
734#page-content table {
735 width: 100%;
736 border-collapse: collapse;
737 margin: 1.5rem 0;
738 font-size: .93rem;
739 box-shadow: var(--shadow-sm);
740 border-radius: var(--radius);
741 overflow: hidden;
742}
743#page-content thead { background: var(--color-primary); color: var(--color-text-inverse); }
744#page-content thead th {
745 padding: .75rem 1rem;
746 text-align: left;
747 font-family: var(--font-ui);
748 font-size: .82rem;
749 font-weight: 700;
750 text-transform: uppercase;
751 letter-spacing: .06em;
752}
753#page-content tbody tr { border-bottom: 1px solid var(--color-border); transition: background var(--transition); }
754#page-content tbody tr:hover { background: var(--color-bg-alt); }
755#page-content tbody tr:last-child { border-bottom: none; }
756#page-content td { padding: .65rem 1rem; vertical-align: top; }
757
758/* ===== CRITIC MARKUP ===== */
759ins.critic {
760 background: var(--color-critic-ins-bg);
761 color: var(--color-critic-ins-fg);
762 text-decoration: none;
763 padding: .05em .25em;
764 border-radius: 3px;
765 border-bottom: 2px solid var(--color-critic-ins-fg);
766}
767del.critic {
768 background: var(--color-critic-del-bg);
769 color: var(--color-critic-del-fg);
770 text-decoration: line-through;
771 text-decoration-color: var(--color-critic-del-fg);
772 padding: .05em .25em;
773 border-radius: 3px;
774 opacity: .85;
775}
776mark.critic {
777 background: var(--color-critic-mark-bg);
778 color: var(--color-critic-mark-fg);
779 padding: .05em .25em;
780 border-radius: 3px;
781}
782.critic-comment {
783 display: inline-block;
784 position: relative;
785 cursor: help;
786}
787.critic-comment-marker {
788 background: var(--color-critic-comment-bg);
789 color: var(--color-critic-comment-fg);
790 font-size: .78em;
791 padding: .1em .4em;
792 border-radius: 3px;
793 font-family: var(--font-ui);
794 font-weight: 700;
795 vertical-align: middle;
796 border: 1px solid var(--color-critic-comment-fg);
797}
798.critic-comment-text {
799 display: none;
800 position: absolute;
801 bottom: 130%;
802 left: 50%;
803 transform: translateX(-50%);
804 background: var(--color-primary-dark);
805 color: var(--color-text-inverse);
806 padding: .5rem .75rem;
807 border-radius: var(--radius);
808 font-size: .82rem;
809 font-family: var(--font-ui);
810 white-space: nowrap;
811 max-width: 250px;
812 white-space: normal;
813 box-shadow: var(--shadow-lg);
814 z-index: 100;
815 line-height: 1.4;
816}
817.critic-comment-text::after {
818 content: '';
819 position: absolute;
820 top: 100%; left: 50%;
821 transform: translateX(-50%);
822 border: 6px solid transparent;
823 border-top-color: var(--color-primary-dark);
824}
825.critic-comment:hover .critic-comment-text,
826.critic-comment:focus-within .critic-comment-text { display: block; }
827.critic-comment-marker:focus { outline: 2px solid var(--color-accent); outline-offset: 2px; }
828
829/* ===== NEWS ITEMS ===== */
830.news-list { list-style: none; padding: 0; margin: 0; }
831.news-item {
832 margin-bottom: 2rem;
833 padding-bottom: 2rem;
834 border-bottom: 1px solid var(--color-border);
835}
836.news-item:last-child { border-bottom: none; margin-bottom: 0; padding-bottom: 0; }
837.news-date-header {
838 display: flex;
839 align-items: baseline;
840 gap: .6rem;
841 margin-bottom: .6rem;
842 flex-wrap: wrap;
843}
844.news-date-icon { font-size: 1rem; line-height: 1; flex-shrink: 0; }
845.news-dow {
846 font-family: var(--font-ui);
847 font-size: .82rem;
848 font-weight: 700;
849 text-transform: uppercase;
850 letter-spacing: .07em;
851 color: var(--color-accent);
852}
853.news-date {
854 font-family: var(--font-ui);
855 font-size: .9rem;
856 font-weight: 600;
857 color: var(--color-text-muted);
858}
859.news-title {
860 font-family: var(--font-heading);
861 font-size: 1.2rem;
862 font-weight: 700;
863 color: var(--color-primary);
864 margin-bottom: .4rem;
865 line-height: 1.3;
866}
867.news-body { font-size: .97rem; }
868.news-body p:last-child { margin-bottom: 0; }
869.news-body p { margin-bottom: .6rem; }
870
871/* News calendar widget */
872.news-cal {
873 font-family: var(--font-ui);
874 font-size: .82rem;
875 user-select: none;
876}
877.news-cal-nav {
878 display: flex;
879 align-items: center;
880 justify-content: space-between;
881 margin-bottom: .5rem;
882}
883.news-cal-nav button {
884 background: none;
885 border: 1px solid var(--color-border);
886 border-radius: 3px;
887 padding: .15rem .45rem;
888 cursor: pointer;
889 color: var(--color-text-muted);
890 font-size: .8rem;
891 line-height: 1.4;
892 transition: background var(--transition), color var(--transition);
893}
894.news-cal-nav button:hover { background: var(--color-bg-alt); color: var(--color-text); }
895.news-cal-nav button:focus-visible { outline: 2px solid var(--color-accent); outline-offset: 2px; }
896.news-cal-month {
897 font-weight: 700;
898 font-size: .85rem;
899 color: var(--color-primary);
900 text-align: center;
901 flex: 1;
902}
903.news-cal-grid {
904 display: grid;
905 grid-template-columns: repeat(7, 1fr);
906 gap: 2px;
907 text-align: center;
908}
909.news-cal-dow {
910 color: var(--color-text-muted);
911 font-size: .7rem;
912 font-weight: 700;
913 padding: .1rem 0 .3rem;
914 text-transform: uppercase;
915}
916.news-cal-day {
917 padding: .2rem .1rem;
918 border-radius: 3px;
919 line-height: 1.4;
920 color: var(--color-text-muted);
921}
922.news-cal-day.empty { visibility: hidden; }
923.news-cal-day.today {
924 background: var(--color-bg-alt);
925 color: var(--color-primary);
926 font-weight: 700;
927}
928.news-cal-day.has-news {
929 background: var(--color-primary);
930 color: var(--color-text-inverse);
931 font-weight: 700;
932 cursor: pointer;
933 border-radius: 3px;
934 transition: background var(--transition);
935}
936.news-cal-day.has-news:hover { background: var(--color-accent); }
937.news-cal-day.has-news:focus-visible { outline: 2px solid var(--color-accent); outline-offset: 2px; }
938.news-cal-day.has-news.today { background: var(--color-accent); }
939
940/* ===== FOOTER ===== */
941#site-footer {
942 background: var(--color-primary-dark);
943 color: rgba(255,255,255,.7);
944 padding: 1.5rem 2rem;
945 display: flex;
946 align-items: center;
947 flex-wrap: wrap;
948 gap: 1rem;
949 font-family: var(--font-ui);
950 font-size: .85rem;
951}
952#site-footer a { color: rgba(255,255,255,.85); text-decoration: none; transition: color var(--transition); }
953#site-footer a:hover { color: #fff; text-decoration: underline; }
954.footer-copyright { flex: 1; }
955.footer-links { display: flex; gap: 1rem; flex-wrap: wrap; }
956.footer-links a {
957 padding: .2rem .5rem;
958 border-radius: 3px;
959 transition: background var(--transition);
960}
961.footer-links a:hover { background: rgba(255,255,255,.1); }
962
963/* ===== LOADING / ERROR ===== */
964#loading-screen {
965 position: fixed; inset: 0;
966 background: var(--color-bg);
967 display: flex;
968 align-items: center;
969 justify-content: center;
970 z-index: 9999;
971 flex-direction: column;
972 gap: 1rem;
973}
974.loading-spinner {
975 width: 40px; height: 40px;
976 border: 3px solid var(--color-border);
977 border-top-color: var(--color-primary);
978 border-radius: 50%;
979 animation: spin .8s linear infinite;
980}
981@keyframes spin { to { transform: rotate(360deg); } }
982.loading-text {
983 font-family: var(--font-ui);
984 color: var(--color-text-muted);
985 font-size: .9rem;
986}
987#error-screen {
988 display: none;
989 position: fixed; inset: 0;
990 background: var(--color-bg);
991 align-items: center;
992 justify-content: center;
993 z-index: 9999;
994 flex-direction: column;
995 gap: 1rem;
996 padding: 2rem;
997 text-align: center;
998}
999#error-screen.active { display: flex; }
1000.error-icon { font-size: 3rem; }
1001.error-title { font-family: var(--font-heading); font-size: 1.5rem; color: var(--color-primary); }
1002.error-msg { font-family: var(--font-ui); color: var(--color-text-muted); max-width: 500px; line-height: 1.6; }
1003.error-msg code { font-family: var(--font-mono); font-size: .9em; background: var(--color-bg-alt); padding: .1em .4em; border-radius: 3px; }
1004
1005/* ===== SIDEBAR MOBILE TOGGLE ===== */
1006/* Overlay a toggle tlačítko se na mobilu nepoužívají — sidebar je pod obsahem */
1007#sidebar-overlay { display: none; }
1008
1009/* ===== RESPONSIVE ===== */
1010@media (max-width: 900px) {
1011 #main-wrapper { flex-direction: column; padding: 1rem; gap: 1rem; }
1012 #menu-toggle { display: none; }
1013
1014 /* Sidebar: statický, plná šířka, pod obsahem */
1015 #sidebar {
1016 position: static;
1017 width: 100%;
1018 max-height: none;
1019 overflow-y: visible;
1020 }
1021 /* Na mobilu zobrazit sekce sidebaru vedle sebe (grid), nebo pod sebou */
1022 .sidebar-inner {
1023 display: grid;
1024 grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
1025 gap: .75rem;
1026 }
1027 /* Každá sidebar sekce jako samostatná karta */
1028 .sidebar-section {
1029 margin-bottom: 0;
1030 }
1031
1032 #page-content { padding: 1.5rem 1.25rem; }
1033 #site-header { padding: 1.25rem 1.25rem 0; }
1034 #site-nav { margin: 0 -1.25rem; padding-left: 1.25rem; padding-right: 1.25rem; }
1035}
1036@media (max-width: 560px) {
1037 #site-title { font-size: 1.5rem; }
1038 #page-content { padding: 1rem .875rem; }
1039 #main-wrapper { padding: .75rem; }
1040 #search-box { max-width: none; }
1041 /* Na malých displejích grid padne na jeden sloupec */
1042 .sidebar-inner { grid-template-columns: 1fr; }
1043}
1044
1045/* ===== PRINT ===== */
1046@media print {
1047 #site-nav, #toolbar, #sidebar, #site-footer { display: none; }
1048 #main-wrapper { padding: 0; }
1049 #page-content { box-shadow: none; border: none; }
1050}
1051
1052/* ===== FOCUS VISIBLE ===== */
1053:focus-visible {
1054 outline: 3px solid var(--color-accent);
1055 outline-offset: 2px;
1056 border-radius: 2px;
1057}
1058</style>
1059<link rel="preconnect" href="https://fonts.googleapis.com">
1060<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
1061<link href="https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;700&family=Playfair+Display:ital,wght@0,700;1,400&family=Source+Serif+4:ital,wght@0,400;0,600;1,400&family=JetBrains+Mono&display=swap" rel="stylesheet">
1062</head>
1063<body>
1064
1065<a href="#main-content" class="skip-link" id="skip-link">Přeskočit na obsah</a>
1066
1067<div id="loading-screen" aria-live="polite" aria-busy="true">
1068 <div class="loading-spinner" role="presentation"></div>
1069 <p class="loading-text" id="loading-text">Načítám…</p>
1070</div>
1071
1072<div id="error-screen" role="alert" aria-live="assertive">
1073 <div class="error-icon" aria-hidden="true">⚠️</div>
1074 <h1 class="error-title" id="error-title">Chyba načítání</h1>
1075 <p class="error-msg" id="error-msg"></p>
1076</div>
1077
1078<header id="site-header" role="banner">
1079 <div class="site-branding">
1080 <h2 id="site-title"></h2>
1081 <p id="site-tagline"></p>
1082 </div>
1083 <nav id="site-nav" role="navigation" aria-label="Hlavní navigace">
1084 <!-- filled by JS -->
1085 </nav>
1086</header>
1087
1088<div id="toolbar" role="search">
1089 <button id="menu-toggle" aria-expanded="false" aria-controls="sidebar" aria-label="Otevřít postranní panel">☰ Menu</button>
1090 <div id="search-box">
1091 <label for="search-input" class="sr-only" id="search-label">Hledat</label>
1092 <input type="search" id="search-input" autocomplete="off" aria-label="Hledat na webu">
1093 <button id="search-btn" type="button">🔍 <span id="search-btn-label">Hledat</span></button>
1094 <button id="search-clear" type="button" hidden id="search-clear-btn"><span id="search-clear-label">Zrušit</span></button>
1095 </div>
1096</div>
1097
1098<div id="sidebar-overlay" aria-hidden="true"></div>
1099
1100<div id="main-wrapper">
1101 <aside id="sidebar" role="complementary" aria-label="Postranní panel">
1102 <div class="sidebar-inner">
1103 <div id="sidebar-custom" class="sidebar-section" hidden>
1104 <div id="sidebar-content"></div>
1105 </div>
1106 <nav class="sidebar-section" aria-label="Obsah stránky" id="toc-nav">
1107 <h2 class="sidebar-heading" id="toc-heading">Obsah</h2>
1108 <ul id="toc-list"></ul>
1109 </nav>
1110 <nav class="sidebar-section" id="ext-links-nav" hidden aria-label="Externí odkazy na stránce">
1111 <h2 class="sidebar-heading" id="ext-links-heading">Odkazy</h2>
1112 <ul id="ext-links-list"></ul>
1113 </nav>
1114 <nav class="sidebar-section" id="comments-nav" hidden aria-label="Komentáře v textu">
1115 <h2 class="sidebar-heading" id="comments-heading">Komentáře</h2>
1116 <ol id="comments-list"></ol>
1117 </nav>
1118 <div class="sidebar-section" id="news-calendar-nav" hidden>
1119 <h2 class="sidebar-heading" id="news-calendar-heading">Kalendář</h2>
1120 <div id="news-calendar"></div>
1121 </div>
1122 </div>
1123 </aside>
1124
1125 <main id="main-content" tabindex="-1">
1126 <div id="search-results" role="region" aria-live="polite" aria-label="Výsledky hledání">
1127 <h2 id="search-results-title"></h2>
1128 <div id="search-results-list"></div>
1129 </div>
1130 <article id="page-content" aria-live="polite">
1131 <!-- filled by JS -->
1132 </article>
1133 </main>
1134</div>
1135
1136<footer id="site-footer" role="contentinfo">
1137 <p class="footer-copyright" id="footer-copyright"></p>
1138 <nav class="footer-links" id="footer-links" aria-label="Patičkové odkazy"></nav>
1139</footer>
1140
1141<script>
1142// =============================================
1143// WEBSITE ENGINE v1.0
1144// Single-file static site renderer
1145// =============================================
1146
1147const Engine = (() => {
1148
1149 // --- State ---
1150 let config = {};
1151 let lang = {};
1152 let sections = []; // [{id, title, slug, content, headings}]
1153 let sidebarMD = '';
1154 let searchIndex = [];
1155 let currentSection = null;
1156
1157 // --- Fetch helpers ---
1158 async function fetchText(url) {
1159 const r = await fetch(url);
1160 if (!r.ok) throw new Error(`HTTP ${r.status}: ${url}`);
1161 const text = await r.text();
1162 return text.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
1163 }
1164 async function fetchJSON(url) {
1165 const r = await fetch(url);
1166 if (!r.ok) throw new Error(`HTTP ${r.status}: ${url}`);
1167 return r.json();
1168 }
1169
1170 // --- Slug ---
1171 function slugify(text) {
1172 return text.toLowerCase()
1173 .normalize('NFD').replace(/[\u0300-\u036f]/g, '')
1174 .replace(/[^a-z0-9]+/g, '-')
1175 .replace(/^-|-$/g, '');
1176 }
1177
1178 // =============================================
1179 // MARKDOWN PARSER
1180 // =============================================
1181 function parseMD(md, basePath) {
1182 let html = md;
1183
1184 // Escape HTML entities in code blocks first
1185 const codeBlocks = [];
1186 html = html.replace(/```([\s\S]*?)```/g, (_, code) => {
1187 const idx = codeBlocks.push(`<pre><code>${escHtml(code.replace(/^\n/, ''))}</code></pre>`) - 1;
1188 return `\x00CODE${idx}\x00`;
1189 });
1190 const inlineCodes = [];
1191 html = html.replace(/`([^`]+)`/g, (_, code) => {
1192 const idx = inlineCodes.push(`<code>${escHtml(code)}</code>`) - 1;
1193 return `\x00ICODE${idx}\x00`;
1194 });
1195
1196 // CriticMarkup — order matters
1197 // {++ insertion ++}
1198 html = html.replace(/\{\+\+([\s\S]*?)\+\+\}/g, (_, t) =>
1199 `<ins class="critic" aria-label="${lang.critic_ins||'Vloženo'}: ${escAttr(t)}">${t}</ins>`);
1200 // {-- deletion --}
1201 html = html.replace(/\{--([\s\S]*?)--\}/g, (_, t) =>
1202 `<del class="critic" aria-label="${lang.critic_del||'Smazáno'}: ${escAttr(t)}">${t}</del>`);
1203 // {~~ old ~> new ~~}
1204 html = html.replace(/\{~~([\s\S]*?)~>([\s\S]*?)~~\}/g, (_, o, n) =>
1205 `<del class="critic" aria-label="${lang.critic_del||'Smazáno'}: ${escAttr(o)}">${o}</del><ins class="critic" aria-label="${lang.critic_ins||'Vloženo'}: ${escAttr(n)}">${n}</ins>`);
1206 // {== highlight ==}
1207 html = html.replace(/\{==([\s\S]*?)==\}/g, (_, t) =>
1208 `<mark class="critic" aria-label="${lang.critic_mark||'Zvýrazněno'}: ${escAttr(t)}">${t}</mark>`);
1209 // {>> comment <<}
1210 html = html.replace(/\{>>([\s\S]*?)<<\}/g, (_, t) =>
1211 `<span class="critic-comment"><button class="critic-comment-marker" tabindex="0" aria-label="${lang.critic_comment||'Komentář'}: ${escAttr(t)}">💬</button><span class="critic-comment-text" role="tooltip">${escHtml(t)}</span></span>`);
1212
1213 // Tables
1214 html = html.replace(/^(\|.+\|\n)([ \t]*\|[ \t]*[-:]+[ \t]*\|.+\n)((?:\|.+\|\n?)*)/gm,
1215 (_, head, sep, body) => {
1216 const alignments = sep.split('|').filter(c => c.trim())
1217 .map(c => { c = c.trim(); return c.endsWith(':') && c.startsWith(':') ? 'center' : c.endsWith(':') ? 'right' : 'left'; });
1218 const ths = head.split('|').filter(c => c.trim() !== '').map((c, i) =>
1219 `<th style="text-align:${alignments[i]||'left'}">${c.trim()}</th>`).join('');
1220 const rows = body.trim().split('\n').filter(Boolean).map(row => {
1221 const tds = row.split('|').filter(c => c.trim() !== '').map((c, i) =>
1222 `<td style="text-align:${alignments[i]||'left'}">${parseMDInline(c.trim())}</td>`).join('');
1223 return `<tr>${tds}</tr>`;
1224 }).join('');
1225 return `<table><thead><tr>${ths}</tr></thead><tbody>${rows}</tbody></table>`;
1226 });
1227
1228 // Headings (h1–h6)
1229 html = html.replace(/^(#{1,6})\s+(.+)$/gm, (_, hashes, text) => {
1230 const level = hashes.length;
1231 const id = slugify(text);
1232 return `<h${level} id="${id}">${parseMDInline(text)}</h${level}>`;
1233 });
1234
1235 // Horizontal rules
1236 html = html.replace(/^[-*_]{3,}\s*$/gm, '<hr>');
1237
1238 // Blockquotes
1239 html = html.replace(/^(>+)\s?(.*)$/gm, (_, marks, text) => `<blockquote><p>${parseMDInline(text)}</p></blockquote>`);
1240
1241 // Unordered lists
1242 html = html.replace(/(^[-*+] .+\n?)+/gm, match => {
1243 const items = match.trim().split('\n').map(l => `<li>${parseMDInline(l.replace(/^[-*+] /, '').trim())}</li>`).join('');
1244 return `<ul>${items}</ul>`;
1245 });
1246 // Ordered lists
1247 html = html.replace(/(^\d+\. .+\n?)+/gm, match => {
1248 const items = match.trim().split('\n').map(l => `<li>${parseMDInline(l.replace(/^\d+\. /, '').trim())}</li>`).join('');
1249 return `<ol>${items}</ol>`;
1250 });
1251
1252 // Paragraphs (lines not starting with block elements)
1253 const blockTags = ['<h','<ul','<ol','<li','<blockquote','<table','<thead','<tbody','<tr','<td','<th','<pre','<hr','<ins','<del','<mark'];
1254 const lines = html.split('\n');
1255 const result = [];
1256 let pBuf = [];
1257 const flushP = () => {
1258 if (pBuf.length) { result.push(`<p>${pBuf.join(' ')}</p>`); pBuf = []; }
1259 };
1260 for (const line of lines) {
1261 const trimmed = line.trim();
1262 if (!trimmed || trimmed.startsWith('\x00CODE') || blockTags.some(t => trimmed.startsWith(t)) || trimmed.endsWith('>')) {
1263 flushP();
1264 if (trimmed) result.push(trimmed);
1265 } else {
1266 pBuf.push(parseMDInline(trimmed));
1267 }
1268 }
1269 flushP();
1270 html = result.join('\n');
1271
1272 // Restore code
1273 html = html.replace(/\x00CODE(\d+)\x00/g, (_, i) => codeBlocks[i]);
1274 html = html.replace(/\x00ICODE(\d+)\x00/g, (_, i) => inlineCodes[i]);
1275
1276 return html;
1277 }
1278
1279 function parseMDInline(text) {
1280 // Images before links
1281 text = text.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, (_, alt, src) =>
1282 `<img src="${escAttr(src)}" alt="${escAttr(alt)}" loading="lazy">`);
1283 // Internal links [text] (no URL)
1284 text = text.replace(/\[([^\]]+)\](?!\()/g, (_, title) => {
1285 const slug = slugify(title);
1286 return `<a href="#${slug}" class="internal-link" data-target="${escAttr(title)}">${title}</a>`;
1287 });
1288 // External links [text](url)
1289 text = text.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (_, t, url) =>
1290 `<a href="${escAttr(url)}"${url.startsWith('http') ? ' target="_blank" rel="noopener noreferrer"' : ''}>${t}</a>`);
1291 // Bold+italic
1292 text = text.replace(/\*\*\*([^*]+)\*\*\*/g, '<strong><em>$1</em></strong>');
1293 // Bold
1294 text = text.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>');
1295 text = text.replace(/__([^_]+)__/g, '<strong>$1</strong>');
1296 // Italic
1297 text = text.replace(/\*([^*]+)\*/g, '<em>$1</em>');
1298 text = text.replace(/_([^_]+)_/g, '<em>$1</em>');
1299 // Auto-linkify bare URLs not already inside a tag or attribute
1300 text = text.replace(/(?<![="'>])(https?:\/\/[^\s<>"')\]]+)/g, url =>
1301 `<a href="${escAttr(url)}" target="_blank" rel="noopener noreferrer">${escHtml(url)}</a>`);
1302 // Strikethrough
1303 text = text.replace(/~~([^~]+)~~/g, '<del>$1</del>');
1304 return text;
1305 }
1306
1307 function escHtml(s) {
1308 return s.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
1309 }
1310 function escAttr(s) {
1311 return s.replace(/"/g,'&quot;').replace(/'/g,'&#39;');
1312 }
1313
1314 // =============================================
1315 // SECTION SPLITTING
1316 // =============================================
1317 // A section whose content begins with a line containing only "multisection"
1318 // (case-insensitive) is a multisection group: its H2s become sub-pages and
1319 // it acts as a nav group rather than a navigable page itself.
1320 function splitSections(md) {
1321 const lines = md.split('\n');
1322 const rawH1s = [];
1323 let current = null;
1324
1325 for (const line of lines) {
1326 const h1 = line.match(/^#(?!#)\s+(.+)$/);
1327 if (h1) {
1328 if (current) rawH1s.push(current);
1329 const title = h1[1].trim();
1330 current = { title, slug: slugify(title), rawContent: '' };
1331 } else if (current) {
1332 current.rawContent += line + '\n';
1333 }
1334 }
1335 if (current) rawH1s.push(current);
1336
1337 // Detect multisection: first non-empty line of rawContent is "multisection"
1338 const result = [];
1339 for (const sec of rawH1s) {
1340 const firstLine = sec.rawContent.split('\n').find(l => l.trim() !== '');
1341 if (firstLine && firstLine.trim().toLowerCase() === 'multisection') {
1342 // Strip the "multisection" keyword line from content
1343 const bodyAfterKeyword = sec.rawContent.replace(/^[\s\S]*?multisection[ \t]*\n/i, '');
1344 // Split into sub-sections by H2
1345 const subLines = bodyAfterKeyword.split('\n');
1346 const subSections = [];
1347 let curSub = null;
1348 for (const line of subLines) {
1349 const h2 = line.match(/^##\s+(.+)$/);
1350 if (h2) {
1351 if (curSub) subSections.push(curSub);
1352 const subTitle = h2[1].trim();
1353 curSub = { title: subTitle, slug: slugify(subTitle), rawContent: '', parentSlug: sec.slug, parentTitle: sec.title };
1354 } else if (curSub) {
1355 curSub.rawContent += line + '\n';
1356 }
1357 }
1358 if (curSub) subSections.push(curSub);
1359
1360 // Push a group placeholder (not a real page)
1361 result.push({
1362 title: sec.title,
1363 slug: sec.slug,
1364 isGroup: true,
1365 children: subSections
1366 });
1367 // Push each sub-section as a top-level navigable section
1368 subSections.forEach(sub => result.push(sub));
1369 } else {
1370 result.push(sec);
1371 }
1372 }
1373 return result;
1374 }
1375
1376 // =============================================
1377 // TOC EXTRACTION
1378 // =============================================
1379 function extractHeadings(html) {
1380 const headings = [];
1381 const re = /<h([234])[^>]*id="([^"]*)"[^>]*>([\s\S]*?)<\/h\1>/gi;
1382 let m;
1383 while ((m = re.exec(html)) !== null) {
1384 headings.push({ level: parseInt(m[1]), id: m[2], text: m[3].replace(/<[^>]+>/g,'') });
1385 }
1386 return headings;
1387 }
1388
1389 // =============================================
1390 // SEARCH INDEX
1391 // =============================================
1392 function buildSearchIndex() {
1393 searchIndex = sections
1394 .filter(sec => !sec.isGroup)
1395 .map(sec => ({
1396 slug: sec.slug,
1397 title: sec.title,
1398 text: (sec.rawContent || '').replace(/[#*_`\[\](){}|>]/g,'').toLowerCase(),
1399 rawContent: sec.rawContent || ''
1400 }));
1401 }
1402
1403 function doSearch(query) {
1404 const q = query.toLowerCase().normalize('NFD').replace(/[\u0300-\u036f]/g,'');
1405 const words = q.split(/\s+/).filter(Boolean);
1406 const results = [];
1407
1408 for (const entry of searchIndex) {
1409 const haystack = (entry.title + ' ' + entry.text).normalize('NFD').replace(/[\u0300-\u036f]/g,'');
1410 const score = words.reduce((s, w) => {
1411 const inTitle = entry.title.toLowerCase().normalize('NFD').replace(/[\u0300-\u036f]/g,'').includes(w) ? 3 : 0;
1412 const inBody = (haystack.match(new RegExp(w.replace(/[.*+?^${}()|[\]\\]/g,'\\$&'), 'g')) || []).length;
1413 return s + inTitle + inBody;
1414 }, 0);
1415 if (score > 0) {
1416 // Find excerpt
1417 let excerpt = entry.text;
1418 const firstWord = words[0];
1419 const idx = excerpt.normalize('NFD').replace(/[\u0300-\u036f]/g,'').indexOf(firstWord);
1420 if (idx > -1) {
1421 const start = Math.max(0, idx - 80);
1422 excerpt = (start > 0 ? '…' : '') + excerpt.slice(start, idx + 200) + '…';
1423 } else {
1424 excerpt = excerpt.slice(0, 200) + '…';
1425 }
1426 // Highlight
1427 let highlighted = escHtml(excerpt);
1428 words.forEach(w => {
1429 highlighted = highlighted.replace(new RegExp(w.replace(/[.*+?^${}()|[\]\\]/g,'\\$&'), 'gi'), m => `<mark>${m}</mark>`);
1430 });
1431 results.push({ entry, score, excerpt: highlighted });
1432 }
1433 }
1434 return results.sort((a,b) => b.score - a.score);
1435 }
1436
1437 // =============================================
1438 // ROUTING
1439 // =============================================
1440 function getHashParts() {
1441 const hash = location.hash.replace(/^#/, '');
1442 if (!hash) return { section: null, anchor: null };
1443 // Exact section slug match — skip group entries (they have no page)
1444 for (const sec of sections) {
1445 if (!sec.isGroup && hash === sec.slug) return { section: sec, anchor: null };
1446 }
1447 // Exact heading id match within any non-group section
1448 for (const sec of sections) {
1449 if (sec.headings && sec.headings.find(h => h.id === hash)) {
1450 return { section: sec, anchor: hash };
1451 }
1452 }
1453 return { section: null, anchor: null };
1454 }
1455
1456 function navigate(hash, replace = false) {
1457 if (replace) {
1458 history.replaceState(null, '', hash ? `#${hash}` : location.pathname);
1459 } else {
1460 history.pushState(null, '', hash ? `#${hash}` : location.pathname);
1461 }
1462 render();
1463 }
1464
1465 // =============================================
1466 // RENDER
1467 // =============================================
1468 function render() {
1469 // Hide search if active
1470 hideSearch();
1471
1472 const { section, anchor } = getHashParts();
1473 // If resolved section is a group (no parsedHTML), navigate to first child
1474 let sec = section || sections.find(s => !s.isGroup);
1475 if (sec && sec.isGroup) {
1476 sec = sec.children && sec.children.length ? sec.children[0] : sections.find(s => !s.isGroup);
1477 }
1478 if (!sec) return;
1479 currentSection = sec;
1480
1481 // Update page title
1482 document.title = `${sec.title} | ${config.title || 'Web'}`;
1483
1484 // Update nav active state
1485 document.querySelectorAll('#site-nav a[data-slug]').forEach(a => {
1486 const active = a.dataset.slug === sec.slug;
1487 a.setAttribute('aria-current', active ? 'page' : 'false');
1488 });
1489 // Auto-expand parent group if a child page is active
1490 document.querySelectorAll('.nav-group').forEach(group => {
1491 const hasActive = group.querySelector(`a[aria-current="page"]`);
1492 const btn = group.querySelector('.nav-group-btn');
1493 const submenuId = btn && btn.getAttribute('aria-controls');
1494 const submenu = submenuId && document.getElementById(submenuId);
1495 if (btn && submenu) {
1496 if (hasActive) {
1497 submenu.hidden = false;
1498 btn.setAttribute('aria-expanded', 'true');
1499 }
1500 }
1501 });
1502
1503 // Render content
1504 const html = sec.parsedHTML;
1505 const pageContent = document.getElementById('page-content');
1506 pageContent.innerHTML = `<h1 class="page-title">${escHtml(sec.title)}</h1>${html}`;
1507
1508 // Resolve and validate internal links — broken ones become <span>
1509 processInternalLinks(pageContent);
1510
1511 // Build TOC
1512 buildTOC(sec.headings);
1513
1514 // Render news items into page (before TOC/links — must happen after innerHTML set)
1515 if (sec.hasNews && sec.newsItems && sec.newsItems.length) {
1516 renderNewsItems(sec.newsItems, pageContent);
1517 }
1518
1519 // Build external links sidebar section
1520 buildExtLinks(sec.parsedHTML);
1521
1522 // Build critic comments sidebar section (after DOM is rendered)
1523 buildComments(pageContent);
1524
1525 // Build news calendar sidebar section
1526 buildNewsCalendar(sec.hasNews ? sec.newsItems : null);
1527
1528 // Sidebar custom content
1529 if (sidebarMD) {
1530 const sidebarEl = document.getElementById('sidebar-content');
1531 sidebarEl.innerHTML = parseMD(sidebarMD);
1532 processInternalLinks(sidebarEl);
1533 document.getElementById('sidebar-custom').hidden = false;
1534 }
1535
1536 // Scroll / anchor
1537 requestAnimationFrame(() => {
1538 if (anchor) {
1539 const el = document.getElementById(anchor);
1540 if (el) { el.scrollIntoView({ behavior: 'smooth', block: 'start' }); return; }
1541 }
1542 window.scrollTo({ top: 0, behavior: 'smooth' });
1543 });
1544
1545 // Update TOC active state on scroll
1546 setupScrollSpy();
1547 }
1548
1549 // Returns the hash to navigate to, or null if no target exists
1550 function resolveInternalTarget(target) {
1551 const targetSlug = slugify(target);
1552
1553 // 1) Exact section match (not a group)
1554 if (sections.find(s => !s.isGroup && s.slug === targetSlug)) {
1555 return targetSlug;
1556 }
1557
1558 // 2) Exact heading match in any section
1559 for (const s of sections) {
1560 if (!s.headings) continue;
1561 const hit = s.headings.find(h =>
1562 h.id === targetSlug || slugify(h.text) === targetSlug
1563 );
1564 if (hit) return hit.id;
1565 }
1566
1567 return null;
1568 }
1569
1570 // Walk all .internal-link elements in a container:
1571 // - if target exists → attach click handler
1572 // - if target doesn't exist → replace <a> with <span class="broken-link">
1573 function processInternalLinks(container) {
1574 container.querySelectorAll('.internal-link').forEach(a => {
1575 const target = a.dataset.target;
1576 const resolved = resolveInternalTarget(target);
1577
1578 if (resolved === null) {
1579 // Replace with plain text including the original brackets
1580 const text = document.createTextNode('[' + a.textContent + ']');
1581 a.replaceWith(text);
1582 } else {
1583 a.href = `#${resolved}`;
1584 a.addEventListener('click', e => {
1585 e.preventDefault();
1586 navigate(resolved);
1587 closeMobileMenu();
1588 });
1589 }
1590 });
1591 }
1592
1593 // =============================================
1594 // NEWS ITEMS PARSER & RENDERER
1595 // =============================================
1596
1597 // Supported date formats:
1598 // YYYY-MM-DD 2026-05-18
1599 // DD.MM.YYYY 18.05.2026 or 18.5.2026
1600 // MM/DD/YYYY 5/18/2026 or 05/18/2026
1601 const NEWS_DATE_RE = /^(?:(\d{4})-(\d{1,2})-(\d{1,2})|(\d{1,2})\.(\d{1,2})\.(\d{4})|(\d{1,2})\/(\d{1,2})\/(\d{4})):\s*/;
1602
1603 function parseNewsDate(line) {
1604 const m = line.match(NEWS_DATE_RE);
1605 if (!m) return null;
1606 let y, mo, d;
1607 if (m[1]) { y = +m[1]; mo = +m[2]; d = +m[3]; } // YYYY-MM-DD
1608 else if (m[4]) { d = +m[4]; mo = +m[5]; y = +m[6]; } // DD.MM.YYYY
1609 else { mo = +m[7]; d = +m[8]; y = +m[9]; } // MM/DD/YYYY
1610 const date = new Date(y, mo - 1, d);
1611 if (isNaN(date.getTime())) return null;
1612 return { date, isoKey: `${y}-${String(mo).padStart(2,'0')}-${String(d).padStart(2,'0')}`,
1613 rest: line.slice(m[0].length).trim() };
1614 }
1615
1616 function formatNewsDate(date) {
1617 const l = config.language || 'cs';
1618 const months = lang.months
1619 ? lang.months
1620 : (l === 'cs'
1621 ? ['ledna','února','března','dubna','května','června','července','srpna','září','října','listopadu','prosince']
1622 : ['January','February','March','April','May','June','July','August','September','October','November','December']);
1623 const dows = lang.days_long
1624 ? lang.days_long
1625 : (l === 'cs'
1626 ? ['neděle','pondělí','úterý','středa','čtvrtek','pátek','sobota']
1627 : ['Sunday','Monday','Tuesday','Wednesday','Thursday','Friday','Saturday']);
1628 const dow = dows[date.getDay()];
1629 const day = date.getDate();
1630 const month = months[date.getMonth()];
1631 const year = date.getFullYear();
1632 const dateStr = l === 'cs' ? `${day}. ${month} ${year}` : `${month} ${day}, ${year}`;
1633 return { dow, dateStr };
1634 }
1635
1636 // Parse rawContent and split into news items + regular MD
1637 // Returns { hasNews, newsItems, regularMD }
1638 // newsItems: [{isoKey, date, title, bodyMD}]
1639 function extractNewsItems(rawContent) {
1640 const lines = rawContent.split('
1641');
1642 const newsItems = [];
1643 const regularLines = [];
1644 let i = 0;
1645
1646 while (i < lines.length) {
1647 const line = lines[i];
1648 const parsed = parseNewsDate(line.trim());
1649
1650 if (parsed) {
1651 // First line after date prefix is the title (inline rest of line)
1652 // Subsequent indented/non-empty lines before next date entry are the body
1653 const titleFirstLine = parsed.rest;
1654 const bodyLines = [];
1655
1656 i++;
1657 while (i < lines.length) {
1658 const nextLine = lines[i];
1659 // Stop if next line starts a new news item
1660 if (parseNewsDate(nextLine.trim())) break;
1661 bodyLines.push(nextLine);
1662 i++;
1663 }
1664
1665 // Trim trailing empty lines from body
1666 while (bodyLines.length && !bodyLines[bodyLines.length - 1].trim()) bodyLines.pop();
1667
1668 newsItems.push({
1669 isoKey: parsed.isoKey,
1670 date: parsed.date,
1671 title: titleFirstLine,
1672 bodyMD: bodyLines.join('
1673')
1674 });
1675 } else {
1676 regularLines.push(line);
1677 i++;
1678 }
1679 }
1680
1681 return {
1682 hasNews: newsItems.length > 0,
1683 newsItems: newsItems.sort((a, b) => b.date - a.date), // newest first
1684 regularMD: regularLines.join('
1685')
1686 };
1687 }
1688
1689 // Render news items into page content el, inserting before first h2 or at end
1690 function renderNewsItems(newsItems, pageContentEl) {
1691 if (!newsItems.length) return;
1692
1693 const ul = document.createElement('ul');
1694 ul.className = 'news-list';
1695 ul.setAttribute('aria-label', lang.news_list || 'Novinky');
1696
1697 newsItems.forEach(item => {
1698 const { dow, dateStr } = formatNewsDate(item.date);
1699 const li = document.createElement('li');
1700 li.className = 'news-item';
1701 li.id = `news-${item.isoKey}`;
1702
1703 // Date header
1704 const header = document.createElement('div');
1705 header.className = 'news-date-header';
1706 header.innerHTML =
1707 `<span class="news-date-icon" aria-hidden="true">📅</span>` +
1708 `<span class="news-dow">${escHtml(dow)}</span>` +
1709 `<span class="news-date">${escHtml(dateStr)}</span>`;
1710
1711 li.appendChild(header);
1712
1713 // Title (if present)
1714 if (item.title) {
1715 const titleEl = document.createElement('div');
1716 titleEl.className = 'news-title';
1717 titleEl.innerHTML = parseMDInline(item.title);
1718 li.appendChild(titleEl);
1719 }
1720
1721 // Body (if present) — full markdown
1722 if (item.bodyMD.trim()) {
1723 const bodyEl = document.createElement('div');
1724 bodyEl.className = 'news-body';
1725 bodyEl.innerHTML = parseMD(item.bodyMD);
1726 li.appendChild(bodyEl);
1727 }
1728
1729 ul.appendChild(li);
1730 });
1731
1732 // Insert before first h2 or append
1733 const firstH2 = pageContentEl.querySelector('h2');
1734 if (firstH2) {
1735 pageContentEl.insertBefore(ul, firstH2);
1736 } else {
1737 pageContentEl.appendChild(ul);
1738 }
1739
1740 // Activate internal links within news items
1741 processInternalLinks(ul);
1742 }
1743
1744 // Build calendar widget in sidebar
1745 function buildNewsCalendar(newsItems) {
1746 const nav = document.getElementById('news-calendar-nav');
1747 const calEl = document.getElementById('news-calendar');
1748
1749 if (!newsItems || !newsItems.length) {
1750 nav.hidden = true;
1751 return;
1752 }
1753 nav.hidden = false;
1754 document.getElementById('news-calendar-heading').textContent =
1755 (lang.calendar_icon || '📅') + ' ' + (lang.calendar || 'Kalendář');
1756
1757 // Build a set of ISO keys for fast lookup
1758 const newsDays = new Set(newsItems.map(n => n.isoKey));
1759
1760 const today = new Date();
1761 // Start with month of most recent news item
1762 let viewYear = newsItems[0].date.getFullYear();
1763 let viewMonth = newsItems[0].date.getMonth(); // 0-based
1764
1765 function renderCalendar() {
1766 calEl.innerHTML = '';
1767 const l = config.language || 'cs';
1768 const monthNames = lang.months_short
1769 ? lang.months_short
1770 : (l === 'cs'
1771 ? ['Led','Ún','Bře','Dub','Kvě','Čvn','Čvc','Srp','Zář','Říj','Lis','Pro']
1772 : ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec']);
1773 const dowShort = lang.days_short
1774 ? lang.days_short
1775 : (l === 'cs'
1776 ? ['Po','Út','St','Čt','Pá','So','Ne']
1777 : ['Mo','Tu','We','Th','Fr','Sa','Su']);
1778
1779 const cal = document.createElement('div');
1780 cal.className = 'news-cal';
1781
1782 // Nav row
1783 const nav = document.createElement('div');
1784 nav.className = 'news-cal-nav';
1785 const prevBtn = document.createElement('button');
1786 prevBtn.textContent = '‹';
1787 prevBtn.type = 'button';
1788 prevBtn.setAttribute('aria-label', lang.cal_prev || 'Předchozí měsíc');
1789 prevBtn.addEventListener('click', () => { viewMonth--; if (viewMonth < 0) { viewMonth = 11; viewYear--; } renderCalendar(); });
1790 const nextBtn = document.createElement('button');
1791 nextBtn.textContent = '›';
1792 nextBtn.type = 'button';
1793 nextBtn.setAttribute('aria-label', lang.cal_next || 'Následující měsíc');
1794 nextBtn.addEventListener('click', () => { viewMonth++; if (viewMonth > 11) { viewMonth = 0; viewYear++; } renderCalendar(); });
1795 const monthLabel = document.createElement('div');
1796 monthLabel.className = 'news-cal-month';
1797 monthLabel.textContent = `${monthNames[viewMonth]} ${viewYear}`;
1798 nav.appendChild(prevBtn); nav.appendChild(monthLabel); nav.appendChild(nextBtn);
1799 cal.appendChild(nav);
1800
1801 // Grid
1802 const grid = document.createElement('div');
1803 grid.className = 'news-cal-grid';
1804
1805 // Day-of-week headers (Mon-first for CS, Mon-first for EN too — ISO week)
1806 dowShort.forEach(d => {
1807 const cell = document.createElement('div');
1808 cell.className = 'news-cal-dow';
1809 cell.textContent = d;
1810 cell.setAttribute('aria-hidden', 'true');
1811 grid.appendChild(cell);
1812 });
1813
1814 // First day of month, adjust to Mon=0 week start
1815 const firstDay = new Date(viewYear, viewMonth, 1);
1816 // getDay(): 0=Sun,1=Mon..6=Sat → convert to Mon=0
1817 const startOffset = (firstDay.getDay() + 6) % 7;
1818 const daysInMonth = new Date(viewYear, viewMonth + 1, 0).getDate();
1819
1820 // Empty cells before first day
1821 for (let i = 0; i < startOffset; i++) {
1822 const cell = document.createElement('div');
1823 cell.className = 'news-cal-day empty';
1824 cell.setAttribute('aria-hidden', 'true');
1825 grid.appendChild(cell);
1826 }
1827
1828 // Day cells
1829 for (let d = 1; d <= daysInMonth; d++) {
1830 const isoKey = `${viewYear}-${String(viewMonth+1).padStart(2,'0')}-${String(d).padStart(2,'0')}`;
1831 const isToday = today.getFullYear() === viewYear && today.getMonth() === viewMonth && today.getDate() === d;
1832 const hasNews = newsDays.has(isoKey);
1833
1834 const cell = document.createElement(hasNews ? 'button' : 'div');
1835 cell.className = 'news-cal-day' + (isToday ? ' today' : '') + (hasNews ? ' has-news' : '');
1836 cell.textContent = d;
1837
1838 if (hasNews) {
1839 cell.type = 'button';
1840 cell.setAttribute('aria-label', `${lang.go_to_news || 'Přejít na novinku'} ${isoKey}`);
1841 cell.addEventListener('click', () => {
1842 const target = document.getElementById(`news-${isoKey}`);
1843 if (target) { target.scrollIntoView({ behavior: 'smooth', block: 'start' }); target.focus(); }
1844 });
1845 } else {
1846 cell.setAttribute('aria-hidden', 'true');
1847 }
1848 grid.appendChild(cell);
1849 }
1850
1851 cal.appendChild(grid);
1852 calEl.appendChild(cal);
1853 }
1854
1855 renderCalendar();
1856 }
1857
1858 function buildTOC(headings) {
1859 const root = document.getElementById('toc-list');
1860 root.innerHTML = '';
1861 if (!headings || !headings.length) {
1862 document.getElementById('toc-nav').hidden = true;
1863 return;
1864 }
1865 document.getElementById('toc-nav').hidden = false;
1866
1867 // Build a proper nested tree: h2 = top level, h3 under last h2, h4 under last h3
1868 // We keep a stack of {level, ul} pairs
1869 const makeLink = h => {
1870 const a = document.createElement('a');
1871 a.href = `#${h.id}`;
1872 a.textContent = h.text;
1873 a.addEventListener('click', e => {
1874 e.preventDefault();
1875 history.pushState(null, '', `#${h.id}`);
1876 const el = document.getElementById(h.id);
1877 if (el) el.scrollIntoView({ behavior: 'smooth', block: 'start' });
1878 closeMobileMenu();
1879 });
1880 return a;
1881 };
1882
1883 // stack entries: { level, ul }
1884 const stack = [{ level: 1, ul: root }];
1885
1886 headings.forEach(h => {
1887 const li = document.createElement('li');
1888 li.appendChild(makeLink(h));
1889
1890 // Pop stack until we find a parent whose level is less than current
1891 while (stack.length > 1 && stack[stack.length - 1].level >= h.level) {
1892 stack.pop();
1893 }
1894 stack[stack.length - 1].ul.appendChild(li);
1895
1896 // This li can be a parent for deeper headings — push a new ul onto stack
1897 const childUl = document.createElement('ul');
1898 li.appendChild(childUl);
1899 stack.push({ level: h.level, ul: childUl });
1900 });
1901
1902 // Remove empty trailing uls
1903 root.querySelectorAll('ul:empty').forEach(u => u.remove());
1904 }
1905
1906 function buildExtLinks(html) {
1907 const nav = document.getElementById('ext-links-nav');
1908 const list = document.getElementById('ext-links-list');
1909 list.innerHTML = '';
1910
1911 // Collect all external href values from rendered HTML
1912 const seen = new Set();
1913 const tmp = document.createElement('div');
1914 tmp.innerHTML = html;
1915 tmp.querySelectorAll('a[href]').forEach(a => {
1916 const href = a.getAttribute('href');
1917 if (href && href.startsWith('http') && !seen.has(href)) {
1918 seen.add(href);
1919 const li = document.createElement('li');
1920 const link = document.createElement('a');
1921 link.href = href;
1922 link.target = '_blank';
1923 link.rel = 'noopener noreferrer';
1924 link.textContent = href;
1925 link.title = href;
1926 li.appendChild(link);
1927 list.appendChild(li);
1928 }
1929 });
1930
1931 nav.hidden = seen.size === 0;
1932 if (document.getElementById('ext-links-heading')) {
1933 document.getElementById('ext-links-heading').textContent = (lang.ext_links_icon || '🔗') + ' ' + (lang.ext_links || 'Page links');
1934 }
1935 }
1936 function buildComments(pageContentEl) {
1937 const nav = document.getElementById('comments-nav');
1938 const list = document.getElementById('comments-list');
1939 list.innerHTML = '';
1940
1941 // Collect all critic comments rendered in the page
1942 const markers = pageContentEl.querySelectorAll('.critic-comment');
1943 if (!markers.length) {
1944 nav.hidden = true;
1945 document.getElementById('comments-heading').textContent =
1946 (lang.comments_icon || '💬') + ' ' + (lang.comments || 'Comments');
1947 return;
1948 }
1949
1950 markers.forEach((comment, idx) => {
1951 const n = idx + 1;
1952 // Give each comment an anchor id so we can link to it
1953 const anchorId = `critic-comment-${n}`;
1954 comment.id = anchorId;
1955 comment.classList.add('critic-comment-anchor');
1956
1957 // Extract comment text from the tooltip span
1958 const tooltip = comment.querySelector('.critic-comment-text');
1959 const text = tooltip ? tooltip.textContent.trim() : '';
1960
1961 const li = document.createElement('li');
1962 const a = document.createElement('a');
1963 a.href = `#${anchorId}`;
1964 a.textContent = text || `(${lang.comment_empty || 'empty comment'})`;
1965 a.setAttribute('aria-label', `${lang.comments || 'Comment'} ${n}: ${text}`);
1966 a.addEventListener('click', e => {
1967 e.preventDefault();
1968 const target = document.getElementById(anchorId);
1969 if (target) {
1970 target.scrollIntoView({ behavior: 'smooth', block: 'center' });
1971 // Move focus to the comment button for keyboard/screen-reader users
1972 const btn = target.querySelector('.critic-comment-marker');
1973 if (btn) btn.focus();
1974 }
1975 });
1976 li.appendChild(a);
1977 list.appendChild(li);
1978 });
1979
1980 nav.hidden = false;
1981 document.getElementById('comments-heading').textContent =
1982 (lang.comments_icon || '💬') + ' ' + (lang.comments || 'Comments');
1983 }
1984
1985 let scrollSpyObserver = null;
1986
1987 function setupScrollSpy() {
1988 if (scrollSpyObserver) scrollSpyObserver.disconnect();
1989 scrollSpyObserver = new IntersectionObserver(entries => {
1990 entries.forEach(entry => {
1991 if (entry.isIntersecting) {
1992 const id = entry.target.id;
1993 document.querySelectorAll('#toc-list a').forEach(a => {
1994 a.classList.toggle('active', a.getAttribute('href') === `#${id}`);
1995 });
1996 }
1997 });
1998 }, { rootMargin: '-20% 0px -70% 0px' });
1999 document.querySelectorAll('#page-content h2, #page-content h3, #page-content h4').forEach(el => scrollSpyObserver.observe(el));
2000 }
2001
2002 // =============================================
2003 // SEARCH UI
2004 // =============================================
2005 function showSearch(results, query) {
2006 const container = document.getElementById('search-results');
2007 const titleEl = document.getElementById('search-results-title');
2008 const listEl = document.getElementById('search-results-list');
2009 const pageContent = document.getElementById('page-content');
2010
2011 container.classList.add('active');
2012 pageContent.style.display = 'none';
2013
2014 titleEl.textContent = results.length
2015 ? (lang.search_results_for || 'Results for') + ` "${query}" (${results.length})`
2016 : (lang.search_no_results || 'No results for') + ` "${query}"`;
2017
2018 if (!results.length) {
2019 listEl.innerHTML = `<p class="search-no-results">${lang.search_try_other || 'Try a different keyword.'}</p>`;
2020 return;
2021 }
2022 listEl.innerHTML = results.map(r => `
2023 <div class="search-result-item">
2024 <h3><a href="#${r.entry.slug}">${escHtml(r.entry.title)}</a></h3>
2025 <p class="result-section">${lang.section || 'Section'}</p>
2026 <p class="result-excerpt">${r.excerpt}</p>
2027 </div>
2028 `).join('');
2029 listEl.querySelectorAll('a').forEach(a => {
2030 a.addEventListener('click', e => {
2031 e.preventDefault();
2032 navigate(a.getAttribute('href').replace('#',''));
2033 clearSearch();
2034 });
2035 });
2036 }
2037
2038 function hideSearch() {
2039 document.getElementById('search-results').classList.remove('active');
2040 const pageContent = document.getElementById('page-content');
2041 if (pageContent) pageContent.style.display = '';
2042 }
2043
2044 function clearSearch() {
2045 const inp = document.getElementById('search-input');
2046 inp.value = '';
2047 document.getElementById('search-clear').hidden = true;
2048 hideSearch();
2049 }
2050
2051 // =============================================
2052 // MOBILE MENU
2053 // =============================================
2054 function openMobileMenu() { /* sidebar je na mobilu staticky pod obsahem */ }
2055 function closeMobileMenu() { /* nic k zavírání */ }
2056
2057 // =============================================
2058 // APPLY CONFIG
2059 // =============================================
2060 function applyConfig() {
2061 const palette = (config.color || 'blue').toLowerCase();
2062 document.documentElement.setAttribute('data-palette', palette);
2063 document.documentElement.lang = config.language || 'cs';
2064
2065 document.getElementById('site-title').textContent = config.title || 'Web';
2066 document.getElementById('site-tagline').textContent = config.tagline || '';
2067
2068 // Footer
2069 const copy = document.getElementById('footer-copyright');
2070 if (config.copyright) {
2071 const { name, url, year } = config.copyright;
2072 const y = year || new Date().getFullYear();
2073 copy.innerHTML = `© ${y} ` + (url ? `<a href="${escAttr(url)}" target="_blank" rel="noopener">${escHtml(name||url)}</a>` : escHtml(name||''));
2074 }
2075 const footerLinks = document.getElementById('footer-links');
2076 if (config.footer_links) {
2077 footerLinks.innerHTML = config.footer_links.map(l =>
2078 `<a href="${escAttr(l.url)}"${l.url.startsWith('http') ? ' target="_blank" rel="noopener"' : ''}>${escHtml(l.label)}</a>`
2079 ).join('');
2080 }
2081 }
2082
2083 function applyLang() {
2084 document.getElementById('toc-heading').textContent = (lang.toc_icon || '📋') + ' ' + (lang.toc || 'On this page');
2085 document.getElementById('search-btn-label').textContent = lang.search || 'Search';
2086 document.getElementById('search-clear-label').textContent = lang.cancel || 'Clear';
2087 document.getElementById('search-input').placeholder = lang.search_placeholder || 'Search…';
2088 document.getElementById('search-input').setAttribute('aria-label', lang.search || 'Search');
2089 document.getElementById('skip-link').textContent = lang.skip_to_content || 'Skip to content';
2090 const menuToggle = document.getElementById('menu-toggle');
2091 menuToggle.textContent = '☰ ' + (lang.menu || 'Menu');
2092 menuToggle.setAttribute('aria-label', lang.open_sidebar || 'Open sidebar');
2093 }
2094
2095 function buildNav() {
2096 const nav = document.getElementById('site-nav');
2097 nav.innerHTML = '';
2098
2099 const sectionLinks = document.createElement('div');
2100 sectionLinks.className = 'nav-section-links';
2101
2102 // Only iterate top-level items: groups + non-child sections
2103 sections.forEach(sec => {
2104 if (sec.parentSlug) return; // sub-pages rendered inside their group
2105
2106 if (sec.isGroup) {
2107 // ── Dropdown group ────────────────────────────────────────────────
2108 const wrapper = document.createElement('div');
2109 wrapper.className = 'nav-group';
2110 wrapper.dataset.groupSlug = sec.slug;
2111
2112 const btn = document.createElement('button');
2113 btn.className = 'nav-group-btn';
2114 btn.type = 'button';
2115 btn.textContent = sec.title;
2116 btn.setAttribute('aria-expanded', 'false');
2117 const submenuId = `submenu-${sec.slug}`;
2118 btn.setAttribute('aria-controls', submenuId);
2119
2120 const submenu = document.createElement('ul');
2121 submenu.className = 'nav-submenu';
2122 submenu.id = submenuId;
2123 submenu.setAttribute('role', 'list');
2124 submenu.hidden = true;
2125
2126 sec.children.forEach(child => {
2127 const li = document.createElement('li');
2128 const a = document.createElement('a');
2129 a.href = `#${child.slug}`;
2130 a.textContent = child.title;
2131 a.dataset.slug = child.slug;
2132 a.addEventListener('click', e => {
2133 e.preventDefault();
2134 navigate(child.slug);
2135 closeAllSubmenus();
2136 closeMobileMenu();
2137 });
2138 li.appendChild(a);
2139 submenu.appendChild(li);
2140 });
2141
2142 btn.addEventListener('click', () => {
2143 const isOpen = btn.getAttribute('aria-expanded') === 'true';
2144 closeAllSubmenus();
2145 if (!isOpen) openSubmenu(btn, submenu);
2146 });
2147
2148 // Close on Escape
2149 wrapper.addEventListener('keydown', e => {
2150 if (e.key === 'Escape') closeAllSubmenus();
2151 });
2152
2153 wrapper.appendChild(btn);
2154 wrapper.appendChild(submenu);
2155 sectionLinks.appendChild(wrapper);
2156
2157 } else {
2158 // ── Regular link ──────────────────────────────────────────────────
2159 const a = document.createElement('a');
2160 a.href = `#${sec.slug}`;
2161 a.textContent = sec.title;
2162 a.dataset.slug = sec.slug;
2163 a.addEventListener('click', e => {
2164 e.preventDefault();
2165 closeAllSubmenus();
2166 navigate(sec.slug);
2167 closeMobileMenu();
2168 });
2169 sectionLinks.appendChild(a);
2170 }
2171 });
2172
2173 nav.appendChild(sectionLinks);
2174
2175 // Extra menu links (right side)
2176 if (config.menu_links && config.menu_links.length) {
2177 const extra = document.createElement('div');
2178 extra.className = 'nav-menu-links';
2179 config.menu_links.forEach(l => {
2180 const a = document.createElement('a');
2181 a.href = l.url;
2182 a.textContent = l.label;
2183 if (l.url.startsWith('http')) { a.target = '_blank'; a.rel = 'noopener noreferrer'; }
2184 extra.appendChild(a);
2185 });
2186 nav.appendChild(extra);
2187 }
2188
2189 // Close submenus when clicking outside nav
2190 document.addEventListener('click', e => {
2191 if (!nav.contains(e.target)) closeAllSubmenus();
2192 });
2193 }
2194
2195 function openSubmenu(btn, submenu) {
2196 submenu.hidden = false;
2197 btn.setAttribute('aria-expanded', 'true');
2198 // Focus first item for keyboard users
2199 const first = submenu.querySelector('a');
2200 if (first) first.focus();
2201 }
2202
2203 function closeAllSubmenus() {
2204 document.querySelectorAll('.nav-group-btn').forEach(b => {
2205 b.setAttribute('aria-expanded', 'false');
2206 const id = b.getAttribute('aria-controls');
2207 const sub = document.getElementById(id);
2208 if (sub) sub.hidden = true;
2209 });
2210 }
2211
2212 // =============================================
2213 // INIT
2214 // =============================================
2215 async function init() {
2216 const loadingText = document.getElementById('loading-text');
2217
2218 try {
2219 loadingText.textContent = 'Načítám konfiguraci…';
2220 config = await fetchJSON('./website.json');
2221
2222 // Load language
2223 const langFile = `./${config.language || 'cs'}.json`;
2224 loadingText.textContent = 'Načítám jazyk…';
2225 try { lang = await fetchJSON(langFile); } catch { lang = {}; }
2226
2227 applyConfig();
2228 applyLang();
2229
2230 // Load content
2231 loadingText.textContent = 'Načítám obsah…';
2232 const mdText = await fetchText('./website.md');
2233
2234 // Load sidebar (optional)
2235 try { sidebarMD = await fetchText('./sidebar.md'); } catch { sidebarMD = ''; }
2236
2237 // Parse sections — groups have no rawContent, skip parsing for them
2238 const rawSections = splitSections(mdText);
2239 sections = rawSections.map(sec => {
2240 if (sec.isGroup) return sec;
2241 // Extract news items before parsing MD (they have their own renderer)
2242 const { hasNews, newsItems, regularMD } = extractNewsItems(sec.rawContent);
2243 const parsedHTML = parseMD(regularMD);
2244 const headings = extractHeadings(parsedHTML);
2245 return { ...sec, parsedHTML, headings, hasNews, newsItems };
2246 });
2247
2248 buildNav();
2249 buildSearchIndex();
2250
2251 // Search events
2252 const searchInput = document.getElementById('search-input');
2253 const searchBtn = document.getElementById('search-btn');
2254 const searchClear = document.getElementById('search-clear');
2255
2256 const performSearch = () => {
2257 const q = searchInput.value.trim();
2258 if (!q) { clearSearch(); return; }
2259 searchClear.hidden = false;
2260 const results = doSearch(q);
2261 showSearch(results, q);
2262 };
2263
2264 searchInput.addEventListener('keydown', e => { if (e.key === 'Enter') performSearch(); });
2265 searchBtn.addEventListener('click', performSearch);
2266 searchClear.addEventListener('click', () => { clearSearch(); render(); });
2267
2268 // Mobile menu
2269 document.getElementById('menu-toggle').addEventListener('click', openMobileMenu);
2270 document.getElementById('sidebar-overlay').addEventListener('click', closeMobileMenu);
2271
2272 // Routing
2273 window.addEventListener('popstate', render);
2274
2275 // Hide loading
2276 document.getElementById('loading-screen').style.display = 'none';
2277
2278 // Initial render — outside main try/catch so file-load errors stay separate
2279 render();
2280
2281 } catch(err) {
2282 // Only reached if file loading failed, not render errors
2283 console.error(err);
2284 document.getElementById('loading-screen').style.display = 'none';
2285 const errScreen = document.getElementById('error-screen');
2286 errScreen.classList.add('active');
2287 document.getElementById('error-title').textContent = lang.error_title || 'Loading error';
2288 document.getElementById('error-msg').innerHTML =
2289 (lang.error_msg || 'Could not load site files. Make sure the files') +
2290 ' <code>website.json</code>, <code>website.md</code>' +
2291 (lang.error_server || ' are present and the site is served over HTTP (not opened as a local file).') +
2292 `<br><br><small>${escHtml(err.message)}</small>`;
2293 }
2294 }
2295
2296 return { init };
2297})();
2298
2299document.addEventListener('DOMContentLoaded', Engine.init);
2300</script>
2301</body>
2302</html>