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}