xref: /plugin/combo/ComboStrap/HeadingTag.php (revision 04fd306c7c155fa133ebb3669986875d65988276)
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 = trim($tagAttributes->getValue(HeadingTag::HEADING_TEXT_ATTRIBUTE));
193            $level = $tagAttributes->getValue(HeadingTag::LEVEL);
194            $pos = 0; // mandatory for header but not for metadata, we set 0 to make the code analyser happy
195            $renderer->header($text, $level, $pos);
196
197            if ($level === 1) {
198                $parsedLabel = $tagAttributes->getValue(self::PARSED_LABEL);
199                $renderer->meta[PageH1::H1_PARSED] = $parsedLabel;
200            }
201
202        }
203
204
205    }
206
207    public static function processMetadataAnalytics(array $data, renderer_plugin_combo_analytics $renderer)
208    {
209
210        $state = $data[PluginUtility::STATE];
211        if ($state !== DOKU_LEXER_ENTER) {
212            return;
213        }
214        /**
215         * Only outline heading metadata
216         * Not component heading
217         */
218        $context = $data[PluginUtility::CONTEXT];
219        if ($context === self::TYPE_OUTLINE) {
220            $callStackArray = $data[PluginUtility::ATTRIBUTES];
221            $tagAttributes = TagAttributes::createFromCallStackArray($callStackArray);
222            $text = $tagAttributes->getValue(HeadingTag::HEADING_TEXT_ATTRIBUTE);
223            $level = $tagAttributes->getValue(HeadingTag::LEVEL);
224            $renderer->header($text, $level, 0);
225        }
226
227    }
228
229    /**
230     * @param string $context
231     * @param TagAttributes $tagAttributes
232     * @param Doku_Renderer_xhtml $renderer
233     * @param int|null $pos - null if the call was generated
234     * @return void
235     */
236    public static function processRenderEnterXhtml(string $context, TagAttributes $tagAttributes, Doku_Renderer_xhtml &$renderer, ?int $pos)
237    {
238
239        /**
240         * All correction that are dependent
241         * on the markup (ie title or heading)
242         * are done in the {@link self::processRenderEnterXhtml()}
243         */
244
245        /**
246         * Variable
247         */
248        $type = $tagAttributes->getType();
249
250        /**
251         * Old syntax deprecated
252         */
253        if ($type === "0") {
254            if ($context === self::TYPE_OUTLINE) {
255                $type = 'h' . self::DEFAULT_LEVEL_OUTLINE_CONTEXT;
256            } else {
257                $type = 'h' . self::DEFAULT_LEVEL_TITLE_CONTEXT;
258            }
259        }
260        /**
261         * Label is for the TOC
262         */
263        $tagAttributes->removeAttributeIfPresent(self::PARSED_LABEL);
264
265
266        /**
267         * Level
268         */
269        $level = $tagAttributes->getValueAndRemove(HeadingTag::LEVEL);
270
271        /**
272         * Display Heading
273         * https://getbootstrap.com/docs/5.0/content/typography/#display-headings
274         */
275        if ($context !== self::TYPE_OUTLINE && $type === null) {
276            /**
277             * if not an outline, a display
278             */
279            $type = "h$level";
280        }
281        if (in_array($type, self::DISPLAY_TYPES)) {
282
283            $displayClass = "display-$level";
284
285            if (Bootstrap::getBootStrapMajorVersion() == Bootstrap::BootStrapFourMajorVersion) {
286                /**
287                 * Make Bootstrap display responsive
288                 */
289                PluginUtility::getSnippetManager()->attachCssInternalStyleSheet(HeadingTag::DISPLAY_BS_4_RESPONSIVE_SNIPPET_ID);
290
291                if (in_array($type, self::DISPLAY_TYPES_ONLY_BS_5)) {
292                    $displayClass = "display-4";
293                    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);
294                }
295
296            }
297            $tagAttributes->addClassName($displayClass);
298        }
299
300        /**
301         * Heading class
302         * https://getbootstrap.com/docs/5.0/content/typography/#headings
303         * Works on 4 and 5
304         */
305        if (in_array($type, self::HEADING_TYPES)) {
306            $tagAttributes->addClassName($type);
307        }
308
309        /**
310         * Card title Context class
311         * TODO: should move to card
312         */
313        if (in_array($context, [BlockquoteTag::TAG, CardTag::CARD_TAG])) {
314            $tagAttributes->addClassName("card-title");
315        }
316
317        $executionContext = ExecutionContext::getActualOrCreateFromEnv();
318
319        /**
320         * Add an outline class to be able to style them at once
321         *
322         * The context is by default the parent name or outline.
323         */
324        $snippetManager = SnippetSystem::getFromContext();
325        if ($context === self::TYPE_OUTLINE) {
326
327            $tagAttributes->addClassName(Outline::getOutlineHeadingClass());
328
329            $snippetManager->attachCssInternalStyleSheet(self::TYPE_OUTLINE);
330
331            // numbering
332            try {
333
334                $enable = $executionContext
335                    ->getConfig()
336                    ->getValue(Outline::CONF_OUTLINE_NUMBERING_ENABLE, Outline::CONF_OUTLINE_NUMBERING_ENABLE_DEFAULT);
337                if ($enable) {
338                    $snippet = $snippetManager->attachCssInternalStyleSheet(Outline::OUTLINE_HEADING_NUMBERING);
339                    if (!$snippet->hasInlineContent()) {
340                        $css = Outline::getCssNumberingRulesFor(Outline::OUTLINE_HEADING_NUMBERING);
341                        $snippet->setInlineContent($css);
342                    }
343                }
344            } catch (ExceptionBadSyntax $e) {
345                LogUtility::internalError("An error has occurred while trying to add the outline heading numbering stylesheet.", self::CANONICAL, $e);
346            } catch (ExceptionNotEnabled $e) {
347                // ok
348            }
349
350            /**
351             * Anchor on id
352             */
353            $snippetManager = PluginUtility::getSnippetManager();
354            try {
355                $snippetManager->attachRemoteJavascriptLibrary(
356                    Outline::OUTLINE_ANCHOR,
357                    "https://cdn.jsdelivr.net/npm/anchor-js@4.3.0/anchor.min.js",
358                    "sha256-LGOWMG4g6/zc0chji4hZP1d8RxR2bPvXMzl/7oPZqjs="
359                );
360            } catch (ExceptionBadArgument|ExceptionBadSyntax $e) {
361                // The url has a file name. this error should not happen
362                LogUtility::internalError("Unable to add anchor. Error:{$e->getMessage()}", Outline::OUTLINE_ANCHOR);
363            }
364            $snippetManager->attachJavascriptFromComponentId(Outline::OUTLINE_ANCHOR);
365
366        }
367        $snippetManager->attachCssInternalStyleSheet(HeadingTag::HEADING_TAG);
368
369        /**
370         * Not a HTML attribute
371         */
372        $tagAttributes->removeComponentAttributeIfPresent(self::HEADING_TEXT_ATTRIBUTE);
373
374        /**
375         * Printing
376         */
377        $tag = self::getTagFromContext($context, $level);
378        $renderer->doc .= $tagAttributes->toHtmlEnterTag($tag);
379
380    }
381
382    /**
383     * @param TagAttributes $tagAttributes
384     * @param string $context
385     * @return string
386     */
387    public
388    static function renderClosingTag(TagAttributes $tagAttributes, string $context): string
389    {
390        $level = $tagAttributes->getValueAndRemove(HeadingTag::LEVEL);
391        if ($level == null) {
392            LogUtility::msg("The level is mandatory when closing a heading", self::CANONICAL);
393        }
394        $tag = self::getTagFromContext($context, $level);
395
396        return "</$tag>";
397    }
398
399    /**
400     * Reduce the end of the input string
401     * to the first opening tag without the ">"
402     * and returns the closing tag
403     *
404     * @param $input
405     * @return array - the heading attributes as a string
406     */
407    public
408    static function reduceToFirstOpeningTagAndReturnAttributes(&$input)
409    {
410        // the variable that will capture the attribute string
411        $headingStartTagString = "";
412        // Set to true when the heading tag has completed
413        $endHeadingParsed = false;
414        // The closing character `>` indicator of the start and end tag
415        // true when found
416        $endTagClosingCharacterParsed = false;
417        $startTagClosingCharacterParsed = false;
418        // We start from the edn
419        $position = strlen($input) - 1;
420        while ($position > 0) {
421            $character = $input[$position];
422
423            if ($character == "<") {
424                if (!$endHeadingParsed) {
425                    // We are at the beginning of the ending tag
426                    $endHeadingParsed = true;
427                } else {
428                    // We have delete all character until the heading start tag
429                    // add the last one and exit
430                    $headingStartTagString = $character . $headingStartTagString;
431                    break;
432                }
433            }
434
435            if ($character == ">") {
436                if (!$endTagClosingCharacterParsed) {
437                    // We are at the beginning of the ending tag
438                    $endTagClosingCharacterParsed = true;
439                } else {
440                    // We have delete all character until the heading start tag
441                    $startTagClosingCharacterParsed = true;
442                }
443            }
444
445            if ($startTagClosingCharacterParsed) {
446                $headingStartTagString = $character . $headingStartTagString;
447            }
448
449
450            // position --
451            $position--;
452
453        }
454        $input = substr($input, 0, $position);
455
456        if (!empty($headingStartTagString)) {
457            return PluginUtility::getTagAttributes($headingStartTagString);
458        } else {
459            LogUtility::msg("The attributes of the heading are empty and this should not be possible");
460            return [];
461        }
462
463
464    }
465
466    public
467    static function handleEnter(\Doku_Handler $handler, TagAttributes $tagAttributes, string $markupTag): array
468    {
469        /**
470         * Context determination
471         */
472        $callStack = CallStack::createFromHandler($handler);
473        $context = HeadingTag::getContext($callStack);
474
475        /**
476         * Level is mandatory (for the closing tag)
477         */
478        $level = $tagAttributes->getValue(HeadingTag::LEVEL);
479        if ($level === null) {
480
481            /**
482             * Old title type
483             * from 1 to 4 to set the display heading
484             */
485            $type = $tagAttributes->getType();
486            if (is_numeric($type) && $type != 0) {
487                $level = $type;
488                if ($markupTag === self::TITLE_TAG) {
489                    $type = "d$level";
490                } else {
491                    $type = "h$level";
492                }
493                $tagAttributes->setType($type);
494            }
495            /**
496             * Still null, check the type
497             */
498            if ($level == null) {
499                if (in_array($type, HeadingTag::getAllTypes())) {
500                    $level = substr($type, 1);
501                }
502            }
503            /**
504             * Still null, default level
505             */
506            if ($level == null) {
507                if ($context === HeadingTag::TYPE_OUTLINE) {
508                    $level = HeadingTag::DEFAULT_LEVEL_OUTLINE_CONTEXT;
509                } else {
510                    $level = HeadingTag::DEFAULT_LEVEL_TITLE_CONTEXT;
511                }
512            }
513            /**
514             * Set the level
515             */
516            $tagAttributes->addComponentAttributeValue(HeadingTag::LEVEL, $level);
517        }
518        return [PluginUtility::CONTEXT => $context];
519    }
520
521    public
522    static function getAllTypes(): array
523    {
524        return array_merge(
525            self::DISPLAY_TYPES,
526            self::HEADING_TYPES,
527            self::SHORT_TYPES,
528            self::TITLE_DISPLAY_TYPES
529        );
530    }
531
532    /**
533     * @param string $context
534     * @param int $level
535     * @return string
536     */
537    private static function getTagFromContext(string $context, int $level): string
538    {
539        if ($context === self::TYPE_OUTLINE) {
540            return "h$level";
541        } else {
542            return "div";
543        }
544    }
545
546
547}
548