xref: /dokuwiki/inc/Parsing/ModeRegistry.php (revision e89aeebd5989e476b6a69236d9aabf72a9a01f14)
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',
90            ],
91            self::CATEGORY_PROTECTED  => ['preformatted', 'code', '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', 'monospace', 'subscript', 'superscript',
249            'footnote', 'eol', 'unformatted', 'preformatted', 'file',
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        ];
276
277        // Underline only loads when DokuWiki is preferred. In MD-preferred
278        // modes, `__` means strong (via gfm_strong_underscore) and loading
279        // Underline here would conflict.
280        if ($dwPreferred) {
281            $modes[] = 'underline';
282        }
283
284        $this->instantiateModes($modes);
285    }
286
287    /**
288     * Load Markdown-specific modes for features that also exist in DokuWiki.
289     * Skipped when syntax is 'dokuwiki'.
290     */
291    protected function loadMarkdownModes(): void
292    {
293        global $conf;
294        $syntax = $conf['syntax'] ?? 'dokuwiki';
295        $mdPreferred = in_array($syntax, ['markdown', 'md+dw'], true);
296
297        $modes = [
298            'gfm_emphasis', 'gfm_emphasis_strong', 'gfm_deleted',
299            'gfm_backtick_single', 'gfm_backtick_double',
300            'gfm_header', 'gfm_link',
301        ];
302
303        // Underscore-based emphasis and strong only load when Markdown is
304        // preferred. In DW-preferred modes, `__` means underline and loading
305        // these would conflict.
306        if ($mdPreferred) {
307            $modes[] = 'gfm_emphasis_underscore';
308            $modes[] = 'gfm_strong_underscore';
309            $modes[] = 'gfm_emphasis_strong_underscore';
310        }
311
312        $this->instantiateModes($modes);
313    }
314
315    /**
316     * Load data-driven modes that require constructor arguments
317     * (smileys, acronyms, entities) and optional config-gated modes.
318     */
319    protected function loadDataModes(): void
320    {
321        global $conf;
322
323        $obj = new Smiley(array_keys(getSmileys()));
324        $this->modes[] = ['sort' => $obj->getSort(), 'mode' => 'smiley', 'obj' => $obj];
325
326        $obj = new Acronym(array_keys(getAcronyms()));
327        $this->modes[] = ['sort' => $obj->getSort(), 'mode' => 'acronym', 'obj' => $obj];
328
329        $obj = new Entity(array_keys(getEntities()));
330        $this->modes[] = ['sort' => $obj->getSort(), 'mode' => 'entity', 'obj' => $obj];
331
332        if (!empty($conf['camelcase'])) {
333            $obj = new Camelcaselink();
334            $this->modes[] = ['sort' => $obj->getSort(), 'mode' => 'camelcaselink', 'obj' => $obj];
335        }
336    }
337
338    /**
339     * Instantiate mode classes by name and add them to the mode list.
340     *
341     * Mode names are split on `_` and each segment is PascalCased to form the
342     * class name (e.g. `gfm_emphasis_underscore` → `GfmEmphasisUnderscore`,
343     * `internallink` → `Internallink`, `strong` → `Strong`).
344     *
345     * @param string[] $modeNames
346     */
347    protected function instantiateModes(array $modeNames): void
348    {
349        foreach ($modeNames as $mode) {
350            $class = implode('', array_map('ucfirst', explode('_', $mode))); // snake_case to PascalCase
351            $class = 'dokuwiki\\Parsing\\ParserMode\\' . $class; // prepend namespace
352            $obj = new $class();
353            $this->modes[] = [
354                'sort' => $obj->getSort(),
355                'mode' => $mode,
356                'obj'  => $obj,
357            ];
358        }
359    }
360
361    /**
362     * Callback function for usort
363     *
364     * @param array $a
365     * @param array $b
366     * @return int
367     */
368    public static function sortModes(array $a, array $b): int
369    {
370        return $a['sort'] <=> $b['sort'];
371    }
372}
373