1<?php
2/**
3 * DokuWiki Syntax Plugin Combostrap.
4 *
5 */
6
7use ComboStrap\Bootstrap;
8use ComboStrap\Call;
9use ComboStrap\CallStack;
10use ComboStrap\LogUtility;
11use ComboStrap\PluginUtility;
12use ComboStrap\Tag;
13use ComboStrap\TagAttributes;
14
15if (!defined('DOKU_INC')) {
16    die();
17}
18
19require_once(__DIR__ . '/../ComboStrap/PluginUtility.php');
20require_once(__DIR__ . '/../ComboStrap/CallStack.php');
21
22/**
23 *
24 * The name of the class must follow a pattern (don't change it)
25 * ie:
26 *    syntax_plugin_PluginName_ComponentName
27 *
28 * The tabs component is a little bit a nasty one
29 * because it's used in three cases:
30 *   * the new syntax to enclose the panels
31 *   * the new syntax to create the tabs
32 *   * the old syntax to create the tabs
33 * The code is using the context to manage this cases
34 *
35 * Full example can be found
36 * in the Javascript section of tabs and navs
37 * https://getbootstrap.com/docs/5.0/components/navs-tabs/#javascript-behavior
38 *
39 * Vertical Pills
40 * https://getbootstrap.com/docs/4.0/components/navs/#vertical
41 */
42class syntax_plugin_combo_tabs extends DokuWiki_Syntax_Plugin
43{
44
45    const TAG = 'tabs';
46
47    /**
48     * A key attributes to set on in the instructions the attributes
49     * of panel
50     */
51    const KEY_PANEL_ATTRIBUTES = "panels";
52    const LABEL = 'label';
53
54    /**
55     * A tabs with this context will create
56     * the HTML for a navigational element
57     * The calls with this context are derived
58     * and created
59     */
60    const NAVIGATIONAL_ELEMENT_CONTEXT = "tabHeader";
61
62    /**
63     * Type tabs
64     */
65    const TABS_TYPE = "tabs";
66    const PILLS_TYPE = "pills";
67    const ENCLOSED_TABS_TYPE = "enclosed-tabs";
68    const ENCLOSED_PILLS_TYPE = "enclosed-pills";
69    const TABS_SKIN = "tabs";
70    const PILLS_SKIN = "pills";
71
72    private static function getComponentType(&$attributes)
73    {
74        $type = self::TABS_TYPE;
75        if (isset($attributes["skin"])) {
76            $type = $attributes["skin"];
77            unset($attributes["skin"]);
78        }
79        if (isset($attributes["type"])) {
80            $type = $attributes["type"];
81            unset($attributes["type"]);
82        }
83        return $type;
84    }
85
86
87    /**
88     * @param $attributes
89     * @return string - return the HTML open tags of the panels (not the navigation)
90     */
91    public static function openTabPanelsElement(&$attributes)
92    {
93        PluginUtility::addClass2Attributes("tab-content", $attributes);
94
95        /**
96         * In preview with only one panel
97         */
98        global $ACT;
99        if($ACT=="preview"&& isset($attributes["selected"])){
100            unset($attributes["selected"]);
101        }
102
103        $html = "<div " . PluginUtility::array2HTMLAttributesAsString($attributes) . ">" . DOKU_LF;
104        $type = self::getComponentType($attributes);
105        switch ($type) {
106            case self::ENCLOSED_TABS_TYPE:
107            case self::ENCLOSED_PILLS_TYPE:
108                $html = "<div class=\"card-body\">" . DOKU_LF . $html;
109                break;
110        }
111        return $html;
112
113
114    }
115
116    public static function closeTabPanelsElement(&$attributes)
117    {
118        $html = "</div>" . DOKU_LF;
119        $type = self::getComponentType($attributes);
120        switch ($type) {
121            case self::ENCLOSED_TABS_TYPE:
122            case self::ENCLOSED_PILLS_TYPE:
123                $html .= "</div>" . DOKU_LF;
124                $html .= "</div>" . DOKU_LF;
125                break;
126        }
127        return $html;
128    }
129
130    public static function closeNavigationalTabElement()
131    {
132        return "</a>" . DOKU_LF . "</li>";
133    }
134
135    /**
136     * @param array $attributes
137     * @return string
138     */
139    public static function openNavigationalTabElement(array $attributes)
140    {
141
142        /**
143         * Check all attributes for the link (not the li)
144         * and delete them
145         */
146        $active = syntax_plugin_combo_panel::getSelectedValue($attributes);
147        $panel = "";
148
149
150        $panelAttrName = "panel";
151        if (isset($attributes[$panelAttrName])) {
152            $panel = $attributes[$panelAttrName];
153            unset($attributes[$panelAttrName]);
154        } else {
155            if (isset($attributes["id"])) {
156                $panel = $attributes["id"];
157                unset($attributes["id"]);
158            } else {
159                LogUtility::msg("A id attribute is missing on a panel tag", LogUtility::LVL_MSG_ERROR, syntax_plugin_combo_tabs::TAG);
160            }
161        }
162
163        /**
164         * Creating the li element
165         */
166        PluginUtility::addClass2Attributes("nav-item", $attributes);
167        $html = "<li " . PluginUtility::array2HTMLAttributesAsString($attributes) . " role=\"presentation\">" . DOKU_LF;
168
169        /**
170         * Creating the a element
171         */
172        $htmlAttributes = array();
173        PluginUtility::addClass2Attributes("nav-link", $htmlAttributes);
174        if ($active === true) {
175            PluginUtility::addClass2Attributes("active", $htmlAttributes);
176            $htmlAttributes["aria-selected"] = "true";
177        }
178        $htmlAttributes['id'] = $panel . "-tab";
179        $namespace = Bootstrap::getDataNamespace();
180        $htmlAttributes["data{$namespace}-toggle"] = "tab";
181        $htmlAttributes['aria-controls'] = $panel;
182        $htmlAttributes["role"] = "tab";
183        $htmlAttributes['href'] = "#$panel";
184
185        $html .= "<a " . PluginUtility::array2HTMLAttributesAsString($htmlAttributes) . ">";
186        return $html;
187    }
188
189    private static function closeNavigationalHeaderComponent($type)
190    {
191        $html = "</ul>" . DOKU_LF;
192        switch ($type) {
193            case self::ENCLOSED_PILLS_TYPE:
194            case self::ENCLOSED_TABS_TYPE:
195                $html .= "</div>" . DOKU_LF;
196        }
197        return $html;
198
199    }
200
201    /**
202     * @param $attributes
203     * @return string - the opening HTML code of the tab navigational header
204     */
205    public static function openNavigationalTabsElement(&$attributes)
206    {
207        $htmlAttributes = $attributes;
208        /**
209         * Unset non-html attributes
210         */
211        unset($htmlAttributes[self::KEY_PANEL_ATTRIBUTES]);
212
213        /**
214         * Type (Skin determination)
215         */
216        $type = self::getComponentType($attributes);
217
218        /**
219         * $skin (tabs or pills)
220         */
221        $skin = self::TABS_TYPE;
222        switch ($type) {
223            case self::TABS_TYPE:
224            case self::ENCLOSED_TABS_TYPE:
225                $skin = self::TABS_SKIN;
226                break;
227            case self::PILLS_TYPE:
228            case self::ENCLOSED_PILLS_TYPE:
229                $skin = self::PILLS_SKIN;
230                break;
231            default:
232                LogUtility::log2FrontEnd("The tabs type ($type) has an unknown skin", LogUtility::LVL_MSG_ERROR, self::TAG);
233        }
234
235        /**
236         * Creates the panel wrapper element
237         */
238        $html = "";
239        switch ($type) {
240            case self::TABS_TYPE:
241            case self::PILLS_TYPE:
242                if (!key_exists("spacing", $htmlAttributes)) {
243                    $htmlAttributes["spacing"] = "mb-3";
244                }
245                PluginUtility::addClass2Attributes("nav", $htmlAttributes);
246                PluginUtility::addClass2Attributes("nav-$skin", $htmlAttributes);
247                $htmlAttributes['role'] = 'tablist';
248                $html = "<ul " . PluginUtility::array2HTMLAttributesAsString($htmlAttributes) . ">";
249                break;
250            case self::ENCLOSED_TABS_TYPE:
251            case self::ENCLOSED_PILLS_TYPE:
252                /**
253                 * The HTML opening for cards
254                 */
255                PluginUtility::addClass2Attributes("card", $htmlAttributes);
256                $html = "<div " . PluginUtility::array2HTMLAttributesAsString($htmlAttributes) . ">" . DOKU_LF .
257                    "<div class=\"card-header\">" . DOKU_LF;
258                /**
259                 * The HTML opening for the menu (UL)
260                 */
261                $ulHtmlAttributes = array();
262                PluginUtility::addClass2Attributes("nav", $ulHtmlAttributes);
263                PluginUtility::addClass2Attributes("nav-$skin", $ulHtmlAttributes);
264                PluginUtility::addClass2Attributes("card-header-$skin", $ulHtmlAttributes);
265                $html .= "<ul " . PluginUtility::array2HTMLAttributesAsString($ulHtmlAttributes) . ">" . DOKU_LF;
266                break;
267            default:
268                LogUtility::log2FrontEnd("The tabs type ($type) is unknown", LogUtility::LVL_MSG_ERROR, self::TAG);
269        }
270        return $html;
271
272    }
273
274
275    /**
276     * Syntax Type.
277     *
278     * Needs to return one of the mode types defined in $PARSER_MODES in parser.php
279     * @see DokuWiki_Syntax_Plugin::getType()
280     */
281    function getType()
282    {
283        return 'container';
284    }
285
286    /**
287     * @return array
288     * Allow which kind of plugin inside
289     * ************************
290     * This function has no effect because {@link SyntaxPlugin::accepts()} is used
291     * ************************
292     */
293    public function getAllowedTypes()
294    {
295        return array('container', 'base', 'formatting', 'substition', 'protected', 'disabled', 'paragraphs');
296    }
297
298    public function accepts($mode)
299    {
300        /**
301         * header mode is disable to take over
302         * and replace it with {@link syntax_plugin_combo_heading}
303         */
304        if ($mode == "header") {
305            return false;
306        }
307        /**
308         * If preformatted is disable, we does not accept it
309         */
310        return syntax_plugin_combo_preformatted::disablePreformatted($mode);
311
312    }
313
314
315    /**
316     * How Dokuwiki will add P element
317     *
318     *  * 'normal' - The plugin can be used inside paragraphs
319     *  * 'block'  - Open paragraphs need to be closed before plugin output - block should not be inside paragraphs
320     *  * 'stack'  - Special case. Plugin wraps other paragraphs. - Stacks can contain paragraphs
321     *
322     * @see DokuWiki_Syntax_Plugin::getPType()
323     */
324    function getPType()
325    {
326        return 'block';
327    }
328
329    /**
330     * @see Doku_Parser_Mode::getSort()
331     *
332     * the mode with the lowest sort number will win out
333     * the container (parent) must then have a lower number than the child
334     */
335    function getSort()
336    {
337        return 100;
338    }
339
340    /**
341     * Create a pattern that will called this plugin
342     *
343     * @param string $mode
344     * @see Doku_Parser_Mode::connectTo()
345     */
346    function connectTo($mode)
347    {
348
349        $pattern = PluginUtility::getContainerTagPattern(self::TAG);
350        $this->Lexer->addEntryPattern($pattern, $mode, PluginUtility::getModeFromTag($this->getPluginComponent()));
351
352    }
353
354    public function postConnect()
355    {
356
357        $this->Lexer->addExitPattern('</' . self::TAG . '>', PluginUtility::getModeFromTag($this->getPluginComponent()));
358
359    }
360
361    /**
362     *
363     * The handle function goal is to parse the matched syntax through the pattern function
364     * and to return the result for use in the renderer
365     * This result is always cached until the page is modified.
366     * @param string $match
367     * @param int $state
368     * @param int $pos
369     * @param Doku_Handler $handler
370     * @return array|bool
371     * @see DokuWiki_Syntax_Plugin::handle()
372     *
373     */
374    function handle($match, $state, $pos, Doku_Handler $handler)
375    {
376
377        switch ($state) {
378
379            case DOKU_LEXER_ENTER:
380
381                $attributes = PluginUtility::getTagAttributes($match);
382
383                return array(
384                    PluginUtility::STATE => $state,
385                    PluginUtility::ATTRIBUTES => $attributes);
386
387            case DOKU_LEXER_UNMATCHED:
388
389                // We should never get there but yeah ...
390                return PluginUtility::handleAndReturnUnmatchedData(self::TAG, $match, $handler);
391
392
393            case DOKU_LEXER_EXIT :
394
395                $tag = new Tag(self::TAG, array(), $state, $handler);
396                $openingTag = $tag->getOpeningTag();
397                $descendant = $openingTag->getFirstMeaningFullDescendant();
398                $context = null;
399                if ($descendant != null) {
400                    /**
401                     * Add the context to the opening and ending tag
402                     */
403                    $context = $descendant->getName();
404                    $openingTag->setContext($context);
405                    if ($context == syntax_plugin_combo_panel::TAG) {
406
407                        /**
408                         * Copy the descendant before manipulating the stack
409                         */
410                        $descendants = $openingTag->getDescendants();
411
412                        /**
413                         * Start the navigation tabs element
414                         * We add calls in the stack to create the tabs navigational element
415                         */
416                        $navigationalCallElements[] = Call::createComboCall(
417                            self::TAG,
418                            DOKU_LEXER_ENTER,
419                            $openingTag->getAttributes(),
420                            self::NAVIGATIONAL_ELEMENT_CONTEXT
421                        )->toCallArray();
422                        $labelStacksToDelete = array();
423                        foreach ($descendants as $descendant) {
424
425                            /**
426                             * Define the panel attributes
427                             * (May be null)
428                             */
429                            if (empty($panelAttributes)) {
430                                $panelAttributes = array();
431                            }
432
433                            /**
434                             * If this is a panel tag, we capture the attributes
435                             */
436                            if (
437                                $descendant->getName() == syntax_plugin_combo_panel::TAG
438                                &&
439                                $descendant->getState() == DOKU_LEXER_ENTER
440                            ) {
441                                $panelAttributes = $descendant->getAttributes();
442                                continue;
443                            }
444
445                            /**
446                             * If this is a label tag, we capture the tags
447                             */
448                            if (
449                                $descendant->getName() == syntax_plugin_combo_label::TAG
450                                &&
451                                $descendant->getState() == DOKU_LEXER_ENTER
452                            ) {
453
454                                $labelStacks = $descendant->getDescendants();
455
456                                /**
457                                 * Get the labels call to delete
458                                 * (done at the end)
459                                 */
460                                $labelStacksSize = sizeof($labelStacks);
461                                $firstPosition = $descendant->getActualPosition(); // the enter label is deleted
462                                $lastPosition = $labelStacks[$labelStacksSize - 1]->getActualPosition() + 1; // the exit label is deleted
463                                $labelStacksToDelete[] = [$firstPosition, $lastPosition];
464
465
466                                /**
467                                 * Build the navigational call stack for this label
468                                 * with another context just to tag them and see them in the stack
469                                 */
470                                $firstLabelCall = $handler->calls[$descendant->getActualPosition()];
471                                $firstLabelCall[1][PluginUtility::CONTEXT] = self::NAVIGATIONAL_ELEMENT_CONTEXT;
472                                $navigationalCallElements[] = $firstLabelCall;
473                                for ($i = 1; $i <= $labelStacksSize; $i++) {
474                                    $intermediateLabelCall = $handler->calls[$descendant->getActualPosition() + $i];
475                                    $intermediateLabelCall[1][PluginUtility::CONTEXT] = self::NAVIGATIONAL_ELEMENT_CONTEXT;
476                                    $navigationalCallElements[] = $intermediateLabelCall;
477                                }
478                                $lastLabelCall = $handler->calls[$lastPosition];
479                                $lastLabelCall[1][PluginUtility::CONTEXT] = self::NAVIGATIONAL_ELEMENT_CONTEXT;
480                                $navigationalCallElements[] = $lastLabelCall;
481                            }
482
483                        }
484                        $navigationalCallElements[] = Call::createComboCall(
485                            self::TAG,
486                            DOKU_LEXER_EXIT,
487                            $openingTag->getAttributes(),
488                            self::NAVIGATIONAL_ELEMENT_CONTEXT
489                        )->toCallArray();
490
491
492                        /**
493                         * Deleting the labels first
494                         * because the navigational tabs are added (and would then move the position)
495                         */
496                        foreach ($labelStacksToDelete as $labelStackToDelete) {
497                            $start = $labelStackToDelete[0];
498                            $end = $labelStackToDelete[1];
499                            CallStack::deleteCalls($handler->calls, $start, $end);
500                        }
501                        /**
502                         * Then deleting
503                         */
504                        CallStack::insertCallStackUpWards($handler->calls, $openingTag->getActualPosition(), $navigationalCallElements);
505                    }
506                }
507
508                return array(
509                    PluginUtility::STATE => $state,
510                    PluginUtility::CONTEXT => $context,
511                    PluginUtility::ATTRIBUTES => $openingTag->getAttributes()
512                );
513
514
515        }
516
517        return array();
518
519    }
520
521    /**
522     * Render the output
523     * @param string $format
524     * @param Doku_Renderer $renderer
525     * @param array $data - what the function handle() return'ed
526     * @return boolean - rendered correctly? (however, returned value is not used at the moment)
527     * @see DokuWiki_Syntax_Plugin::render()
528     *
529     *
530     */
531    function render($format, Doku_Renderer $renderer, $data)
532    {
533
534        if ($format == 'xhtml') {
535
536            /** @var Doku_Renderer_xhtml $renderer */
537            $state = $data[PluginUtility::STATE];
538
539            switch ($state) {
540
541                case DOKU_LEXER_ENTER :
542                    $context = $data[PluginUtility::CONTEXT];
543                    $attributes = $data[PluginUtility::ATTRIBUTES];
544
545                    switch ($context) {
546                        /**
547                         * When the tag tabs enclosed the panels
548                         */
549                        case syntax_plugin_combo_panel::TAG:
550                            $renderer->doc .= self::openTabPanelsElement($attributes);
551                            break;
552                        /**
553                         * When the tag tabs are derived (new syntax)
554                         */
555                        case self::NAVIGATIONAL_ELEMENT_CONTEXT:
556                            /**
557                             * Old syntax, when the tag had to be added specifically
558                             */
559                        case syntax_plugin_combo_tab::TAG:
560                            $renderer->doc .= self::openNavigationalTabsElement($attributes);
561                            break;
562                        default:
563                            LogUtility::log2FrontEnd("The context ($context) is unknown in enter", LogUtility::LVL_MSG_ERROR, self::TAG);
564
565                    }
566
567
568                    break;
569                case DOKU_LEXER_EXIT :
570                    $context = $data[PluginUtility::CONTEXT];
571                    $attributes = $data[PluginUtility::ATTRIBUTES];
572                    switch ($context) {
573                        /**
574                         * New syntax (tabpanel enclosing)
575                         */
576                        case syntax_plugin_combo_panel::TAG:
577                            $renderer->doc .= self::closeTabPanelsElement($attributes);
578                            break;
579                        /**
580                         * Old syntax
581                         */
582                        case syntax_plugin_combo_tab::TAG:
583                            /**
584                             * New syntax (Derived)
585                             */
586                        case self::NAVIGATIONAL_ELEMENT_CONTEXT:
587                            $type = self::getComponentType($data[PluginUtility::ATTRIBUTES]);
588                            $renderer->doc .= self::closeNavigationalHeaderComponent($type);
589                            break;
590                        default:
591                            LogUtility::log2FrontEnd("The context $context is unknown in exit", LogUtility::LVL_MSG_ERROR, self::TAG);
592                    }
593                    break;
594                case DOKU_LEXER_UNMATCHED:
595                    $renderer->doc .= PluginUtility::renderUnmatched($data);
596                    break;
597            }
598            return true;
599        }
600        return false;
601    }
602
603
604}
605