1<?php
2
3namespace ComboStrap;
4
5
6use ComboStrap\Meta\Field\PageH1;
7use Doku_Renderer_metadata;
8use Doku_Renderer_xhtml;
9use renderer_plugin_combo_analytics;
10use syntax_plugin_combo_webcode;
11
12class HeadingTag
13{
14
15    /**
16     * The type of the heading tag
17     */
18    public const DISPLAY_TYPES = ["d1", "d2", "d3", "d4", "d5", "d6"];
19    public const HEADING_TYPES = ["h1", "h2", "h3", "h4", "h5", "h6"];
20    public const SHORT_TYPES = ["1", "2", "3", "4", "5", "6"];
21
22    /**
23     * The type of the title tag
24     * @deprecated
25     */
26    public const TITLE_DISPLAY_TYPES = ["0", "1", "2", "3", "4", "5", "6"];
27
28
29    /**
30     * An heading may be printed
31     * as outline and should be in the toc
32     */
33    public const TYPE_OUTLINE = "outline";
34    public const CANONICAL = "heading";
35
36    public const SYNTAX_TYPE = 'baseonly';
37    public const SYNTAX_PTYPE = 'block';
38    /**
39     * The section generation:
40     *   - Dokuwiki section (ie div just after the heading)
41     *   - or Combo section (ie section just before the heading)
42     */
43    public const CONF_SECTION_LAYOUT = 'section_layout';
44    public const CONF_SECTION_LAYOUT_VALUES = [HeadingTag::CONF_SECTION_LAYOUT_COMBO, HeadingTag::CONF_SECTION_LAYOUT_DOKUWIKI];
45    public const CONF_SECTION_LAYOUT_DEFAULT = HeadingTag::CONF_SECTION_LAYOUT_COMBO;
46    public const LEVEL = 'level';
47    public const CONF_SECTION_LAYOUT_DOKUWIKI = "dokuwiki";
48    /**
49     *  old tag
50     */
51    public const TITLE_TAG = "title";
52    /**
53     * New tag
54     */
55    public const HEADING_TAG = "heading";
56    public const LOGICAL_TAG = self::HEADING_TAG;
57
58
59    /**
60     * The default level if not set
61     * Not level 1 because this is the top level heading
62     * Not level 2 because this is the most used level and we can confound with it
63     */
64    public const DEFAULT_LEVEL_TITLE_CONTEXT = "3";
65    public const DISPLAY_BS_4_RESPONSIVE_SNIPPET_ID = "display-bs-4";
66    /**
67     * The attribute that holds only the text of the heading
68     * (used to create the id and the text in the toc)
69     */
70    public const HEADING_TEXT_ATTRIBUTE = "heading_text";
71    public const CONF_SECTION_LAYOUT_COMBO = "combo";
72
73
74    /**
75     * 1 because in test if used without any, this is
76     * the first expected one in a outline
77     */
78    public const DEFAULT_LEVEL_OUTLINE_CONTEXT = "1";
79    public const TYPE_TITLE = "title";
80    public const TAGS = [HeadingTag::HEADING_TAG, HeadingTag::TITLE_TAG];
81    /**
82     * only available in 5
83     */
84    public const DISPLAY_TYPES_ONLY_BS_5 = ["d5", "d6"];
85
86    /**
87     * The label is the text that is generally used
88     * in a TOC but also as default title for the page
89     */
90    public const PARSED_LABEL = "label";
91
92
93    /**
94     * A common function used to handle exit of headings
95     * @param \Doku_Handler $handler
96     * @return array
97     */
98    public static function handleExit(\Doku_Handler $handler): array
99    {
100
101        $callStack = CallStack::createFromHandler($handler);
102
103        /**
104         * Delete the last space if any
105         */
106        $callStack->moveToEnd();
107        $previous = $callStack->previous();
108        if ($previous->getState() == DOKU_LEXER_UNMATCHED) {
109            $previous->setPayload(rtrim($previous->getCapturedContent()));
110        }
111        $callStack->next();
112
113        /**
114         * Get context data
115         */
116        $openingTag = $callStack->moveToPreviousCorrespondingOpeningCall();
117        $openingAttributes = $openingTag->getAttributes(); // for level
118        $context = $openingTag->getContext(); // for sectioning
119
120        return array(
121            PluginUtility::STATE => DOKU_LEXER_EXIT,
122            PluginUtility::ATTRIBUTES => $openingAttributes,
123            PluginUtility::CONTEXT => $context
124        );
125    }
126
127    /**
128     * @param CallStack $callStack
129     * @return string
130     */
131    public static function getContext(CallStack $callStack): string
132    {
133
134        /**
135         * If the heading is inside a component,
136         * it's a title heading, otherwise it's a outline heading
137         *
138         * (Except for {@link syntax_plugin_combo_webcode} that can wrap several outline heading)
139         *
140         * When the parent is empty, a section_open (ie another outline heading)
141         * this is a outline
142         */
143        $parent = $callStack->moveToParent();
144        if ($parent && $parent->getTagName() === Tag\WebCodeTag::TAG) {
145            $parent = $callStack->moveToParent();
146        }
147        if ($parent && $parent->getComponentName() !== "section_open") {
148            $headingType = self::TYPE_TITLE;
149        } else {
150            $headingType = self::TYPE_OUTLINE;
151        }
152
153        switch ($headingType) {
154            case HeadingTag::TYPE_TITLE:
155
156                $context = $parent->getTagName();
157                break;
158
159            case HeadingTag::TYPE_OUTLINE:
160
161                $context = HeadingTag::TYPE_OUTLINE;
162                break;
163
164            default:
165                LogUtility::msg("The heading type ($headingType) is unknown");
166                $context = "";
167                break;
168        }
169        return $context;
170    }
171
172    /**
173     * @param $data
174     * @param Doku_Renderer_metadata $renderer
175     */
176    public static function processHeadingEnterMetadata($data, Doku_Renderer_metadata $renderer)
177    {
178
179        $state = $data[PluginUtility::STATE];
180        if (!in_array($state, [DOKU_LEXER_ENTER, DOKU_LEXER_SPECIAL])) {
181            return;
182        }
183        /**
184         * Only outline heading metadata
185         * Not component heading
186         */
187        $context = $data[PluginUtility::CONTEXT];
188        if ($context === self::TYPE_OUTLINE) {
189
190            $callStackArray = $data[PluginUtility::ATTRIBUTES];
191            $tagAttributes = TagAttributes::createFromCallStackArray($callStackArray);
192            $text = $tagAttributes->getValue(HeadingTag::HEADING_TEXT_ATTRIBUTE);
193            if ($text !== null) {
194                $text = trim($text);
195            }
196            $level = $tagAttributes->getValue(HeadingTag::LEVEL);
197            $pos = 0; // mandatory for header but not for metadata, we set 0 to make the code analyser happy
198            $renderer->header($text, $level, $pos);
199
200            if ($level === 1) {
201                $parsedLabel = $tagAttributes->getValue(self::PARSED_LABEL);
202                $renderer->meta[PageH1::H1_PARSED] = $parsedLabel;
203            }
204
205        }
206
207
208    }
209
210    public static function processMetadataAnalytics(array $data, renderer_plugin_combo_analytics $renderer)
211    {
212
213        $state = $data[PluginUtility::STATE];
214        if ($state !== DOKU_LEXER_ENTER) {
215            return;
216        }
217        /**
218         * Only outline heading metadata
219         * Not component heading
220         */
221        $context = $data[PluginUtility::CONTEXT] ?? null;
222        if ($context === self::TYPE_OUTLINE) {
223            $callStackArray = $data[PluginUtility::ATTRIBUTES];
224            $tagAttributes = TagAttributes::createFromCallStackArray($callStackArray);
225            $text = $tagAttributes->getValue(HeadingTag::HEADING_TEXT_ATTRIBUTE);
226            $level = $tagAttributes->getValue(HeadingTag::LEVEL);
227            $renderer->header($text, $level, 0);
228        }
229
230    }
231
232    /**
233     * @param string $context
234     * @param TagAttributes $tagAttributes
235     * @param Doku_Renderer_xhtml $renderer
236     * @param int|null $pos - null if the call was generated
237     * @return void
238     */
239    public static function processRenderEnterXhtml(string $context, TagAttributes $tagAttributes, Doku_Renderer_xhtml &$renderer, ?int $pos)
240    {
241
242        /**
243         * All correction that are dependent
244         * on the markup (ie title or heading)
245         * are done in the {@link self::processRenderEnterXhtml()}
246         */
247
248        /**
249         * Variable
250         */
251        $type = $tagAttributes->getType();
252
253        /**
254         * Old syntax deprecated
255         */
256        if ($type === "0") {
257            if ($context === self::TYPE_OUTLINE) {
258                $type = 'h' . self::DEFAULT_LEVEL_OUTLINE_CONTEXT;
259            } else {
260                $type = 'h' . self::DEFAULT_LEVEL_TITLE_CONTEXT;
261            }
262        }
263        /**
264         * Label is for the TOC
265         */
266        $tagAttributes->removeAttributeIfPresent(self::PARSED_LABEL);
267
268
269        /**
270         * Level
271         */
272        $level = $tagAttributes->getValueAndRemove(HeadingTag::LEVEL);
273
274        /**
275         * Display Heading
276         * https://getbootstrap.com/docs/5.0/content/typography/#display-headings
277         */
278        if ($context !== self::TYPE_OUTLINE && $type === null) {
279            /**
280             * if not an outline, a display
281             */
282            $type = "h$level";
283        }
284        if (in_array($type, self::DISPLAY_TYPES)) {
285
286            $displayClass = "display-$level";
287
288            if (Bootstrap::getBootStrapMajorVersion() == Bootstrap::BootStrapFourMajorVersion) {
289                /**
290                 * Make Bootstrap display responsive
291                 */
292                PluginUtility::getSnippetManager()->attachCssInternalStyleSheet(HeadingTag::DISPLAY_BS_4_RESPONSIVE_SNIPPET_ID);
293
294                if (in_array($type, self::DISPLAY_TYPES_ONLY_BS_5)) {
295                    $displayClass = "display-4";
296                    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);
297                }
298
299            }
300            $tagAttributes->addClassName($displayClass);
301        }
302
303        /**
304         * Heading class
305         * https://getbootstrap.com/docs/5.0/content/typography/#headings
306         * Works on 4 and 5
307         */
308        if (in_array($type, self::HEADING_TYPES)) {
309            $tagAttributes->addClassName($type);
310        }
311
312        /**
313         * Card title Context class
314         * TODO: should move to card
315         */
316        if (in_array($context, [BlockquoteTag::TAG, CardTag::CARD_TAG])) {
317            $tagAttributes->addClassName("card-title");
318        }
319
320        $executionContext = ExecutionContext::getActualOrCreateFromEnv();
321
322        /**
323         * Add an outline class to be able to style them at once
324         *
325         * The context is by default the parent name or outline.
326         */
327        $snippetManager = SnippetSystem::getFromContext();
328        if ($context === self::TYPE_OUTLINE) {
329
330            $tagAttributes->addClassName(Outline::getOutlineHeadingClass());
331
332            $snippetManager->attachCssInternalStyleSheet(self::TYPE_OUTLINE);
333
334            // numbering
335            try {
336
337                $enable = $executionContext
338                    ->getConfig()
339                    ->getValue(Outline::CONF_OUTLINE_NUMBERING_ENABLE, Outline::CONF_OUTLINE_NUMBERING_ENABLE_DEFAULT);
340                if ($enable) {
341                    $snippet = $snippetManager->attachCssInternalStyleSheet(Outline::OUTLINE_HEADING_NUMBERING);
342                    if (!$snippet->hasInlineContent()) {
343                        $css = Outline::getCssNumberingRulesFor(Outline::OUTLINE_HEADING_NUMBERING);
344                        $snippet->setInlineContent($css);
345                    }
346                }
347            } catch (ExceptionBadSyntax $e) {
348                LogUtility::internalError("An error has occurred while trying to add the outline heading numbering stylesheet.", self::CANONICAL, $e);
349            } catch (ExceptionNotEnabled $e) {
350                // ok
351            }
352
353            /**
354             * Anchor on id
355             */
356            $snippetManager = PluginUtility::getSnippetManager();
357            try {
358                $snippetManager->attachRemoteJavascriptLibrary(
359                    Outline::OUTLINE_ANCHOR,
360                    "https://cdn.jsdelivr.net/npm/anchor-js@4.3.0/anchor.min.js",
361                    "sha256-LGOWMG4g6/zc0chji4hZP1d8RxR2bPvXMzl/7oPZqjs="
362                );
363            } catch (ExceptionBadArgument|ExceptionBadSyntax $e) {
364                // The url has a file name. this error should not happen
365                LogUtility::internalError("Unable to add anchor. Error:{$e->getMessage()}", Outline::OUTLINE_ANCHOR);
366            }
367            $snippetManager->attachJavascriptFromComponentId(Outline::OUTLINE_ANCHOR);
368
369        }
370        $snippetManager->attachCssInternalStyleSheet(HeadingTag::HEADING_TAG);
371
372        /**
373         * Not a HTML attribute
374         */
375        $tagAttributes->removeComponentAttributeIfPresent(self::HEADING_TEXT_ATTRIBUTE);
376
377        /**
378         * Two headings 1 are shown
379         *
380         * We delete the heading 1 in the instructions
381         * if the template has a content header
382         *
383         * The instructions may not be reprocessed after upgrade for instance
384         * when the installation is done manually
385         *
386         * To avoid to have two headings, we set a display none if this is the case
387         *
388         * Note that this should only apply on the document and not on a partial but yeah
389         * We go that h1 is not used in partials.
390         */
391        if ($level === 1) {
392
393            $hasMainHeaderElement = TemplateForWebPage::create()
394                ->setRequestedContextPath(ExecutionContext::getActualOrCreateFromEnv()->getContextPath())
395                ->hasElement(TemplateSlot::MAIN_HEADER_ID);
396            if ($hasMainHeaderElement) {
397                $tagAttributes->addClassName("d-none");
398            }
399
400        }
401
402        /**
403         * Printing
404         */
405        $tag = self::getTagFromContext($context, $level);
406        $renderer->doc .= $tagAttributes->toHtmlEnterTag($tag);
407
408    }
409
410    /**
411     * @param TagAttributes $tagAttributes
412     * @param string $context
413     * @return string
414     */
415    public
416    static function renderClosingTag(TagAttributes $tagAttributes, string $context): string
417    {
418        $level = $tagAttributes->getValueAndRemove(HeadingTag::LEVEL);
419        if ($level == null) {
420            LogUtility::msg("The level is mandatory when closing a heading", self::CANONICAL);
421        }
422        $tag = self::getTagFromContext($context, $level);
423
424        return "</$tag>";
425    }
426
427    /**
428     * Reduce the end of the input string
429     * to the first opening tag without the ">"
430     * and returns the closing tag
431     *
432     * @param $input
433     * @return array - the heading attributes as a string
434     */
435    public
436    static function reduceToFirstOpeningTagAndReturnAttributes(&$input)
437    {
438        // the variable that will capture the attribute string
439        $headingStartTagString = "";
440        // Set to true when the heading tag has completed
441        $endHeadingParsed = false;
442        // The closing character `>` indicator of the start and end tag
443        // true when found
444        $endTagClosingCharacterParsed = false;
445        $startTagClosingCharacterParsed = false;
446        // We start from the edn
447        $position = strlen($input) - 1;
448        while ($position > 0) {
449            $character = $input[$position];
450
451            if ($character == "<") {
452                if (!$endHeadingParsed) {
453                    // We are at the beginning of the ending tag
454                    $endHeadingParsed = true;
455                } else {
456                    // We have delete all character until the heading start tag
457                    // add the last one and exit
458                    $headingStartTagString = $character . $headingStartTagString;
459                    break;
460                }
461            }
462
463            if ($character == ">") {
464                if (!$endTagClosingCharacterParsed) {
465                    // We are at the beginning of the ending tag
466                    $endTagClosingCharacterParsed = true;
467                } else {
468                    // We have delete all character until the heading start tag
469                    $startTagClosingCharacterParsed = true;
470                }
471            }
472
473            if ($startTagClosingCharacterParsed) {
474                $headingStartTagString = $character . $headingStartTagString;
475            }
476
477
478            // position --
479            $position--;
480
481        }
482        $input = substr($input, 0, $position);
483
484        if (!empty($headingStartTagString)) {
485            return PluginUtility::getTagAttributes($headingStartTagString);
486        } else {
487            LogUtility::msg("The attributes of the heading are empty and this should not be possible");
488            return [];
489        }
490
491
492    }
493
494    public
495    static function handleEnter(\Doku_Handler $handler, TagAttributes $tagAttributes, string $markupTag): array
496    {
497        /**
498         * Context determination
499         */
500        $callStack = CallStack::createFromHandler($handler);
501        $context = HeadingTag::getContext($callStack);
502
503        /**
504         * Level is mandatory (for the closing tag)
505         */
506        $level = $tagAttributes->getValue(HeadingTag::LEVEL);
507        if ($level === null) {
508
509            /**
510             * Old title type
511             * from 1 to 4 to set the display heading
512             */
513            $type = $tagAttributes->getType();
514            if (is_numeric($type) && $type != 0) {
515                $level = $type;
516                if ($markupTag === self::TITLE_TAG) {
517                    $type = "d$level";
518                } else {
519                    $type = "h$level";
520                }
521                $tagAttributes->setType($type);
522            }
523            /**
524             * Still null, check the type
525             */
526            if ($level == null) {
527                if (in_array($type, HeadingTag::getAllTypes())) {
528                    $level = substr($type, 1);
529                }
530            }
531            /**
532             * Still null, default level
533             */
534            if ($level == null) {
535                if ($context === HeadingTag::TYPE_OUTLINE) {
536                    $level = HeadingTag::DEFAULT_LEVEL_OUTLINE_CONTEXT;
537                } else {
538                    $level = HeadingTag::DEFAULT_LEVEL_TITLE_CONTEXT;
539                }
540            }
541            /**
542             * Set the level
543             */
544            $tagAttributes->addComponentAttributeValue(HeadingTag::LEVEL, $level);
545        }
546        return [PluginUtility::CONTEXT => $context];
547    }
548
549    public
550    static function getAllTypes(): array
551    {
552        return array_merge(
553            self::DISPLAY_TYPES,
554            self::HEADING_TYPES,
555            self::SHORT_TYPES,
556            self::TITLE_DISPLAY_TYPES
557        );
558    }
559
560    /**
561     * @param string $context
562     * @param int $level
563     * @return string
564     */
565    private static function getTagFromContext(string $context, int $level): string
566    {
567        if ($context === self::TYPE_OUTLINE) {
568            return "h$level";
569        } else {
570            return "div";
571        }
572    }
573
574
575}
576