1<?php
2/**
3 * DokuWiki Plugin infobox (Syntax Component)
4 *
5 * @license GPL 2 http://www.gnu.org/licenses/gpl-2.0.html
6 */
7if (!defined('DOKU_INC')) die();
8
9class syntax_plugin_infobox extends DokuWiki_Syntax_Plugin {
10    public function getType() {
11        return 'substition';
12    }
13
14    public function getPType() {
15        return 'block';
16    }
17
18    public function getSort() {
19        return 199;
20    }
21
22    public function connectTo($mode) {
23        $this->Lexer->addEntryPattern('\{\{infobox>', $mode, 'plugin_infobox');
24    }
25
26    public function postConnect() {
27        $this->Lexer->addExitPattern('\}\}', 'plugin_infobox');
28    }
29
30    public function handle($match, $state, $pos, Doku_Handler $handler) {
31        switch ($state) {
32            case DOKU_LEXER_ENTER:
33                return array('state' => 'enter');
34
35            case DOKU_LEXER_UNMATCHED:
36                // This contains the actual content between {{infobox> and }}
37                $lines = explode("\n", $match);
38
39                $params = [
40                    'fields' => [],
41                    'images' => [],
42                    'sections' => [],
43                    'collapsed_sections' => []
44                ];
45
46                $currentSection = null;
47                $currentSubgroup = null;
48                $currentKey = null;
49                $currentValue = '';
50                $headerlessCounter = 0;
51
52                foreach ($lines as $line) {
53                    // Don't trim the line yet - we need to preserve indentation for multi-line values
54
55                    // Check if we're currently capturing a multi-line value
56                    if ($currentKey !== null) {
57                        // Continue capturing multi-line value
58                        $currentValue .= "\n" . $line;
59
60                        // Check if all plugin syntaxes are closed
61                        if (substr_count($currentValue, '{{') === substr_count($currentValue, '}}')) {
62                            $this->_saveField($params, $currentKey, trim($currentValue), $currentSection, $currentSubgroup);
63                            $currentKey = null;
64                            $currentValue = '';
65                        }
66                        continue;
67                    }
68
69                    $trimmedLine = trim($line);
70                    if (empty($trimmedLine)) continue;
71
72                    // Check for headerless section (====)
73                    if ($trimmedLine === '====') {
74                        $headerlessCounter++;
75                        $currentSection = '_headerless_' . $headerlessCounter;
76                        $currentSubgroup = null;
77                        $params['sections'][$currentSection] = [];
78                        continue;
79                    }
80
81                    // Check for section headers
82                    if (preg_match('/^(={2,3})\s*(.+?)\s*\1$/', $trimmedLine, $sectionMatches)) {
83                        $currentSection = $sectionMatches[2];
84                        $currentSubgroup = null; // Reset subgroup when entering new section
85                        $params['sections'][$currentSection] = [];
86                        if ($sectionMatches[1] === '===') {
87                            $params['collapsed_sections'][$currentSection] = true;
88                        }
89                        continue;
90                    }
91
92                    // Check for headerless subgroup (::::::)
93                    if ($trimmedLine === '::::::') {
94                        if ($currentSection !== null) {
95                            $headerlessCounter++;
96                            $currentSubgroup = '_headerless_' . $headerlessCounter;
97                            if (!isset($params['sections'][$currentSection]['_subgroups'])) {
98                                $params['sections'][$currentSection]['_subgroups'] = [];
99                            }
100                            $params['sections'][$currentSection]['_subgroups'][$currentSubgroup] = [];
101                        }
102                        continue;
103                    }
104
105                    // Check for subgroup headers (:::)
106                    if (preg_match('/^:::\s*(.+?)\s*:::$/', $trimmedLine, $subgroupMatches)) {
107                        if ($currentSection !== null) {
108                            $currentSubgroup = $subgroupMatches[1];
109                            if (!isset($params['sections'][$currentSection]['_subgroups'])) {
110                                $params['sections'][$currentSection]['_subgroups'] = [];
111                            }
112                            $params['sections'][$currentSection]['_subgroups'][$currentSubgroup] = [];
113                        }
114                        continue;
115                    }
116
117                    // Check for divider lines
118                    if (preg_match('/^divider\s*=\s*(.+)$/i', $trimmedLine, $matches)) {
119                        $dividerText = trim($matches[1]);
120                        if ($currentSection !== null) {
121                            if ($currentSubgroup !== null) {
122                                // Add divider to subgroup
123                                $params['sections'][$currentSection]['_subgroups'][$currentSubgroup]['_divider_' . md5($dividerText)] = [
124                                    'type' => 'divider',
125                                    'text' => $dividerText
126                                ];
127                            } else {
128                                // Add divider to section
129                                $params['sections'][$currentSection]['_divider_' . md5($dividerText)] = [
130                                    'type' => 'divider',
131                                    'text' => $dividerText
132                                ];
133                            }
134                        } else {
135                            // Add divider to main fields
136                            $params['fields']['_divider_' . md5($dividerText)] = [
137                                'type' => 'divider',
138                                'text' => $dividerText
139                            ];
140                        }
141                    }
142                    // Check for full-width value (= value =)
143                    elseif (preg_match('/^=\s+(.+?)\s+=$/', $trimmedLine, $fullwidthMatches)) {
144                        $fullwidthValue = $fullwidthMatches[1];
145                        $fullwidthKey = '_fullwidth_' . md5($fullwidthValue . microtime());
146                        $fieldData = [
147                            'type' => 'fullwidth',
148                            'value' => $fullwidthValue
149                        ];
150                        if ($currentSection !== null) {
151                            if ($currentSubgroup !== null) {
152                                $params['sections'][$currentSection]['_subgroups'][$currentSubgroup][$fullwidthKey] = $fieldData;
153                            } else {
154                                $params['sections'][$currentSection][$fullwidthKey] = $fieldData;
155                            }
156                        } else {
157                            $params['fields'][$fullwidthKey] = $fieldData;
158                        }
159                    }
160                    // Check if this line contains a key=value pair
161                    elseif (strpos($trimmedLine, '=') !== false) {
162                        // Split only on the first = to handle values containing =
163                        $pos = strpos($trimmedLine, '=');
164                        $key = trim(substr($trimmedLine, 0, $pos));
165                        $value = trim(substr($trimmedLine, $pos + 1));
166
167                        // Check if value contains unclosed plugin syntax
168                        $openCount = substr_count($value, '{{');
169                        $closeCount = substr_count($value, '}}');
170
171                        if ($openCount > $closeCount) {
172                            // Value contains unclosed plugin syntax, start multi-line capture
173                            $currentKey = $key;
174                            $currentValue = $value;
175                        } else {
176                            // Value is complete on this line
177                            $this->_saveField($params, $key, $value, $currentSection, $currentSubgroup);
178                        }
179                    }
180                }
181
182                // Save any remaining multi-line value
183                if ($currentKey !== null) {
184                    $this->_saveField($params, $currentKey, trim($currentValue), $currentSection, $currentSubgroup);
185                }
186
187                return array('state' => 'content', 'params' => $params);
188
189            case DOKU_LEXER_EXIT:
190                return array('state' => 'exit');
191        }
192
193        return false;
194    }
195
196    private function _saveField(&$params, $key, $value, $currentSection, $currentSubgroup = null) {
197        // Skip empty values unless explicitly showing them
198        if (empty($value) && $value !== '0') {
199            return;
200        }
201
202        // Handle spoiler/blur functionality with ! prefix
203        $blurKey = false;
204        $blurValue = false;
205
206        // Check for ! in key (e.g., !name = david)
207        if (strpos($key, '!') === 0) {
208            $blurKey = true;
209            $blurValue = true;
210            $key = ltrim($key, '!');
211        }
212
213        // Check for ! in value (e.g., name = !david)
214        if (strpos($value, '!') === 0) {
215            $blurValue = true;
216            $value = ltrim($value, '!');
217        }
218
219        // Handle image fields
220        if (preg_match('/^image(\d*)$/', $key, $matches)) {
221            $imgNum = $matches[1] ?: '1';
222            // Check if image has a caption (format: filename|caption)
223            if (strpos($value, '|') !== false) {
224                list($imgPath, $caption) = explode('|', $value, 2);
225                $params['images'][$imgNum] = [
226                    'path' => trim($imgPath),
227                    'caption' => trim($caption)
228                ];
229            } else {
230                $params['images'][$imgNum] = [
231                    'path' => trim($value),
232                    'caption' => ''
233                ];
234            }
235        } elseif ($key === 'name' || $key === 'title') {
236            $params['name'] = $value;
237        } elseif ($key === 'header_image') {
238            $params['header_image'] = $value;
239        } elseif ($key === 'class') {
240            $params['class'] = $value;
241        } else {
242            // Add to current section/subgroup or main fields
243            if ($currentSection !== null) {
244                if ($currentSubgroup !== null) {
245                    // Add to subgroup
246                    $params['sections'][$currentSection]['_subgroups'][$currentSubgroup][$key] = [
247                        'value' => $value,
248                        'blur_key' => $blurKey,
249                        'blur_value' => $blurValue
250                    ];
251                } else {
252                    // Add to section (not in a subgroup)
253                    $params['sections'][$currentSection][$key] = [
254                        'value' => $value,
255                        'blur_key' => $blurKey,
256                        'blur_value' => $blurValue
257                    ];
258                }
259            } else {
260                // Add to main fields
261                $params['fields'][$key] = [
262                    'value' => $value,
263                    'blur_key' => $blurKey,
264                    'blur_value' => $blurValue
265                ];
266            }
267        }
268    }
269
270    public function render($mode, Doku_Renderer $renderer, $data) {
271        if ($mode != 'xhtml') return false;
272
273        if (!is_array($data) || !isset($data['state'])) return false;
274
275        switch ($data['state']) {
276            case 'enter':
277                // Start of infobox - nothing to do
278                break;
279
280            case 'content':
281                // Render the actual infobox
282                $params = $data['params'];
283                $this->_renderInfobox($renderer, $params);
284                break;
285
286            case 'exit':
287                // End of infobox - nothing to do
288                break;
289        }
290
291        return true;
292    }
293
294    private function _renderInfobox($renderer, $data) {
295        // Generate unique ID for this infobox
296        $boxId = 'infobox_' . md5(serialize($data));
297
298        // Allow custom CSS classes
299        $customClass = isset($data['class']) ? ' ' . hsc($data['class']) : '';
300
301        $renderer->doc .= '<div class="infobox' . $customClass . '" id="' . $boxId . '" role="complementary" aria-label="Information box">';
302
303        // Header image (optional)
304        if (isset($data['header_image'])) {
305            $renderer->doc .= '<div class="infobox-header-image">';
306            $renderer->internalmedia(
307                $data['header_image'],
308                null,
309                null,
310                null,
311                null,
312                'cache',
313                'details'
314            );
315            $renderer->doc .= '</div>';
316        }
317
318        // Title
319        if (isset($data['name'])) {
320            $renderer->doc .= '<div class="infobox-title">' . $this->_parseWikiText($data['name']) . '</div>';
321        }
322
323        // Multiple images with tabs
324        if (!empty($data['images'])) {
325            $renderer->doc .= '<div class="infobox-images">';
326
327            // Image tabs
328            if (count($data['images']) > 1) {
329                $renderer->doc .= '<div class="infobox-image-tabs" role="tablist">';
330                $first = true;
331                $tabCount = 1;
332                foreach ($data['images'] as $num => $imgData) {
333                    $tabLabel = $imgData['caption'] ?: 'Image ' . $tabCount;
334                    $activeClass = $first ? ' active' : '';
335                    $tabIndex = $first ? '0' : '-1';
336                    $ariaSelected = $first ? 'true' : 'false';
337                    $renderer->doc .= '<button class="infobox-tab' . $activeClass . '" role="tab" aria-selected="' . $ariaSelected . '" onclick="showInfoboxImage(\'' . $boxId . '\', ' . $num . ')" aria-label="View ' . hsc($tabLabel) . '" tabindex="' . $tabIndex . '">';
338                    // Add tab number if no custom caption provided
339                    if (!$imgData['caption'] && count($data['images']) > 1) {
340                        $renderer->doc .= '<span class="infobox-tab-number">' . $tabCount . '. </span>';
341                    }
342                    $renderer->doc .= hsc($tabLabel);
343                    $renderer->doc .= '</button>';
344                    $first = false;
345                    $tabCount++;
346                }
347                $renderer->doc .= '</div>';
348            }
349
350            // Image containers
351            $first = true;
352            foreach ($data['images'] as $num => $imgData) {
353                $activeClass = $first ? ' active' : '';
354                $renderer->doc .= '<div class="infobox-image-container' . $activeClass . '" id="' . $boxId . '_img_' . $num . '" role="tabpanel">';
355
356                // Use DokuWiki's internal media rendering for proper lightbox support
357                $renderer->internalmedia(
358                    $imgData['path'],
359                    $imgData['caption'] ?: null,
360                    null,
361                    300,
362                    null,
363                    'cache',
364                    'details'
365                );
366
367                if ($imgData['caption'] && count($data['images']) == 1) {
368                    $renderer->doc .= '<div class="infobox-image-caption">' . hsc($imgData['caption']) . '</div>';
369                }
370                $renderer->doc .= '</div>';
371                $first = false;
372            }
373
374            $renderer->doc .= '</div>';
375        }
376
377        // Main fields table
378        if (!empty($data['fields'])) {
379            $renderer->doc .= '<table class="infobox-table">';
380            foreach ($data['fields'] as $key => $fieldData) {
381                // Handle dividers
382                if (is_array($fieldData) && isset($fieldData['type']) && $fieldData['type'] === 'divider') {
383                    $renderer->doc .= '</table><div class="infobox-divider">' . hsc($fieldData['text']) . '</div><table class="infobox-table">';
384                    continue;
385                }
386
387                // Handle full-width values
388                if (is_array($fieldData) && isset($fieldData['type']) && $fieldData['type'] === 'fullwidth') {
389                    $renderer->doc .= '<tr><td colspan="2" class="infobox-fullwidth">' . $this->_parseWikiText($fieldData['value']) . '</td></tr>';
390                    continue;
391                }
392
393                // Handle both old string format and new array format for backwards compatibility
394                if (is_array($fieldData)) {
395                    $value = $fieldData['value'];
396                    $blurKey = isset($fieldData['blur_key']) ? $fieldData['blur_key'] : false;
397                    $blurValue = isset($fieldData['blur_value']) ? $fieldData['blur_value'] : false;
398                } else {
399                    $value = $fieldData;
400                    $blurKey = false;
401                    $blurValue = false;
402                }
403
404                $keyClass = $blurKey ? ' class="infobox-spoiler"' : '';
405                $valueClass = $blurValue ? ' class="infobox-spoiler"' : '';
406
407                $renderer->doc .= '<tr>';
408                $renderer->doc .= '<th' . $keyClass . '>' . $this->_renderFieldName($key) . '</th>';
409                $renderer->doc .= '<td' . $valueClass . '>' . $this->_parseWikiText($value) . '</td>';
410                $renderer->doc .= '</tr>';
411            }
412            $renderer->doc .= '</table>';
413        }
414
415        // Sections
416        foreach ($data['sections'] as $sectionName => $sectionData) {
417            $sectionId = $boxId . '_section_' . md5($sectionName);
418            $isCollapsed = isset($data['collapsed_sections'][$sectionName]);
419            $isHeaderless = strpos($sectionName, '_headerless_') === 0;
420            $collapsibleClass = $isCollapsed ? ' collapsible collapsed' : '';
421            $headerlessClass = $isHeaderless ? ' headerless' : '';
422
423            $renderer->doc .= '<div class="infobox-section' . $collapsibleClass . $headerlessClass . '">';
424
425            // Only render section header if not headerless
426            if (!$isHeaderless) {
427                if ($isCollapsed) {
428                    $renderer->doc .= '<div class="infobox-section-header" onclick="toggleInfoboxSection(\'' . $sectionId . '\')" ' .
429                                     'role="button" tabindex="0" aria-expanded="false" aria-controls="' . $sectionId . '">';
430                } else {
431                    $renderer->doc .= '<div class="infobox-section-header">';
432                }
433                $renderer->doc .= hsc($sectionName);
434                if ($isCollapsed) {
435                    $renderer->doc .= '<span class="infobox-section-toggle">▼</span>';
436                }
437                $renderer->doc .= '</div>';
438            }
439
440            $contentClass = $isCollapsed ? 'infobox-section-content collapsed' : 'infobox-section-content';
441            $renderer->doc .= '<div class="' . $contentClass . '" id="' . $sectionId . '">';
442
443            // Check if this section has subgroups
444            $hasSubgroups = isset($sectionData['_subgroups']) && !empty($sectionData['_subgroups']);
445
446            if ($hasSubgroups) {
447                // Render subgroups
448                $renderer->doc .= '<div class="infobox-subgroups">';
449                foreach ($sectionData['_subgroups'] as $subgroupName => $subgroupFields) {
450                    $isSubgroupHeaderless = strpos($subgroupName, '_headerless_') === 0;
451                    $subgroupClass = $isSubgroupHeaderless ? 'infobox-subgroup headerless' : 'infobox-subgroup';
452                    $renderer->doc .= '<div class="' . $subgroupClass . '">';
453                    if (!$isSubgroupHeaderless) {
454                        $renderer->doc .= '<div class="infobox-subgroup-header">' . hsc($subgroupName) . '</div>';
455                    }
456
457                    if (!empty($subgroupFields)) {
458                        $renderer->doc .= '<table class="infobox-table">';
459                        foreach ($subgroupFields as $key => $fieldData) {
460                            // Handle dividers
461                            if (is_array($fieldData) && isset($fieldData['type']) && $fieldData['type'] === 'divider') {
462                                $renderer->doc .= '</table><div class="infobox-divider">' . hsc($fieldData['text']) . '</div><table class="infobox-table">';
463                                continue;
464                            }
465
466                            // Handle full-width values (spans column width in subgroups)
467                            if (is_array($fieldData) && isset($fieldData['type']) && $fieldData['type'] === 'fullwidth') {
468                                $renderer->doc .= '<tr><td colspan="2" class="infobox-fullwidth">' . $this->_parseWikiText($fieldData['value']) . '</td></tr>';
469                                continue;
470                            }
471
472                            // Handle both old string format and new array format for backwards compatibility
473                            if (is_array($fieldData)) {
474                                $value = $fieldData['value'];
475                                $blurKey = isset($fieldData['blur_key']) ? $fieldData['blur_key'] : false;
476                                $blurValue = isset($fieldData['blur_value']) ? $fieldData['blur_value'] : false;
477                            } else {
478                                $value = $fieldData;
479                                $blurKey = false;
480                                $blurValue = false;
481                            }
482
483                            $keyClass = $blurKey ? ' class="infobox-spoiler"' : '';
484                            $valueClass = $blurValue ? ' class="infobox-spoiler"' : '';
485
486                            $renderer->doc .= '<tr>';
487                            $renderer->doc .= '<th' . $keyClass . '>' . $this->_renderFieldName($key) . '</th>';
488                            $renderer->doc .= '<td' . $valueClass . '>' . $this->_parseWikiText($value) . '</td>';
489                            $renderer->doc .= '</tr>';
490                        }
491                        $renderer->doc .= '</table>';
492                    }
493                    $renderer->doc .= '</div>';
494                }
495                $renderer->doc .= '</div>';
496            }
497
498            // Render regular section fields (not in subgroups)
499            $regularFields = array_filter($sectionData, function($key) {
500                return $key !== '_subgroups';
501            }, ARRAY_FILTER_USE_KEY);
502
503            if (!empty($regularFields)) {
504                $renderer->doc .= '<table class="infobox-table">';
505                foreach ($regularFields as $key => $fieldData) {
506                    // Handle dividers
507                    if (is_array($fieldData) && isset($fieldData['type']) && $fieldData['type'] === 'divider') {
508                        $renderer->doc .= '</table><div class="infobox-divider">' . hsc($fieldData['text']) . '</div><table class="infobox-table">';
509                        continue;
510                    }
511
512                    // Handle full-width values
513                    if (is_array($fieldData) && isset($fieldData['type']) && $fieldData['type'] === 'fullwidth') {
514                        $renderer->doc .= '<tr><td colspan="2" class="infobox-fullwidth">' . $this->_parseWikiText($fieldData['value']) . '</td></tr>';
515                        continue;
516                    }
517
518                    // Handle both old string format and new array format for backwards compatibility
519                    if (is_array($fieldData)) {
520                        $value = $fieldData['value'];
521                        $blurKey = isset($fieldData['blur_key']) ? $fieldData['blur_key'] : false;
522                        $blurValue = isset($fieldData['blur_value']) ? $fieldData['blur_value'] : false;
523                    } else {
524                        $value = $fieldData;
525                        $blurKey = false;
526                        $blurValue = false;
527                    }
528
529                    $keyClass = $blurKey ? ' class="infobox-spoiler"' : '';
530                    $valueClass = $blurValue ? ' class="infobox-spoiler"' : '';
531
532                    $renderer->doc .= '<tr>';
533                    $renderer->doc .= '<th' . $keyClass . '>' . $this->_renderFieldName($key) . '</th>';
534                    $renderer->doc .= '<td' . $valueClass . '>' . $this->_parseWikiText($value) . '</td>';
535                    $renderer->doc .= '</tr>';
536                }
537                $renderer->doc .= '</table>';
538            }
539
540            $renderer->doc .= '</div>';
541            $renderer->doc .= '</div>';
542        }
543
544        $renderer->doc .= '</div>';
545
546        // Add JavaScript for image tabs (only once per page)
547        static $jsAdded = false;
548        if (!$jsAdded) {
549            $renderer->doc .= '<script>
550            // Spoiler click-to-reveal functionality
551            function revealSpoiler(element) {
552                element.classList.add("revealed");
553                element.removeAttribute("tabindex");
554                element.setAttribute("aria-label", "Content revealed");
555            }
556
557            function showInfoboxImage(boxId, imageNum) {
558                // Hide all images in this infobox
559                var containers = document.querySelectorAll("#" + boxId + " .infobox-image-container");
560                containers.forEach(function(container) {
561                    container.classList.remove("active");
562                });
563
564                // Remove active class from all tabs in this infobox
565                var tabs = document.querySelectorAll("#" + boxId + " .infobox-tab");
566                tabs.forEach(function(tab) {
567                    tab.classList.remove("active");
568                    tab.setAttribute("tabindex", "-1");
569                    tab.setAttribute("aria-selected", "false");
570                });
571
572                // Show selected image
573                document.getElementById(boxId + "_img_" + imageNum).classList.add("active");
574
575                // Add active class to clicked tab
576                var activeTab = tabs[imageNum - 1];
577                activeTab.classList.add("active");
578                activeTab.setAttribute("tabindex", "0");
579                activeTab.setAttribute("aria-selected", "true");
580            }
581
582            function toggleInfoboxSection(sectionId) {
583                var content = document.getElementById(sectionId);
584                var header = content.previousElementSibling;
585                var toggle = header.querySelector(".infobox-section-toggle");
586
587                if (content.classList.contains("collapsed")) {
588                    content.classList.remove("collapsed");
589                    header.setAttribute("aria-expanded", "true");
590                    if (toggle) toggle.textContent = "▲";
591                } else {
592                    content.classList.add("collapsed");
593                    header.setAttribute("aria-expanded", "false");
594                    if (toggle) toggle.textContent = "▼";
595                }
596            }
597
598            // Add keyboard support
599            document.addEventListener("DOMContentLoaded", function() {
600                // Initialize spoiler elements
601                var spoilers = document.querySelectorAll(".infobox-spoiler");
602                spoilers.forEach(function(spoiler) {
603                    spoiler.setAttribute("tabindex", "0");
604                    spoiler.setAttribute("role", "button");
605                    spoiler.setAttribute("aria-label", "Blurred content - click or press Enter to reveal");
606
607                    spoiler.addEventListener("click", function() {
608                        if (!this.classList.contains("revealed")) {
609                            revealSpoiler(this);
610                        }
611                    });
612
613                    spoiler.addEventListener("keydown", function(e) {
614                        if ((e.key === "Enter" || e.key === " ") && !this.classList.contains("revealed")) {
615                            e.preventDefault();
616                            revealSpoiler(this);
617                        }
618                    });
619                });
620
621                // Keyboard navigation for image tabs
622                var tabGroups = document.querySelectorAll(".infobox-image-tabs");
623                tabGroups.forEach(function(tabGroup) {
624                    var tabs = tabGroup.querySelectorAll(".infobox-tab");
625
626                    tabs.forEach(function(tab, index) {
627                        tab.addEventListener("keydown", function(e) {
628                            var newIndex = -1;
629
630                            switch(e.key) {
631                                case "ArrowLeft":
632                                    e.preventDefault();
633                                    newIndex = index - 1;
634                                    if (newIndex < 0) newIndex = tabs.length - 1;
635                                    break;
636                                case "ArrowRight":
637                                    e.preventDefault();
638                                    newIndex = index + 1;
639                                    if (newIndex >= tabs.length) newIndex = 0;
640                                    break;
641                                case "Home":
642                                    e.preventDefault();
643                                    newIndex = 0;
644                                    break;
645                                case "End":
646                                    e.preventDefault();
647                                    newIndex = tabs.length - 1;
648                                    break;
649                            }
650
651                            if (newIndex >= 0) {
652                                tabs[newIndex].focus();
653                                tabs[newIndex].click();
654                            }
655                        });
656                    });
657                });
658
659                // Keyboard support for collapsible sections
660                var headers = document.querySelectorAll(".infobox-section.collapsible .infobox-section-header");
661                headers.forEach(function(header) {
662                    header.addEventListener("keydown", function(e) {
663                        if (e.key === "Enter" || e.key === " ") {
664                            e.preventDefault();
665                            header.click();
666                        }
667                    });
668                });
669
670                // Fix for section header lines
671                var infoboxes = document.querySelectorAll(".infobox");
672                if (infoboxes.length > 0) {
673                    var headers = document.querySelectorAll("h1, h2, h3, h4, h5, h6");
674                    headers.forEach(function(header) {
675                        header.style.overflow = "hidden";
676                    });
677
678                    var pageContent = document.querySelector(".dokuwiki .page");
679                    if (pageContent && !pageContent.classList.contains("has-infobox")) {
680                        pageContent.classList.add("has-infobox");
681                    }
682                }
683            });
684            </script>';
685            $jsAdded = true;
686        }
687
688        return true;
689    }
690
691    private function _parseWikiText($text) {
692        // Handle multi-line values properly
693        $text = str_replace('\n', "\n", $text);
694
695        $info = array();
696        $xhtml = p_render('xhtml', p_get_instructions($text), $info);
697
698        // Remove wrapping <p> tags if it's a single paragraph
699        if (substr_count($xhtml, '<p>') == 1) {
700            $xhtml = preg_replace('/^\s*<p>(.*)<\/p>\s*$/s', '$1', $xhtml);
701        }
702
703        return $xhtml;
704    }
705
706    private function _formatKey($key) {
707        // Convert underscores to spaces and capitalize words
708        return ucwords(str_replace('_', ' ', $key));
709    }
710
711    private function _renderFieldName($key) {
712        // Handle pipe syntax for icons: "icon.png|Field Name"
713        if (strpos($key, '|') !== false && preg_match('/^([^|]+)\|(.+)$/', $key, $matches)) {
714            $iconFile = trim($matches[1]);
715            $label = trim($matches[2]);
716
717            // Check if the first part looks like an image file
718            if (preg_match('/\.(png|jpg|jpeg|gif|svg)$/i', $iconFile)) {
719                // Use DokuWiki's media resolution instead of manual path construction
720                global $conf;
721
722                // Try to resolve the media file using DokuWiki's functions
723                $mediaFile = cleanID($iconFile);
724                $file = mediaFN($mediaFile);
725
726                if (file_exists($file)) {
727                    // File exists, use DokuWiki's media URL
728                    $iconHtml = '<img src="' . ml($mediaFile) . '" alt="" class="infobox-field-icon" />';
729                } else {
730                    // Fallback: try as direct media reference with debugging
731                    $iconHtml = '<img src="' . DOKU_URL . 'data/media/' . hsc($iconFile) . '" alt="[' . hsc($iconFile) . ']" class="infobox-field-icon" title="Icon: ' . hsc($iconFile) . '" />';
732                }
733
734                return $iconHtml . hsc($this->_formatKey($label));
735            }
736        }
737
738        // No icons, just format normally
739        return hsc($this->_formatKey($key));
740    }
741}