xref: /dokuwiki/inc/Parsing/ModeRegistry.php (revision 10fb3d6558c6f13ffaca18fede92dbc37fe3ede0)
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'],
77            self::CATEGORY_FORMATTING => [
78                'strong', 'emphasis', 'underline', 'monospace',
79                'subscript', 'superscript', 'deleted', 'footnote',
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            ],
87            self::CATEGORY_PROTECTED  => ['preformatted', 'code', 'file'],
88            self::CATEGORY_DISABLED   => ['unformatted'],
89            self::CATEGORY_PARAGRAPHS => ['eol'],
90        ];
91    }
92
93    /**
94     * Get all mode names in the given categories.
95     *
96     * @param string[] $categories One or more CATEGORY_* constants
97     * @return string[] Unique list of mode names
98     */
99    public function getModesForCategories(array $categories): array
100    {
101        global $PARSER_MODES;
102        $modes = [];
103        foreach ($categories as $cat) {
104            if (isset($PARSER_MODES[$cat])) {
105                $modes = array_merge($modes, $PARSER_MODES[$cat]);
106            }
107        }
108        return array_unique($modes);
109    }
110
111    /**
112     * Get the raw categories array.
113     *
114     * @return array<string, string[]> Category name => list of mode names
115     */
116    public function getCategories(): array
117    {
118        global $PARSER_MODES;
119        return $PARSER_MODES;
120    }
121
122    /**
123     * Register a mode in a category.
124     *
125     * @param string $category One of the CATEGORY_* constants
126     * @param string $modeName The mode name to register
127     * @return void
128     */
129    public function registerMode(string $category, string $modeName): void
130    {
131        global $PARSER_MODES;
132        $PARSER_MODES[$category][] = $modeName;
133        $this->modes = null; // invalidate cached mode list
134    }
135
136    /**
137     * Register a mode that handles its own line endings.
138     * Modes registered here will be skipped by Eol's connectTo().
139     *
140     * @param string $mode The mode name
141     * @return void
142     */
143    public function registerBlockEolMode(string $mode): void
144    {
145        $this->blockEolModes[] = $mode;
146    }
147
148    /**
149     * Get all modes that handle their own line endings.
150     *
151     * @return string[]
152     */
153    public function getBlockEolModes(): array
154    {
155        return $this->blockEolModes;
156    }
157
158    /**
159     * Register regex-escaped line start marker characters for a mode.
160     * Preformatted uses these to build a negative lookahead.
161     *
162     * @param string $mode The mode name
163     * @param string[] $markers Regex-escaped marker characters (e.g. ['\\*', '\\-'])
164     * @return void
165     */
166    public function registerLineStartMarkers(string $mode, array $markers): void
167    {
168        $this->lineStartMarkers[$mode] = $markers;
169    }
170
171    /**
172     * Get all registered line start markers, merged and deduplicated.
173     *
174     * @return string[]
175     */
176    public function getLineStartMarkers(): array
177    {
178        if (!$this->lineStartMarkers) return [];
179        return array_unique(array_merge(...array_values($this->lineStartMarkers)));
180    }
181
182    /**
183     * Get all parser modes, fully instantiated and sorted by priority.
184     *
185     * This includes syntax plugins, built-in modes, formatting modes, and
186     * data-driven modes (smileys, acronyms, entities). Results are cached
187     * unless running in a test environment.
188     *
189     * @return array[] Each entry is ['sort' => int, 'mode' => string, 'obj' => ModeInterface]
190     */
191    public function getModes(): array
192    {
193        global $conf;
194
195        if ($this->modes !== null && !defined('DOKU_UNITTEST')) {
196            return $this->modes;
197        }
198
199        $this->modes = [];
200        $syntax = $conf['syntax'] ?? 'dokuwiki';
201        $loadDw = in_array($syntax, ['dokuwiki', 'dw+md', 'md+dw']);
202        $loadMd = in_array($syntax, ['markdown', 'dw+md', 'md+dw']);
203
204        $this->loadPluginModes();
205        $this->loadAlwaysModes();
206        if ($loadDw) $this->loadDokuWikiModes();
207        if ($loadMd) $this->loadMarkdownModes();
208        $this->loadDataModes();
209
210        usort($this->modes, self::sortModes(...));
211        return $this->modes;
212    }
213
214    /**
215     * Load syntax plugin modes and register them in their categories.
216     */
217    protected function loadPluginModes(): void
218    {
219        global $PARSER_MODES;
220
221        $plugins = plugin_list('syntax');
222        foreach ($plugins as $p) {
223            $obj = plugin_load('syntax', $p);
224            if (!$obj instanceof PluginInterface) continue;
225            $PARSER_MODES[$obj->getType()][] = "plugin_$p";
226            $this->modes[] = [
227                'sort' => $obj->getSort(),
228                'mode' => "plugin_$p",
229                'obj'  => $obj,
230            ];
231            unset($obj);
232        }
233    }
234
235    /**
236     * Load modes that have no equivalent in the other syntax.
237     * These are always active regardless of the syntax setting.
238     */
239    protected function loadAlwaysModes(): void
240    {
241        global $conf;
242
243        $modes = [
244            'strong', 'underline', 'monospace', 'subscript', 'superscript',
245            'footnote', 'eol', 'unformatted', 'preformatted', 'file',
246            'quote', 'externallink', 'emaillink', 'windowssharelink',
247            'notoc', 'nocache', 'rss',
248        ];
249
250        if ($conf['typography']) {
251            $modes[] = 'quotes';
252            $modes[] = 'multiplyentity';
253        }
254
255        $this->instantiateModes($modes);
256    }
257
258    /**
259     * Load DokuWiki-specific modes for features that also exist in Markdown.
260     * Skipped when syntax is 'markdown'.
261     */
262    protected function loadDokuWikiModes(): void
263    {
264        $this->instantiateModes([
265            'emphasis', 'deleted', 'code', 'header', 'hr',
266            'linebreak', 'internallink', 'media', 'listblock', 'table',
267        ]);
268    }
269
270    /**
271     * Load Markdown-specific modes for features that also exist in DokuWiki.
272     * Skipped when syntax is 'dokuwiki'.
273     */
274    protected function loadMarkdownModes(): void
275    {
276        $this->instantiateModes([
277            // Future: 'gfmemphasis', 'gfmdeleted', 'gfmcode', etc.
278        ]);
279    }
280
281    /**
282     * Load data-driven modes that require constructor arguments
283     * (smileys, acronyms, entities) and optional config-gated modes.
284     */
285    protected function loadDataModes(): void
286    {
287        global $conf;
288
289        $obj = new Smiley(array_keys(getSmileys()));
290        $this->modes[] = ['sort' => $obj->getSort(), 'mode' => 'smiley', 'obj' => $obj];
291
292        $obj = new Acronym(array_keys(getAcronyms()));
293        $this->modes[] = ['sort' => $obj->getSort(), 'mode' => 'acronym', 'obj' => $obj];
294
295        $obj = new Entity(array_keys(getEntities()));
296        $this->modes[] = ['sort' => $obj->getSort(), 'mode' => 'entity', 'obj' => $obj];
297
298        if (!empty($conf['camelcase'])) {
299            $obj = new Camelcaselink();
300            $this->modes[] = ['sort' => $obj->getSort(), 'mode' => 'camelcaselink', 'obj' => $obj];
301        }
302    }
303
304    /**
305     * Instantiate mode classes by name and add them to the mode list.
306     *
307     * Each name is resolved to a class in the ParserMode namespace via ucfirst().
308     *
309     * @param string[] $modeNames
310     */
311    protected function instantiateModes(array $modeNames): void
312    {
313        foreach ($modeNames as $mode) {
314            $class = 'dokuwiki\\Parsing\\ParserMode\\' . ucfirst($mode);
315            $obj = new $class();
316            $this->modes[] = [
317                'sort' => $obj->getSort(),
318                'mode' => $mode,
319                'obj'  => $obj,
320            ];
321        }
322    }
323
324    /**
325     * Callback function for usort
326     *
327     * @param array $a
328     * @param array $b
329     * @return int
330     */
331    public static function sortModes(array $a, array $b): int
332    {
333        return $a['sort'] <=> $b['sort'];
334    }
335}
336