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
397            // fuck: template should be a runtime parameters and is not
398            $executingAction = ExecutionContext::getActualOrCreateFromEnv()->getExecutingAction();
399            if ($executingAction === "combo_" . FetcherPageBundler::NAME) {
400                $hasMainHeaderElement = false;
401            }
402
403            if ($hasMainHeaderElement) {
404                $tagAttributes->addClassName("d-none");
405            }
406
407        }
408
409        /**
410         * Printing
411         */
412        $tag = self::getTagFromContext($context, $level);
413        $renderer->doc .= $tagAttributes->toHtmlEnterTag($tag);
414
415    }
416
417    /**
418     * @param TagAttributes $tagAttributes
419     * @param string $context
420     * @return string
421     */
422    public
423    static function renderClosingTag(TagAttributes $tagAttributes, string $context): string
424    {
425        $level = $tagAttributes->getValueAndRemove(HeadingTag::LEVEL);
426        if ($level == null) {
427            LogUtility::msg("The level is mandatory when closing a heading", self::CANONICAL);
428        }
429        $tag = self::getTagFromContext($context, $level);
430
431        return "</$tag>";
432    }
433
434    /**
435     * Reduce the end of the input string
436     * to the first opening tag without the ">"
437     * and returns the closing tag
438     *
439     * @param $input
440     * @return array - the heading attributes as a string
441     */
442    public
443    static function reduceToFirstOpeningTagAndReturnAttributes(&$input)
444    {
445        // the variable that will capture the attribute string
446        $headingStartTagString = "";
447        // Set to true when the heading tag has completed
448        $endHeadingParsed = false;
449        // The closing character `>` indicator of the start and end tag
450        // true when found
451        $endTagClosingCharacterParsed = false;
452        $startTagClosingCharacterParsed = false;
453        // We start from the edn
454        $position = strlen($input) - 1;
455        while ($position > 0) {
456            $character = $input[$position];
457
458            if ($character == "<") {
459                if (!$endHeadingParsed) {
460                    // We are at the beginning of the ending tag
461                    $endHeadingParsed = true;
462                } else {
463                    // We have delete all character until the heading start tag
464                    // add the last one and exit
465                    $headingStartTagString = $character . $headingStartTagString;
466                    break;
467                }
468            }
469
470            if ($character == ">") {
471                if (!$endTagClosingCharacterParsed) {
472                    // We are at the beginning of the ending tag
473                    $endTagClosingCharacterParsed = true;
474                } else {
475                    // We have delete all character until the heading start tag
476                    $startTagClosingCharacterParsed = true;
477                }
478            }
479
480            if ($startTagClosingCharacterParsed) {
481                $headingStartTagString = $character . $headingStartTagString;
482            }
483
484
485            // position --
486            $position--;
487
488        }
489        $input = substr($input, 0, $position);
490
491        if (!empty($headingStartTagString)) {
492            return PluginUtility::getTagAttributes($headingStartTagString);
493        } else {
494            LogUtility::msg("The attributes of the heading are empty and this should not be possible");
495            return [];
496        }
497
498
499    }
500
501    public
502    static function handleEnter(\Doku_Handler $handler, TagAttributes $tagAttributes, string $markupTag): array
503    {
504        /**
505         * Context determination
506         */
507        $callStack = CallStack::createFromHandler($handler);
508        $context = HeadingTag::getContext($callStack);
509
510        /**
511         * Level is mandatory (for the closing tag)
512         */
513        $level = $tagAttributes->getValue(HeadingTag::LEVEL);
514        if ($level === null) {
515
516            /**
517             * Old title type
518             * from 1 to 4 to set the display heading
519             */
520            $type = $tagAttributes->getType();
521            if (is_numeric($type) && $type != 0) {
522                $level = $type;
523                if ($markupTag === self::TITLE_TAG) {
524                    $type = "d$level";
525                } else {
526                    $type = "h$level";
527                }
528                $tagAttributes->setType($type);
529            }
530            /**
531             * Still null, check the type
532             */
533            if ($level == null) {
534                if (in_array($type, HeadingTag::getAllTypes())) {
535                    $level = substr($type, 1);
536                }
537            }
538            /**
539             * Still null, default level
540             */
541            if ($level == null) {
542                if ($context === HeadingTag::TYPE_OUTLINE) {
543                    $level = HeadingTag::DEFAULT_LEVEL_OUTLINE_CONTEXT;
544                } else {
545                    $level = HeadingTag::DEFAULT_LEVEL_TITLE_CONTEXT;
546                }
547            }
548            /**
549             * Set the level
550             */
551            $tagAttributes->addComponentAttributeValue(HeadingTag::LEVEL, $level);
552        }
553        return [PluginUtility::CONTEXT => $context];
554    }
555
556    public
557    static function getAllTypes(): array
558    {
559        return array_merge(
560            self::DISPLAY_TYPES,
561            self::HEADING_TYPES,
562            self::SHORT_TYPES,
563            self::TITLE_DISPLAY_TYPES
564        );
565    }
566
567    /**
568     * @param string $context
569     * @param int $level
570     * @return string
571     */
572    private static function getTagFromContext(string $context, int $level): string
573    {
574        if ($context === self::TYPE_OUTLINE) {
575            return "h$level";
576        } else {
577            return "div";
578        }
579    }
580
581
582}
583