1<?php 2 3namespace dokuwiki\Parsing; 4 5use dokuwiki\Extension\PluginInterface; 6use dokuwiki\Extension\SyntaxPlugin; 7use dokuwiki\Parsing\ParserMode\Acronym; 8use dokuwiki\Parsing\ParserMode\ModeInterface; 9use dokuwiki\Parsing\ParserMode\Camelcaselink; 10use dokuwiki\Parsing\ParserMode\Entity; 11use dokuwiki\Parsing\ParserMode\Smiley; 12 13/** 14 * Central registry for parser mode categories and mode instantiation. 15 * 16 * The underlying data is kept in the global $PARSER_MODES array because 17 * third-party plugins read and write it directly at runtime (e.g. to register 18 * their mode in a category). All methods in this class operate on that global 19 * so changes are visible to both old and new code. 20 */ 21class ModeRegistry 22{ 23 // Category constants (preserving the historical 'substition' typo) 24 public const CATEGORY_CONTAINER = 'container'; 25 public const CATEGORY_BASEONLY = 'baseonly'; 26 public const CATEGORY_FORMATTING = 'formatting'; 27 public const CATEGORY_SUBSTITION = 'substition'; 28 public const CATEGORY_PROTECTED = 'protected'; 29 public const CATEGORY_DISABLED = 'disabled'; 30 public const CATEGORY_PARAGRAPHS = 'paragraphs'; 31 32 /** @var array{sort: int, mode: string, obj: ModeInterface}[]|null */ 33 private ?array $modes = null; 34 35 /** @var string[] Modes that handle their own line endings (skip EOL connection) */ 36 private array $blockEolModes = []; 37 38 /** @var array<string, string[]> Mode name => regex-escaped line start marker characters */ 39 private array $lineStartMarkers = []; 40 41 private static ?self $instance = null; 42 43 /** 44 * Get the singleton instance of the ModeRegistry. 45 * 46 * @return self 47 */ 48 public static function getInstance(): self 49 { 50 if (!self::$instance instanceof self) { 51 self::$instance = new self(); 52 } 53 return self::$instance; 54 } 55 56 /** 57 * Reset the singleton instance. 58 * 59 * This is mainly useful for testing to force re-initialization. 60 * 61 * @return void 62 */ 63 public static function reset(): void 64 { 65 self::$instance = null; 66 } 67 68 /** 69 * Constructor. Initializes the global $PARSER_MODES array with the default mode categories. 70 */ 71 private function __construct() 72 { 73 global $PARSER_MODES; 74 $PARSER_MODES = [ 75 self::CATEGORY_CONTAINER => ['listblock', 'table', 'quote', 'hr'], 76 self::CATEGORY_BASEONLY => ['header', 'gfm_header'], 77 self::CATEGORY_FORMATTING => [ 78 'strong', 'emphasis', 'underline', 'monospace', 79 'subscript', 'superscript', 'deleted', 'footnote', 80 'gfm_emphasis', 'gfm_emphasis_underscore', 'gfm_strong_underscore', 81 'gfm_emphasis_strong', 'gfm_emphasis_strong_underscore', 82 'gfm_deleted', 'gfm_backtick_single', 'gfm_backtick_double', 83 ], 84 self::CATEGORY_SUBSTITION => [ 85 'acronym', 'smiley', 'wordblock', 'entity', 86 'camelcaselink', 'internallink', 'media', 'externallink', 87 'linebreak', 'emaillink', 'windowssharelink', 'filelink', 88 'notoc', 'nocache', 'multiplyentity', 'quotes', 'rss', 89 'gfm_link', 'gfm_media', 90 ], 91 self::CATEGORY_PROTECTED => ['preformatted', 'code', 'file', 'gfm_code', 'gfm_file'], 92 self::CATEGORY_DISABLED => ['unformatted'], 93 self::CATEGORY_PARAGRAPHS => ['eol'], 94 ]; 95 } 96 97 /** 98 * Get all mode names in the given categories. 99 * 100 * @param string[] $categories One or more CATEGORY_* constants 101 * @return string[] Unique list of mode names 102 */ 103 public function getModesForCategories(array $categories): array 104 { 105 global $PARSER_MODES; 106 $modes = []; 107 foreach ($categories as $cat) { 108 if (isset($PARSER_MODES[$cat])) { 109 $modes = array_merge($modes, $PARSER_MODES[$cat]); 110 } 111 } 112 return array_unique($modes); 113 } 114 115 /** 116 * Get the raw categories array. 117 * 118 * @return array<string, string[]> Category name => list of mode names 119 */ 120 public function getCategories(): array 121 { 122 global $PARSER_MODES; 123 return $PARSER_MODES; 124 } 125 126 /** 127 * Register a mode in a category. 128 * 129 * @param string $category One of the CATEGORY_* constants 130 * @param string $modeName The mode name to register 131 * @return void 132 */ 133 public function registerMode(string $category, string $modeName): void 134 { 135 global $PARSER_MODES; 136 $PARSER_MODES[$category][] = $modeName; 137 $this->modes = null; // invalidate cached mode list 138 } 139 140 /** 141 * Register a mode that handles its own line endings. 142 * Modes registered here will be skipped by Eol's connectTo(). 143 * 144 * @param string $mode The mode name 145 * @return void 146 */ 147 public function registerBlockEolMode(string $mode): void 148 { 149 $this->blockEolModes[] = $mode; 150 } 151 152 /** 153 * Get all modes that handle their own line endings. 154 * 155 * @return string[] 156 */ 157 public function getBlockEolModes(): array 158 { 159 return $this->blockEolModes; 160 } 161 162 /** 163 * Register regex-escaped line start marker characters for a mode. 164 * Preformatted uses these to build a negative lookahead. 165 * 166 * @param string $mode The mode name 167 * @param string[] $markers Regex-escaped marker characters (e.g. ['\\*', '\\-']) 168 * @return void 169 */ 170 public function registerLineStartMarkers(string $mode, array $markers): void 171 { 172 $this->lineStartMarkers[$mode] = $markers; 173 } 174 175 /** 176 * Get all registered line start markers, merged and deduplicated. 177 * 178 * @return string[] 179 */ 180 public function getLineStartMarkers(): array 181 { 182 if (!$this->lineStartMarkers) return []; 183 return array_unique(array_merge(...array_values($this->lineStartMarkers))); 184 } 185 186 /** 187 * Get all parser modes, fully instantiated and sorted by priority. 188 * 189 * This includes syntax plugins, built-in modes, formatting modes, and 190 * data-driven modes (smileys, acronyms, entities). Results are cached 191 * unless running in a test environment. 192 * 193 * @return array[] Each entry is ['sort' => int, 'mode' => string, 'obj' => ModeInterface] 194 */ 195 public function getModes(): array 196 { 197 global $conf; 198 199 if ($this->modes !== null && !defined('DOKU_UNITTEST')) { 200 return $this->modes; 201 } 202 203 $this->modes = []; 204 $syntax = $conf['syntax'] ?? 'dokuwiki'; 205 $loadDw = in_array($syntax, ['dokuwiki', 'dw+md', 'md+dw']); 206 $loadMd = in_array($syntax, ['markdown', 'dw+md', 'md+dw']); 207 208 $this->loadPluginModes(); 209 $this->loadAlwaysModes(); 210 if ($loadDw) $this->loadDokuWikiModes(); 211 if ($loadMd) $this->loadMarkdownModes(); 212 $this->loadDataModes(); 213 214 usort($this->modes, self::sortModes(...)); 215 return $this->modes; 216 } 217 218 /** 219 * Load syntax plugin modes and register them in their categories. 220 */ 221 protected function loadPluginModes(): void 222 { 223 global $PARSER_MODES; 224 225 $plugins = plugin_list('syntax'); 226 foreach ($plugins as $p) { 227 $obj = plugin_load('syntax', $p); 228 if (!$obj instanceof PluginInterface) continue; 229 $PARSER_MODES[$obj->getType()][] = "plugin_$p"; 230 $this->modes[] = [ 231 'sort' => $obj->getSort(), 232 'mode' => "plugin_$p", 233 'obj' => $obj, 234 ]; 235 unset($obj); 236 } 237 } 238 239 /** 240 * Load modes that have no equivalent in the other syntax. 241 * These are always active regardless of the syntax setting. 242 */ 243 protected function loadAlwaysModes(): void 244 { 245 global $conf; 246 247 $modes = [ 248 'strong', 'subscript', 'superscript', 249 'footnote', 'eol', 'preformatted', 250 'quote', 'externallink', 'emaillink', 'windowssharelink', 251 'notoc', 'nocache', 'rss', 252 ]; 253 254 if ($conf['typography']) { 255 $modes[] = 'quotes'; 256 $modes[] = 'multiplyentity'; 257 } 258 259 $this->instantiateModes($modes); 260 } 261 262 /** 263 * Load DokuWiki-specific modes for features that also exist in Markdown. 264 * Skipped when syntax is 'markdown'. 265 */ 266 protected function loadDokuWikiModes(): void 267 { 268 global $conf; 269 $syntax = $conf['syntax'] ?? 'dokuwiki'; 270 $dwPreferred = in_array($syntax, ['dokuwiki', 'dw+md'], true); 271 272 $modes = [ 273 'emphasis', 'deleted', 'code', 'header', 'hr', 274 'linebreak', 'internallink', 'media', 'listblock', 'table', 275 'monospace', 'unformatted', 'file', 276 ]; 277 278 // Underline only loads when DokuWiki is preferred. In MD-preferred 279 // modes, `__` means strong (via gfm_strong_underscore) and loading 280 // Underline here would conflict. 281 if ($dwPreferred) { 282 $modes[] = 'underline'; 283 } 284 285 $this->instantiateModes($modes); 286 } 287 288 /** 289 * Load Markdown-specific modes for features that also exist in DokuWiki. 290 * Skipped when syntax is 'dokuwiki'. 291 */ 292 protected function loadMarkdownModes(): void 293 { 294 global $conf; 295 $syntax = $conf['syntax'] ?? 'dokuwiki'; 296 $mdPreferred = in_array($syntax, ['markdown', 'md+dw'], true); 297 298 $modes = [ 299 'gfm_emphasis', 'gfm_emphasis_strong', 'gfm_deleted', 300 'gfm_backtick_single', 'gfm_backtick_double', 301 'gfm_header', 'gfm_link', 'gfm_media', 302 'gfm_code', 'gfm_file', 303 ]; 304 305 // Underscore-based emphasis and strong only load when Markdown is 306 // preferred. In DW-preferred modes, `__` means underline and loading 307 // these would conflict. 308 if ($mdPreferred) { 309 $modes[] = 'gfm_emphasis_underscore'; 310 $modes[] = 'gfm_strong_underscore'; 311 $modes[] = 'gfm_emphasis_strong_underscore'; 312 } 313 314 $this->instantiateModes($modes); 315 } 316 317 /** 318 * Load data-driven modes that require constructor arguments 319 * (smileys, acronyms, entities) and optional config-gated modes. 320 */ 321 protected function loadDataModes(): void 322 { 323 global $conf; 324 325 $obj = new Smiley(array_keys(getSmileys())); 326 $this->modes[] = ['sort' => $obj->getSort(), 'mode' => 'smiley', 'obj' => $obj]; 327 328 $obj = new Acronym(array_keys(getAcronyms())); 329 $this->modes[] = ['sort' => $obj->getSort(), 'mode' => 'acronym', 'obj' => $obj]; 330 331 $obj = new Entity(array_keys(getEntities())); 332 $this->modes[] = ['sort' => $obj->getSort(), 'mode' => 'entity', 'obj' => $obj]; 333 334 if (!empty($conf['camelcase'])) { 335 $obj = new Camelcaselink(); 336 $this->modes[] = ['sort' => $obj->getSort(), 'mode' => 'camelcaselink', 'obj' => $obj]; 337 } 338 } 339 340 /** 341 * Instantiate mode classes by name and add them to the mode list. 342 * 343 * Mode names are split on `_` and each segment is PascalCased to form the 344 * class name (e.g. `gfm_emphasis_underscore` → `GfmEmphasisUnderscore`, 345 * `internallink` → `Internallink`, `strong` → `Strong`). 346 * 347 * @param string[] $modeNames 348 */ 349 protected function instantiateModes(array $modeNames): void 350 { 351 foreach ($modeNames as $mode) { 352 $class = implode('', array_map('ucfirst', explode('_', $mode))); // snake_case to PascalCase 353 $class = 'dokuwiki\\Parsing\\ParserMode\\' . $class; // prepend namespace 354 $obj = new $class(); 355 $this->modes[] = [ 356 'sort' => $obj->getSort(), 357 'mode' => $mode, 358 'obj' => $obj, 359 ]; 360 } 361 } 362 363 /** 364 * Callback function for usort 365 * 366 * @param array $a 367 * @param array $b 368 * @return int 369 */ 370 public static function sortModes(array $a, array $b): int 371 { 372 return $a['sort'] <=> $b['sort']; 373 } 374} 375