xref: /dokuwiki/inc/Parsing/ModeRegistry.php (revision 7958e69808290099292c0703b95d88708f6ebb96)
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        global $PARSER_MODES;
200        $this->modes = [];
201
202        // 1. Load syntax plugins and register their modes
203        $plugins = plugin_list('syntax');
204        foreach ($plugins as $p) {
205            $obj = plugin_load('syntax', $p);
206            if (!$obj instanceof PluginInterface) continue;
207            $PARSER_MODES[$obj->getType()][] = "plugin_$p";
208            $this->modes[] = [
209                'sort' => $obj->getSort(),
210                'mode' => "plugin_$p",
211                'obj'  => $obj,
212            ];
213            unset($obj);
214        }
215
216        // 2. Add standard built-in modes
217        $builtinModes = [
218            'listblock', 'preformatted', 'notoc', 'nocache',
219            'header', 'table', 'linebreak', 'footnote',
220            'hr', 'unformatted', 'code', 'file', 'quote',
221            'internallink', 'rss', 'media', 'externallink',
222            'emaillink', 'windowssharelink', 'eol',
223            'strong', 'emphasis', 'underline', 'monospace',
224            'subscript', 'superscript', 'deleted',
225        ];
226        if ($conf['typography']) {
227            $builtinModes[] = 'quotes';
228            $builtinModes[] = 'multiplyentity';
229        }
230        foreach ($builtinModes as $mode) {
231            $class = 'dokuwiki\\Parsing\\ParserMode\\' . ucfirst($mode);
232            $obj = new $class();
233            $this->modes[] = [
234                'sort' => $obj->getSort(),
235                'mode' => $mode,
236                'obj'  => $obj,
237            ];
238        }
239
240        // 3. Add data-driven modes
241        $obj = new Smiley(array_keys(getSmileys()));
242        $this->modes[] = ['sort' => $obj->getSort(), 'mode' => 'smiley', 'obj' => $obj];
243
244        $obj = new Acronym(array_keys(getAcronyms()));
245        $this->modes[] = ['sort' => $obj->getSort(), 'mode' => 'acronym', 'obj' => $obj];
246
247        $obj = new Entity(array_keys(getEntities()));
248        $this->modes[] = ['sort' => $obj->getSort(), 'mode' => 'entity', 'obj' => $obj];
249
250        // 4. Optional camelcase mode
251        if (!empty($conf['camelcase'])) {
252            $obj = new Camelcaselink();
253            $this->modes[] = ['sort' => $obj->getSort(), 'mode' => 'camelcaselink', 'obj' => $obj];
254        }
255
256        // 5. Sort by priority
257        usort($this->modes, self::sortModes(...));
258
259        return $this->modes;
260    }
261
262    /**
263     * Callback function for usort
264     *
265     * @param array $a
266     * @param array $b
267     * @return int
268     */
269    public static function sortModes(array $a, array $b): int
270    {
271        return $a['sort'] <=> $b['sort'];
272    }
273}
274