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