1<?php
2
3
4use ComboStrap\AnalyticsDocument;
5use ComboStrap\Bootstrap;
6use ComboStrap\CallStack;
7use ComboStrap\LogUtility;
8use ComboStrap\PageH1;
9use ComboStrap\PluginUtility;
10use ComboStrap\TagAttributes;
11
12
13if (!defined('DOKU_INC')) die();
14
15/**
16 * Class syntax_plugin_combo_heading
17 * Heading HTML super set
18 *
19 * It contains also all heading utility class
20 *
21 */
22class syntax_plugin_combo_heading extends DokuWiki_Syntax_Plugin
23{
24
25
26    const TAG = "heading";
27    const OLD_TITLE_TAG = "title"; // old tag
28    const TAGS = [self::TAG, self::OLD_TITLE_TAG];
29
30    const LEVEL = 'level';
31    const DISPLAY_BS_4_RESPONSIVE_SNIPPET_ID = "display-bs-4";
32    const ALL_TYPES = ["h1", "h2", "h3", "h4", "h5", "h6", "d1", "d2", "d3", "d4", "d5", "d6"];
33    const DISPLAY_TYPES = ["d1", "d2", "d3", "d4", "d5", "d6"];
34    const DISPLAY_TYPES_ONLY_BS_5 = ["d5", "d6"]; // only available in 5
35
36    /**
37     * An heading may be printed
38     * as outline and should be in the toc
39     */
40    const TYPE_OUTLINE = "outline";
41    const HEADING_TYPES = ["h1", "h2", "h3", "h4", "h5", "h6"];
42    /**
43     * The attribute that holds only the text of the heading
44     * (used to create the id and the text in the toc)
45     */
46    const HEADING_TEXT_ATTRIBUTE = "heading_text";
47    const TYPE_TITLE = "title";
48
49    const CANONICAL = "heading";
50
51    const SYNTAX_TYPE = 'baseonly';
52    const SYNTAX_PTYPE = 'block';
53
54    /**
55     * The default level if not set
56     * Not level 1 because this is the top level heading
57     * Not level 2 because this is the most used level and we can confound with it
58     */
59    const DEFAULT_LEVEL = "3";
60
61    /**
62     * The section generation:
63     *   - Dokuwiki section (ie div just after the heading)
64     *   - or Combo section (ie section just before the heading)
65     */
66    public const CONF_SECTION_LAYOUT = 'section_layout';
67    const CONF_SECTION_LAYOUT_COMBO = "combo";
68    const CONF_SECTION_LAYOUT_DOKUWIKI = "dokuwiki";
69    const CONF_SECTION_LAYOUT_VALUES = [self::CONF_SECTION_LAYOUT_COMBO, self::CONF_SECTION_LAYOUT_DOKUWIKI];
70    const CONF_SECTION_LAYOUT_DEFAULT = self::CONF_SECTION_LAYOUT_COMBO;
71
72    /**
73     * A common function used to handle exit of headings
74     * @param CallStack $callStack
75     * @return array
76     */
77    public static function handleExit(CallStack $callStack)
78    {
79        /**
80         * Delete the last space if any
81         */
82        $callStack->moveToEnd();
83        $previous = $callStack->previous();
84        if ($previous->getState() == DOKU_LEXER_UNMATCHED) {
85            $previous->setPayload(rtrim($previous->getCapturedContent()));
86        }
87        $callStack->next();
88
89        /**
90         * Get context data
91         */
92        $openingTag = $callStack->moveToPreviousCorrespondingOpeningCall();
93        $openingAttributes = $openingTag->getAttributes(); // for level
94        $context = $openingTag->getContext(); // for sectioning
95
96        return array(
97            PluginUtility::STATE => DOKU_LEXER_EXIT,
98            PluginUtility::ATTRIBUTES => $openingAttributes,
99            PluginUtility::CONTEXT => $context
100        );
101    }
102
103    public static function processHeadingMetadataH1($level, $text)
104    {
105        /**
106         * Capture the h1
107         */
108        if ($level == 1) {
109
110            global $ID;
111            p_set_metadata(
112                $ID,
113                array(PageH1::H1_PARSED => trim($text)),
114                false,
115                false // runtime meta
116            );
117
118        }
119    }
120
121
122    /**
123     * @param $data
124     * @param Doku_Renderer_metadata $renderer
125     */
126    public static function processHeadingMetadata($data, Doku_Renderer_metadata $renderer)
127    {
128
129        $state = $data[PluginUtility::STATE];
130        if ($state == DOKU_LEXER_ENTER) {
131            /**
132             * Only outline heading metadata
133             * Not component heading
134             */
135            $context = $data[PluginUtility::CONTEXT];
136            if ($context === self::TYPE_OUTLINE) {
137                $callStackArray = $data[PluginUtility::ATTRIBUTES];
138                $tagAttributes = TagAttributes::createFromCallStackArray($callStackArray);
139                $text = trim($tagAttributes->getValue(syntax_plugin_combo_heading::HEADING_TEXT_ATTRIBUTE));
140                $level = $tagAttributes->getValue(syntax_plugin_combo_heading::LEVEL);
141                self::processHeadingMetadataH1($level, $text);
142                $renderer->header($text, $level, null);
143            }
144        }
145
146    }
147
148    public static function processMetadataAnalytics(array $data, renderer_plugin_combo_analytics $renderer)
149    {
150        $state = $data[PluginUtility::STATE];
151        if ($state == DOKU_LEXER_ENTER) {
152            /**
153             * Only outline heading metadata
154             * Not component heading
155             */
156            $context = $data[PluginUtility::CONTEXT];
157            if ($context == self::TYPE_OUTLINE) {
158                $callStackArray = $data[PluginUtility::ATTRIBUTES];
159                $tagAttributes = TagAttributes::createFromCallStackArray($callStackArray);
160                $text = $tagAttributes->getValue(syntax_plugin_combo_heading::HEADING_TEXT_ATTRIBUTE);
161                $level = $tagAttributes->getValue(syntax_plugin_combo_heading::LEVEL);
162                $renderer->header($text, $level, null);
163            }
164        }
165    }
166
167
168    /**
169     * @param CallStack $callStack
170     * @return string
171     */
172    public static function getContext($callStack)
173    {
174
175        /**
176         * If the heading is inside a component,
177         * it's a title heading, otherwise it's a outline heading
178         * (Except for {@link syntax_plugin_combo_webcode} that can wrap outline heading)
179         *
180         * When the parent is empty, a section_open (ie another outline heading)
181         * this is a outline
182         */
183        $parent = $callStack->moveToParent();
184        if ($parent != false && $parent->getTagName() == syntax_plugin_combo_webcode::TAG) {
185            $parent = $callStack->moveToParent();
186        }
187        if ($parent != false && $parent->getComponentName() != "section_open") {
188            $headingType = self::TYPE_TITLE;
189        } else {
190            $headingType = self::TYPE_OUTLINE;
191        }
192
193        switch ($headingType) {
194            case syntax_plugin_combo_heading::TYPE_TITLE:
195
196                $context = $parent->getTagName();
197                break;
198
199            case syntax_plugin_combo_heading::TYPE_OUTLINE:
200
201                $context = syntax_plugin_combo_heading::TYPE_OUTLINE;
202                break;
203
204            default:
205                LogUtility::msg("The heading type ($headingType) is unknown");
206                $context = "";
207                break;
208        }
209        return $context;
210    }
211
212    /**
213     * Reduce the end of the input string
214     * to the first opening tag without the ">"
215     * and returns the closing tag
216     *
217     * @param $input
218     * @return array - the heading attributes as a string
219     */
220    public static function reduceToFirstOpeningTagAndReturnAttributes(&$input)
221    {
222        // the variable that will capture the attribute string
223        $headingStartTagString = "";
224        // Set to true when the heading tag has completed
225        $endHeadingParsed = false;
226        // The closing character `>` indicator of the start and end tag
227        // true when found
228        $endTagClosingCharacterParsed = false;
229        $startTagClosingCharacterParsed = false;
230        // We start from the edn
231        $position = strlen($input) - 1;
232        while ($position > 0) {
233            $character = $input[$position];
234
235            if ($character == "<") {
236                if (!$endHeadingParsed) {
237                    // We are at the beginning of the ending tag
238                    $endHeadingParsed = true;
239                } else {
240                    // We have delete all character until the heading start tag
241                    // add the last one and exit
242                    $headingStartTagString = $character . $headingStartTagString;
243                    break;
244                }
245            }
246
247            if ($character == ">") {
248                if (!$endTagClosingCharacterParsed) {
249                    // We are at the beginning of the ending tag
250                    $endTagClosingCharacterParsed = true;
251                } else {
252                    // We have delete all character until the heading start tag
253                    $startTagClosingCharacterParsed = true;
254                }
255            }
256
257            if ($startTagClosingCharacterParsed) {
258                $headingStartTagString = $character . $headingStartTagString;
259            }
260
261
262            // position --
263            $position--;
264
265        }
266        $input = substr($input, 0, $position);
267
268        if (!empty($headingStartTagString)) {
269            return PluginUtility::getTagAttributes($headingStartTagString);
270        } else {
271            LogUtility::msg("The attributes of the heading are empty and this should not be possible");
272            return [];
273        }
274
275
276    }
277
278    /**
279     * @param string $context
280     * @param TagAttributes $tagAttributes
281     * @param Doku_Renderer_xhtml $renderer
282     * @param integer $pos
283     */
284    public static function renderOpeningTag($context, $tagAttributes, &$renderer, $pos)
285    {
286
287        /**
288         * Variable
289         */
290        $type = $tagAttributes->getType();
291
292
293        /**
294         * Level
295         */
296        $level = $tagAttributes->getValueAndRemove(syntax_plugin_combo_heading::LEVEL);
297
298
299        /**
300         * Display Heading
301         * https://getbootstrap.com/docs/5.0/content/typography/#display-headings
302         */
303        if (in_array($type, self::DISPLAY_TYPES)) {
304
305            $displayClass = "display-$level";
306
307            if (Bootstrap::getBootStrapMajorVersion() == "4") {
308                /**
309                 * Make Bootstrap display responsive
310                 */
311                PluginUtility::getSnippetManager()->attachCssInternalStyleSheetForSlot(syntax_plugin_combo_heading::DISPLAY_BS_4_RESPONSIVE_SNIPPET_ID);
312
313                if (in_array($type, self::DISPLAY_TYPES_ONLY_BS_5)) {
314                    $displayClass = "display-4";
315                    LogUtility::msg("Bootstrap 4 does not support the type ($type). Switch to " . PluginUtility::getDocumentationHyperLink(Bootstrap::CANONICAL, "bootstrap 5") . " if you want to use it. The display type was set to `d4`", LogUtility::LVL_MSG_WARNING, self::CANONICAL);
316                }
317
318            }
319            $tagAttributes->addClassName($displayClass);
320
321        }
322
323        /**
324         * Heading class
325         * https://getbootstrap.com/docs/5.0/content/typography/#headings
326         * Works on 4 and 5
327         */
328        if (in_array($type, self::HEADING_TYPES)) {
329            $tagAttributes->addClassName($type);
330        }
331
332        /**
333         * Card title Context class
334         * TODO: should move to card
335         */
336        if (in_array($context, [syntax_plugin_combo_blockquote::TAG, syntax_plugin_combo_card::TAG])) {
337            $tagAttributes->addClassName("card-title");
338        }
339
340        if ($context == self::TYPE_OUTLINE) {
341
342            /**
343             * Calling the {@link Doku_Renderer_xhtml::header()}
344             * with the captured text to be Dokuwiki Template compatible
345             * It will create the toc and the section editing
346             */
347            if ($tagAttributes->hasComponentAttribute(self::HEADING_TEXT_ATTRIBUTE)) {
348                $tocText = $tagAttributes->getValueAndRemove(self::HEADING_TEXT_ATTRIBUTE);
349                if (empty($tocText)) {
350                    LogUtility::msg("The heading text should be not null on the enter tag");
351                }
352                if (trim(strtolower($tocText)) === "articles related") {
353                    $tagAttributes->addClassName("d-print-none");
354                }
355            } else {
356                $tocText = "Heading Text Not found";
357                LogUtility::msg("The heading text attribute was not found for the toc");
358            }
359
360
361            // note on the position value
362            // this is the exact position because we does not capture any EOL
363            // and therefore the section should start at the first captured character
364
365            $renderer->header($tocText, $level, $pos);
366            $attributes = syntax_plugin_combo_heading::reduceToFirstOpeningTagAndReturnAttributes($renderer->doc);
367            foreach ($attributes as $key => $value) {
368                if ($key === "id" && $tagAttributes->hasAttribute($key)) {
369                    // The id was set in the markup, don't overwrite
370                    continue;
371                }
372                $tagAttributes->addComponentAttributeValue($key, $value);
373            }
374
375        }
376
377
378        /**
379         * Printing
380         */
381        $renderer->doc .= $tagAttributes->toHtmlEnterTag("h$level");
382
383    }
384
385    /**
386     * @param TagAttributes $tagAttributes
387     * @return string
388     */
389    public static function renderClosingTag(TagAttributes $tagAttributes): string
390    {
391        $level = $tagAttributes->getValueAndRemove(syntax_plugin_combo_heading::LEVEL);
392        if ($level == null) {
393            LogUtility::msg("The level is mandatory when closing a heading", self::CANONICAL);
394        }
395        return "</h$level>" . DOKU_LF;
396    }
397
398
399    /**
400     * Syntax Type.
401     *
402     * Needs to return one of the mode types defined in $PARSER_MODES in parser.php
403     * @see DokuWiki_Syntax_Plugin::getType()
404     */
405    function getType(): string
406    {
407        return self::SYNTAX_TYPE;
408    }
409
410    /**
411     *
412     * How Dokuwiki will add P element
413     *
414     *  * 'normal' - The plugin can be used inside paragraphs (inline)
415     *  * 'block'  - Open paragraphs need to be closed before plugin output - block should not be inside paragraphs
416     *  * 'stack'  - Special case. Plugin wraps other paragraphs. - Stacks can contain paragraphs
417     *
418     * @see DokuWiki_Syntax_Plugin::getPType()
419     *
420     * This is the equivalent of inline or block for css
421     */
422    function getPType()
423    {
424        return self::SYNTAX_PTYPE;
425    }
426
427    /**
428     * @return array
429     * Allow which kind of plugin inside
430     *
431     * No one of array('baseonly','container', 'formatting', 'substition', 'protected', 'disabled', 'paragraphs')
432     * because we manage self the content and we call self the parser
433     *
434     * Return an array of one or more of the mode types {@link $PARSER_MODES} in Parser.php
435     */
436    function getAllowedTypes()
437    {
438        return array('formatting', 'substition', 'protected', 'disabled', 'paragraphs');
439    }
440
441    /**
442     *
443     * @return int
444     */
445    function getSort()
446    {
447        return 50;
448    }
449
450
451    function connectTo($mode)
452    {
453
454        /**
455         * Heading tag
456         */
457        foreach (self::TAGS as $tag) {
458            $this->Lexer->addEntryPattern(PluginUtility::getContainerTagPattern($tag), $mode, PluginUtility::getModeFromTag($this->getPluginComponent()));
459        }
460
461    }
462
463    public function postConnect()
464    {
465        foreach (self::TAGS as $tag) {
466            $this->Lexer->addExitPattern(PluginUtility::getEndTagPattern($tag), PluginUtility::getModeFromTag($this->getPluginComponent()));
467        }
468    }
469
470
471    function handle($match, $state, $pos, Doku_Handler $handler)
472    {
473
474        switch ($state) {
475
476
477            case DOKU_LEXER_ENTER :
478
479                $tagAttributes = TagAttributes::createFromTagMatch($match);
480
481                /**
482                 * Level is mandatory (for the closing tag)
483                 */
484                $level = $tagAttributes->getValue(syntax_plugin_combo_heading::LEVEL);
485                if ($level == null) {
486
487                    /**
488                     * Old title type
489                     * from 1 to 4 to set the display heading
490                     */
491                    $type = $tagAttributes->getType();
492                    if (is_numeric($type) && $type != 0) {
493                        $level = $type;
494                        $tagAttributes->setType("d$level");
495                    }
496                    /**
497                     * Still null, check the type
498                     */
499                    if ($level == null) {
500                        if (in_array($type, self::ALL_TYPES)) {
501                            $level = substr($type, 1);
502                        }
503                    }
504                    /**
505                     * Still null, default level
506                     */
507                    if ($level == null) {
508                        $level = self::DEFAULT_LEVEL;
509                    }
510                    /**
511                     * Set the level
512                     */
513                    $tagAttributes->addComponentAttributeValue(self::LEVEL, $level);
514                }
515
516                /**
517                 * Context determination
518                 */
519                $callStack = CallStack::createFromHandler($handler);
520                $context = self::getContext($callStack);
521
522                return array(
523                    PluginUtility::STATE => $state,
524                    PluginUtility::ATTRIBUTES => $tagAttributes->toCallStackArray(),
525                    PluginUtility::CONTEXT => $context,
526                    PluginUtility::POSITION => $pos
527                );
528
529            case DOKU_LEXER_UNMATCHED :
530
531                return PluginUtility::handleAndReturnUnmatchedData(self::TAG, $match, $handler);
532
533            case DOKU_LEXER_EXIT :
534
535                $callStack = CallStack::createFromHandler($handler);
536
537
538                /**
539                 * Get enter attributes and content
540                 */
541                $callStack->moveToEnd();
542                $openingTag = $callStack->moveToPreviousCorrespondingOpeningCall();
543                $context = $openingTag->getContext(); // for sectioning
544                $attributes = $openingTag->getAttributes(); // for the level
545
546                return array(
547                    PluginUtility::STATE => $state,
548                    PluginUtility::CONTEXT => $context,
549                    PluginUtility::ATTRIBUTES => $attributes
550                );
551
552
553        }
554        return array();
555
556    }
557
558    /**
559     * Render the output
560     * @param string $format
561     * @param Doku_Renderer $renderer
562     * @param array $data - what the function handle() return'ed
563     * @return boolean - rendered correctly? (however, returned value is not used at the moment)
564     * @see DokuWiki_Syntax_Plugin::render()
565     *
566     *
567     */
568    function render($format, Doku_Renderer $renderer, $data)
569    {
570
571        if ($format == 'xhtml') {
572
573            /** @var Doku_Renderer_xhtml $renderer */
574            $state = $data[PluginUtility::STATE];
575            switch ($state) {
576
577                case DOKU_LEXER_ENTER:
578                    $parentTag = $data[PluginUtility::CONTEXT];
579                    $attributes = $data[PluginUtility::ATTRIBUTES];
580                    $pos = $data[PluginUtility::POSITION];
581                    $tagAttributes = TagAttributes::createFromCallStackArray($attributes, syntax_plugin_combo_heading::TAG);
582                    self::renderOpeningTag($parentTag, $tagAttributes, $renderer, $pos);
583                    break;
584                case DOKU_LEXER_UNMATCHED:
585                    $renderer->doc .= PluginUtility::renderUnmatched($data);
586                    break;
587                case DOKU_LEXER_EXIT:
588                    $attributes = $data[PluginUtility::ATTRIBUTES];
589                    $tagAttributes = TagAttributes::createFromCallStackArray($attributes);
590                    $renderer->doc .= self::renderClosingTag($tagAttributes);
591                    break;
592
593            }
594        } else if ($format == renderer_plugin_combo_analytics::RENDERER_FORMAT) {
595
596            /**
597             * @var renderer_plugin_combo_analytics $renderer
598             */
599            syntax_plugin_combo_heading::processMetadataAnalytics($data, $renderer);
600
601        } else if ($format == "metadata") {
602
603            /**
604             * @var Doku_Renderer_metadata $renderer
605             */
606            syntax_plugin_combo_heading::processHeadingMetadata($data, $renderer);
607
608        }
609        // unsupported $mode
610        return false;
611    }
612
613
614}
615
616