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