Lexer->addEntryPattern('\{\{infobox>', $mode, 'plugin_infobox'); } public function postConnect() { $this->Lexer->addExitPattern('\}\}', 'plugin_infobox'); } public function handle($match, $state, $pos, Doku_Handler $handler) { switch ($state) { case DOKU_LEXER_ENTER: return array('state' => 'enter'); case DOKU_LEXER_UNMATCHED: // This contains the actual content between {{infobox> and }} $lines = explode("\n", $match); $params = [ 'fields' => [], 'images' => [], 'sections' => [], 'collapsed_sections' => [] ]; $currentSection = null; $currentSubgroup = null; $currentKey = null; $currentValue = ''; $headerlessCounter = 0; foreach ($lines as $line) { // Don't trim the line yet - we need to preserve indentation for multi-line values // Check if we're currently capturing a multi-line value if ($currentKey !== null) { // Continue capturing multi-line value $currentValue .= "\n" . $line; // Check if all plugin syntaxes are closed if (substr_count($currentValue, '{{') === substr_count($currentValue, '}}')) { $this->_saveField($params, $currentKey, trim($currentValue), $currentSection, $currentSubgroup); $currentKey = null; $currentValue = ''; } continue; } $trimmedLine = trim($line); if (empty($trimmedLine)) continue; // Check for headerless section (====) if ($trimmedLine === '====') { $headerlessCounter++; $currentSection = '_headerless_' . $headerlessCounter; $currentSubgroup = null; $params['sections'][$currentSection] = []; continue; } // Check for section headers if (preg_match('/^(={2,3})\s*(.+?)\s*\1$/', $trimmedLine, $sectionMatches)) { $currentSection = $sectionMatches[2]; $currentSubgroup = null; // Reset subgroup when entering new section $params['sections'][$currentSection] = []; if ($sectionMatches[1] === '===') { $params['collapsed_sections'][$currentSection] = true; } continue; } // Check for headerless subgroup (::::::) if ($trimmedLine === '::::::') { if ($currentSection !== null) { $headerlessCounter++; $currentSubgroup = '_headerless_' . $headerlessCounter; if (!isset($params['sections'][$currentSection]['_subgroups'])) { $params['sections'][$currentSection]['_subgroups'] = []; } $params['sections'][$currentSection]['_subgroups'][$currentSubgroup] = []; } continue; } // Check for subgroup headers (:::) if (preg_match('/^:::\s*(.+?)\s*:::$/', $trimmedLine, $subgroupMatches)) { if ($currentSection !== null) { $currentSubgroup = $subgroupMatches[1]; if (!isset($params['sections'][$currentSection]['_subgroups'])) { $params['sections'][$currentSection]['_subgroups'] = []; } $params['sections'][$currentSection]['_subgroups'][$currentSubgroup] = []; } continue; } // Check for divider lines if (preg_match('/^divider\s*=\s*(.+)$/i', $trimmedLine, $matches)) { $dividerText = trim($matches[1]); if ($currentSection !== null) { if ($currentSubgroup !== null) { // Add divider to subgroup $params['sections'][$currentSection]['_subgroups'][$currentSubgroup]['_divider_' . md5($dividerText)] = [ 'type' => 'divider', 'text' => $dividerText ]; } else { // Add divider to section $params['sections'][$currentSection]['_divider_' . md5($dividerText)] = [ 'type' => 'divider', 'text' => $dividerText ]; } } else { // Add divider to main fields $params['fields']['_divider_' . md5($dividerText)] = [ 'type' => 'divider', 'text' => $dividerText ]; } } // Check for full-width value (= value =) elseif (preg_match('/^=\s+(.+?)\s+=$/', $trimmedLine, $fullwidthMatches)) { $fullwidthValue = $fullwidthMatches[1]; $fullwidthKey = '_fullwidth_' . md5($fullwidthValue . microtime()); $fieldData = [ 'type' => 'fullwidth', 'value' => $fullwidthValue ]; if ($currentSection !== null) { if ($currentSubgroup !== null) { $params['sections'][$currentSection]['_subgroups'][$currentSubgroup][$fullwidthKey] = $fieldData; } else { $params['sections'][$currentSection][$fullwidthKey] = $fieldData; } } else { $params['fields'][$fullwidthKey] = $fieldData; } } // Check if this line contains a key=value pair elseif (strpos($trimmedLine, '=') !== false) { // Split only on the first = to handle values containing = $pos = strpos($trimmedLine, '='); $key = trim(substr($trimmedLine, 0, $pos)); $value = trim(substr($trimmedLine, $pos + 1)); // Check if value contains unclosed plugin syntax $openCount = substr_count($value, '{{'); $closeCount = substr_count($value, '}}'); if ($openCount > $closeCount) { // Value contains unclosed plugin syntax, start multi-line capture $currentKey = $key; $currentValue = $value; } else { // Value is complete on this line $this->_saveField($params, $key, $value, $currentSection, $currentSubgroup); } } } // Save any remaining multi-line value if ($currentKey !== null) { $this->_saveField($params, $currentKey, trim($currentValue), $currentSection, $currentSubgroup); } return array('state' => 'content', 'params' => $params); case DOKU_LEXER_EXIT: return array('state' => 'exit'); } return false; } private function _saveField(&$params, $key, $value, $currentSection, $currentSubgroup = null) { // Skip empty values unless explicitly showing them if (empty($value) && $value !== '0') { return; } // Handle spoiler/blur functionality with ! prefix $blurKey = false; $blurValue = false; // Check for ! in key (e.g., !name = david) if (strpos($key, '!') === 0) { $blurKey = true; $blurValue = true; $key = ltrim($key, '!'); } // Check for ! in value (e.g., name = !david) if (strpos($value, '!') === 0) { $blurValue = true; $value = ltrim($value, '!'); } // Handle image fields if (preg_match('/^image(\d*)$/', $key, $matches)) { $imgNum = $matches[1] ?: '1'; // Check if image has a caption (format: filename|caption) if (strpos($value, '|') !== false) { list($imgPath, $caption) = explode('|', $value, 2); $params['images'][$imgNum] = [ 'path' => trim($imgPath), 'caption' => trim($caption) ]; } else { $params['images'][$imgNum] = [ 'path' => trim($value), 'caption' => '' ]; } } elseif ($key === 'name' || $key === 'title') { $params['name'] = $value; } elseif ($key === 'header_image') { $params['header_image'] = $value; } elseif ($key === 'class') { $params['class'] = $value; } else { // Add to current section/subgroup or main fields if ($currentSection !== null) { if ($currentSubgroup !== null) { // Add to subgroup $params['sections'][$currentSection]['_subgroups'][$currentSubgroup][$key] = [ 'value' => $value, 'blur_key' => $blurKey, 'blur_value' => $blurValue ]; } else { // Add to section (not in a subgroup) $params['sections'][$currentSection][$key] = [ 'value' => $value, 'blur_key' => $blurKey, 'blur_value' => $blurValue ]; } } else { // Add to main fields $params['fields'][$key] = [ 'value' => $value, 'blur_key' => $blurKey, 'blur_value' => $blurValue ]; } } } public function render($mode, Doku_Renderer $renderer, $data) { if ($mode != 'xhtml') return false; if (!is_array($data) || !isset($data['state'])) return false; switch ($data['state']) { case 'enter': // Start of infobox - nothing to do break; case 'content': // Render the actual infobox $params = $data['params']; $this->_renderInfobox($renderer, $params); break; case 'exit': // End of infobox - nothing to do break; } return true; } private function _renderInfobox($renderer, $data) { // Generate unique ID for this infobox $boxId = 'infobox_' . md5(serialize($data)); // Allow custom CSS classes $customClass = isset($data['class']) ? ' ' . hsc($data['class']) : ''; $renderer->doc .= '