xref: /plugin/combo/syntax/webcode.php (revision e55e4e71208cf11ca8c608d736f6c1a20fb4c5c5)
1<?php
2/**
3 * Copyright (c) 2021. ComboStrap, Inc. and its affiliates. All Rights Reserved.
4 *
5 * This source code is licensed under the GPL license found in the
6 * COPYING  file in the root directory of this source tree.
7 *
8 * @license  GPL 3 (https://www.gnu.org/licenses/gpl-3.0.en.html)
9 * @author   ComboStrap <support@combostrap.com>
10 *
11 */
12
13/**
14 * Plugin Webcode: Show webcode (Css, HTML) in a iframe
15 *
16 */
17
18// must be run within Dokuwiki
19use ComboStrap\CallStack;
20use ComboStrap\LogUtility;
21use ComboStrap\PluginUtility;
22use ComboStrap\Site;
23use ComboStrap\TagAttributes;
24
25if (!defined('DOKU_INC')) die();
26
27/**
28 * Webcode
29 */
30class syntax_plugin_combo_webcode extends DokuWiki_Syntax_Plugin
31{
32
33    const EXTERNAL_RESOURCES_ATTRIBUTE_DISPLAY = 'externalResources'; // In the action bar
34    const EXTERNAL_RESOURCES_ATTRIBUTE_KEY = 'externalresources'; // In the code
35
36    // Simple cache bursting implementation for the webCodeConsole.(js|css) file
37    // They must be incremented manually when they changed
38    const WEB_CSS_VERSION = 1.1;
39    const WEB_CONSOLE_JS_VERSION = 2.1;
40
41    const TAG = 'webcode';
42
43    /**
44     * The tag that have codes
45     */
46    const CODE_TAGS =
47        array(
48            syntax_plugin_combo_code::CODE_TAG,
49            "plugin_combo_code",
50            syntax_plugin_combo_codemarkdown::TAG
51        );
52
53    /**
54     * The attribute names in the array
55     */
56    const CODES_ATTRIBUTE = "codes";
57    const USE_CONSOLE_ATTRIBUTE = "useConsole";
58    const RENDERING_MODE_ATTRIBUTE = 'renderingmode';
59    const RENDERING_ONLY_RESULT = "onlyresult";
60
61    /**
62     * Marki code
63     */
64    const MARKI_LANG = 'marki';
65    const DOKUWIKI_LANG = 'dw';
66    const MARKIS = [self::MARKI_LANG, self::DOKUWIKI_LANG];
67
68    /**
69     * Syntax Type.
70     *
71     * Needs to return one of the mode types defined in $PARSER_MODES in parser.php
72     * @see https://www.dokuwiki.org/devel:syntax_plugins#syntax_types
73     *
74     * container because it may contain header in case of how to
75     */
76    public function getType()
77    {
78        return 'container';
79    }
80
81    public function getPType()
82    {
83        return "stack";
84    }
85
86
87    /**
88     * @return array
89     * Allow which kind of plugin inside
90     *
91     * array('container', 'baseonly','formatting', 'substition', 'protected', 'disabled', 'paragraphs')
92     *
93     */
94    public function getAllowedTypes()
95    {
96        return array('container', 'baseonly', 'formatting', 'substition', 'protected', 'disabled', 'paragraphs');
97    }
98
99
100    public function accepts($mode)
101    {
102
103        return syntax_plugin_combo_preformatted::disablePreformatted($mode);
104
105    }
106
107    /**
108     * @see Doku_Parser_Mode::getSort()
109     * The mode (plugin) with the lowest sort number will win out
110     *
111     * See {@link Doku_Parser_Mode_code}
112     */
113    public function getSort()
114    {
115        return 99;
116    }
117
118    /**
119     * Called before any calls to ConnectTo
120     * @return void
121     */
122    function preConnect()
123    {
124    }
125
126    /**
127     * Create a pattern that will called this plugin
128     *
129     * @param string $mode
130     *
131     * All dokuwiki mode can be seen in the parser.php file
132     * @see Doku_Parser_Mode::connectTo()
133     */
134    public function connectTo($mode)
135    {
136
137        $pattern = PluginUtility::getContainerTagPattern(self::TAG);
138        $this->Lexer->addEntryPattern($pattern, $mode, PluginUtility::getModeForComponent($this->getPluginComponent()));
139
140    }
141
142
143    // This where the addPattern and addExitPattern are defined
144    public function postConnect()
145    {
146        $this->Lexer->addExitPattern('</' . self::TAG . '>', PluginUtility::getModeForComponent($this->getPluginComponent()));
147    }
148
149
150    /**
151     * Handle the match
152     * You get the match for each pattern in the $match variable
153     * $state says if it's an entry, exit or match pattern
154     *
155     * This is an instruction block and is cached apart from the rendering output
156     * There is two caches levels
157     * This cache may be suppressed with the url parameters ?purge=true
158     *
159     * The returned values are cached in an array that will be passed to the render method
160     * The handle function goal is to parse the matched syntax through the pattern function
161     * and to return the result for use in the renderer
162     * This result is always cached until the page is modified.
163     * @param string $match
164     * @param int $state
165     * @param int $pos
166     * @param Doku_Handler $handler
167     * @return array|bool
168     * @throws Exception
169     * @see DokuWiki_Syntax_Plugin::handle()
170     *
171     */
172    public function handle($match, $state, $pos, Doku_Handler $handler)
173    {
174        switch ($state) {
175
176            case DOKU_LEXER_ENTER :
177
178                // Default
179                $defaultAttributes = array();
180                $defaultAttributes['frameborder'] = 1;
181                $defaultAttributes['width'] = '100%';
182                $defaultAttributes['name'] = "WebCode iFrame";
183                $defaultAttributes[self::RENDERING_MODE_ATTRIBUTE] = 'story';
184                // 'height' is set by the javascript if not set
185                // 'width' and 'scrolling' gets their natural value
186
187                // Parse and create the call stack array
188                $tagAttributes = TagAttributes::createFromTagMatch($match, $defaultAttributes);
189                $callStackArray = $tagAttributes->toCallStackArray();
190
191                return array(
192                    PluginUtility::STATE => $state,
193                    PluginUtility::ATTRIBUTES => $callStackArray
194                );
195
196
197            case DOKU_LEXER_UNMATCHED :
198
199                return PluginUtility::handleAndReturnUnmatchedData(self::TAG, $match, $handler);
200
201
202            case DOKU_LEXER_EXIT:
203
204                /**
205                 * Capture all codes
206                 */
207                $codes = array();
208                /**
209                 * Does the javascript contains a console statement
210                 */
211                $useConsole = false;
212
213                /**
214                 * Callstack
215                 */
216                $callStack = CallStack::createFromHandler($handler);
217                $openingTag = $callStack->moveToPreviousCorrespondingOpeningCall();
218                $renderingMode = strtolower($openingTag->getAttribute(self::RENDERING_MODE_ATTRIBUTE));
219
220                /**
221                 * The mime (ie xml,html, ...) and code content are in two differents
222                 * call. To be able to set the content to the good type
223                 * we keep a trace of it
224                 */
225                $actualCodeType = "";
226
227                /**
228                 * Loop
229                 */
230                while ($actualTag = $callStack->next()) {
231
232
233                    $tagName = $actualTag->getTagName();
234                    if (in_array($tagName, self::CODE_TAGS)) {
235
236                        /**
237                         * Only rendering mode, we don't display the node
238                         * on all node (enter, exit and unmatched)
239                         */
240                        if ($renderingMode == self::RENDERING_ONLY_RESULT) {
241                            $actualTag->addAttribute(TagAttributes::DISPLAY, "none");
242                        }
243
244                        switch ($actualTag->getState()) {
245
246                            case DOKU_LEXER_ENTER:
247                                // Get the code (The content between the code nodes)
248                                // We ltrim because the match gives us the \n at the beginning and at the end
249                                $actualCodeType = strtolower(trim($actualTag->getType()));
250
251                                // Xml is html
252                                if ($actualCodeType == 'xml') {
253                                    $actualCodeType = 'html';
254                                }
255
256                                // markdown, dokuwiki is marki
257                                if (in_array($actualCodeType, ['md', 'markdown', 'dw'])) {
258                                    $actualCodeType = self::MARKI_LANG;
259                                }
260
261                                // The code for a language may be scattered in multiple block
262                                if (!isset($codes[$actualCodeType])) {
263                                    $codes[$actualCodeType] = "";
264                                }
265
266                                continue 2;
267
268                            case DOKU_LEXER_UNMATCHED:
269
270                                $codeContent = $actualTag->getPluginData()[PluginUtility::PAYLOAD];
271
272                                if (empty($actualCodeType)) {
273                                    LogUtility::msg("The type of the code should not be null for the code content " . $codeContent, LogUtility::LVL_MSG_WARNING, self::TAG);
274                                    continue 2;
275                                }
276
277                                // Append it
278                                $codes[$actualCodeType] = $codes[$actualCodeType] . $codeContent;
279
280                                // Check if a javascript console function is used, only if the flag is not set to true
281                                if (!$useConsole == true) {
282                                    if (in_array($actualCodeType, array('babel', 'javascript', 'html', 'xml'))) {
283                                        // if the code contains 'console.'
284                                        $result = preg_match('/' . 'console\.' . '/is', $codeContent);
285                                        if ($result) {
286                                            $useConsole = true;
287                                        }
288                                    }
289                                }
290                                // Reset
291                                $actualCodeType = "";
292                                break;
293
294                        }
295                    }
296
297                }
298
299                return array(
300                    PluginUtility::STATE => $state,
301                    self::CODES_ATTRIBUTE => $codes,
302                    self::USE_CONSOLE_ATTRIBUTE => $useConsole,
303                    PluginUtility::ATTRIBUTES => $openingTag->getAttributes()
304                );
305
306        }
307        return false;
308
309    }
310
311    /**
312     * Render the output
313     * @param string $mode
314     * @param Doku_Renderer $renderer
315     * @param array $data - what the function handle() return'ed
316     * @return bool - rendered correctly (not used)
317     *
318     * The rendering process
319     * @see DokuWiki_Syntax_Plugin::render()
320     *
321     */
322    public function render($mode, Doku_Renderer $renderer, $data)
323    {
324        // The $data variable comes from the handle() function
325        //
326        // $mode = 'xhtml' means that we output html
327        // There is other mode such as metadata where you can output data for the headers (Not 100% sure)
328        if ($mode == 'xhtml') {
329
330
331            /** @var Doku_Renderer_xhtml $renderer */
332
333            $state = $data[PluginUtility::STATE];
334            switch ($state) {
335
336
337                case DOKU_LEXER_UNMATCHED :
338
339                    $renderer->doc .= PluginUtility::renderUnmatched($data);
340                    break;
341
342                case DOKU_LEXER_EXIT :
343                    $codes = $data[self::CODES_ATTRIBUTE];
344                    $callStackArray = $data[PluginUtility::ATTRIBUTES];
345                    $iFrameAttributes = TagAttributes::createFromCallStackArray($callStackArray, self::TAG);
346
347                    // Create the real output of webcode
348                    if (sizeof($codes) == 0) {
349                        return false;
350                    }
351
352                    // Credits bar
353                    $bar = '<div class="webcode-bar">';
354
355
356                    // Dokuwiki Code ?
357                    if (array_key_exists(self::MARKI_LANG, $codes)) {
358
359                        $markiCode = $codes[self::MARKI_LANG];
360                        /**
361                         * By default, markup code
362                         * is rendered inside the page
363                         * We got less problem such as iframe overflow
364                         * due to lazy loading, such as relative link, ...
365                         *
366                         */
367                        if (!$iFrameAttributes->hasComponentAttribute("iframe")) {
368                            $renderer->doc .= PluginUtility::render($markiCode);
369                            return true;
370                        }
371
372                        $queryParams = array(
373                            'call' => action_plugin_combo_webcode::CALL_ID,
374                            action_plugin_combo_webcode::MARKI_PARAM => $markiCode
375                        );
376                        $queryString = http_build_query($queryParams);
377                        $url = Site::getAjaxUrl() . "?$queryString";
378                        $iFrameAttributes->addHtmlAttributeValue("src", $url);
379
380                    } else {
381
382
383                        // Js, Html, Css
384                        $iframeSrcValue = '<html><head>';
385                        $iframeSrcValue .= '<meta http-equiv="content-type" content="text/html; charset=UTF-8"/>';
386                        $iframeSrcValue .= '<title>Made by WebCode</title>';
387                        $iframeSrcValue .= '<link rel="stylesheet" type="text/css" href="https://cdnjs.cloudflare.com/ajax/libs/normalize/3.0.3/normalize.min.css"/>';
388
389
390                        // External Resources such as css stylesheet or js
391                        $externalResources = [];
392                        if ($iFrameAttributes->hasComponentAttribute(self::EXTERNAL_RESOURCES_ATTRIBUTE_KEY)) {
393                            $resources = $iFrameAttributes->getValueAndRemove(self::EXTERNAL_RESOURCES_ATTRIBUTE_KEY);
394                            $externalResources = explode(",", $resources);
395                        }
396
397                        // Babel Preprocessor, if babel is used, add it to the external resources
398                        if (array_key_exists('babel', $codes)) {
399                            $babelMin = "https://unpkg.com/babel-standalone@6/babel.min.js";
400                            // a load of babel invoke it (be sure to not have it twice
401                            if (!(array_key_exists($babelMin, $externalResources))) {
402                                $externalResources[] = $babelMin;
403                            }
404                        }
405
406                        // Add the external resources
407                        foreach ($externalResources as $externalResource) {
408                            $pathInfo = pathinfo($externalResource);
409                            $fileExtension = $pathInfo['extension'];
410                            switch ($fileExtension) {
411                                case 'css':
412                                    $iframeSrcValue .= '<link rel="stylesheet" type="text/css" href="' . $externalResource . '"/>';
413                                    break;
414                                case 'js':
415                                    $iframeSrcValue .= '<script type="text/javascript" src="' . $externalResource . '"></script>';
416                                    break;
417                            }
418                        }
419
420
421                        // WebConsole style sheet
422                        $iframeSrcValue .= '<link rel="stylesheet" type="text/css" href="' . PluginUtility::getResourceBaseUrl() . '/webcode/webcode-iframe.css?ver=' . self::WEB_CSS_VERSION . '"/>';
423
424                        // A little margin to make it neater
425                        // that can be overwritten via cascade
426                        $iframeSrcValue .= '<style>body { margin:10px } /* default margin */</style>';
427
428                        // The css
429                        if (array_key_exists('css', $codes)) {
430                            $iframeSrcValue .= '<!-- The CSS code -->';
431                            $iframeSrcValue .= '<style>' . $codes['css'] . '</style>';
432                        };
433                        $iframeSrcValue .= '</head><body>';
434                        if (array_key_exists('html', $codes)) {
435                            $iframeSrcValue .= '<!-- The HTML code -->';
436                            $iframeSrcValue .= $codes['html'];
437                        }
438                        // The javascript console area is based at the end of the HTML document
439                        $useConsole = $data[self::USE_CONSOLE_ATTRIBUTE];
440                        if ($useConsole) {
441                            $iframeSrcValue .= '<!-- WebCode Console -->';
442                            $iframeSrcValue .= '<div><p class="webConsoleTitle">Console Output:</p>';
443                            $iframeSrcValue .= '<div id="webCodeConsole"></div>';
444                            $iframeSrcValue .= '<script type="text/javascript" src="' . PluginUtility::getResourceBaseUrl() . '/webcode/webcode-console.js?ver=' . self::WEB_CONSOLE_JS_VERSION . '"></script>';
445                            $iframeSrcValue .= '</div>';
446                        }
447                        // The javascript comes at the end because it may want to be applied on previous HTML element
448                        // as the page load in the IO order, javascript must be placed at the end
449                        if (array_key_exists('javascript', $codes)) {
450                            $iframeSrcValue .= '<!-- The Javascript code -->';
451                            $iframeSrcValue .= '<script type="text/javascript">' . $codes['javascript'] . '</script>';
452                        }
453                        if (array_key_exists('babel', $codes)) {
454                            $iframeSrcValue .= '<!-- The Babel code -->';
455                            $iframeSrcValue .= '<script type="text/babel">' . $codes['babel'] . '</script>';
456                        }
457                        $iframeSrcValue .= '</body></html>';
458                        $iFrameAttributes->addHtmlAttributeValue("srcdoc", $iframeSrcValue);
459
460                        // Code bar with button
461                        $bar .= '<div class="webcode-bar-item">' . PluginUtility::getUrl(self::TAG, "Rendered by WebCode", false) . '</div>';
462                        $bar .= '<div class="webcode-bar-item">' . $this->addJsFiddleButton($codes, $externalResources, $useConsole, $iFrameAttributes->getValue("name")) . '</div>';
463
464
465                    }
466
467                    /**
468                     * If there is no height
469                     */
470                    if (!$iFrameAttributes->hasComponentAttribute(TagAttributes::HEIGHT_KEY)) {
471
472                        /**
473                         * Adjust the height attribute
474                         * of the iframe element
475                         * Any styling attribute would take over
476                         */
477                        PluginUtility::getSnippetManager()->attachJavascriptSnippetForBar(self::TAG);
478                        /**
479                         * CSS Height auto works when an image is loaded asynchronously but not
480                         * when there is only text in the iframe
481                         */
482                        //$iFrameAttributes->addStyleDeclaration("height", "auto");
483                        /**
484                         * Due to margin at the bottom with css height=auto,
485                         * we may see a scroll bar
486                         * This block of code is to avoid scrolling,
487                         * then scrolling = no if not set
488                         */
489                        if (!$iFrameAttributes->hasComponentAttribute("scrolling")) {
490                            $iFrameAttributes->addHtmlAttributeValue("scrolling", "no");
491                        }
492
493                    }
494
495
496                    PluginUtility::getSnippetManager()->attachCssSnippetForBar(self::TAG);
497
498                    /**
499                     * The iframe does not have any width
500                     * By default, we set it to 100% and it can be
501                     * constraint with the `width` attributes that will
502                     * set a a max-width
503                     */
504                    $iFrameAttributes->addStyleDeclaration("width","100%");
505
506                    $iFrameHtml = $iFrameAttributes->toHtmlEnterTag("iframe") . '</iframe>';
507                    $bar .= '</div>'; // close the bar
508                    $renderer->doc .= "<div class=\"webcode-wrapper\">" . $iFrameHtml . $bar . '</div>';
509
510
511                    break;
512            }
513
514            return true;
515        }
516        return false;
517    }
518
519    /**
520     * @param array $codes the array containing the codes
521     * @param array $externalResources the attributes of a call (for now the externalResources)
522     * @param bool $useConsole
523     * @param string $snippetTitle
524     * @return string the HTML form code
525     *
526     * Specification, see http://doc.jsfiddle.net/api/post.html
527     */
528    public function addJsFiddleButton($codes, $externalResources, $useConsole = false, $snippetTitle = null)
529    {
530
531        $postURL = "https://jsfiddle.net/api/post/library/pure/"; //No Framework
532
533
534        if ($useConsole) {
535            // If their is a console.log function, add the Firebug Lite support of JsFiddle
536            // Seems to work only with the Edge version of jQuery
537            // $postURL .= "edge/dependencies/Lite/";
538            // The firebug logging is not working anymore because of 404
539            // Adding them here
540            $externalResources[] = 'The firebug resources for the console.log features';
541            $externalResources[] = PluginUtility::getResourceBaseUrl() . '/firebug/firebug-lite.css';
542            $externalResources[] = PluginUtility::getResourceBaseUrl() . '/firebug/firebug-lite-1.2.js';
543        }
544
545        // The below code is to prevent this JsFiddle bug: https://github.com/jsfiddle/jsfiddle-issues/issues/726
546        // The order of the resources is not guaranteed
547        // We pass then the resources only if their is one resources
548        // Otherwise we pass them as a script element in the HTML.
549        if (count($externalResources) <= 1) {
550            $externalResourcesInput = '<input type="hidden" name="resources" value="' . implode(",", $externalResources) . '">';
551        } else {
552            $codes['html'] .= "\n\n\n\n\n<!-- The resources -->\n";
553            $codes['html'] .= "<!-- They have been added here because their order is not guarantee through the API. -->\n";
554            $codes['html'] .= "<!-- See: https://github.com/jsfiddle/jsfiddle-issues/issues/726 -->\n";
555            foreach ($externalResources as $externalResource) {
556                if ($externalResource != "") {
557                    $extension = pathinfo($externalResource)['extension'];
558                    switch ($extension) {
559                        case "css":
560                            $codes['html'] .= "<link href=\"" . $externalResource . "\" rel=\"stylesheet\">\n";
561                            break;
562                        case "js":
563                            $codes['html'] .= "<script src=\"" . $externalResource . "\"></script>\n";
564                            break;
565                        default:
566                            $codes['html'] .= "<!-- " . $externalResource . " -->\n";
567                    }
568                }
569            }
570        }
571
572        $jsCode = $codes['javascript'];
573        $jsPanel = 0; // language for the js specific panel (0 = JavaScript)
574        if (array_key_exists('babel', $codes)) {
575            $jsCode = $codes['babel'];
576            $jsPanel = 3; // 3 = Babel
577        }
578
579        // Title and description
580        global $ID;
581        $pageTitle = tpl_pagetitle($ID, true);
582        if (!$snippetTitle) {
583
584            $snippetTitle = "Code from " . $pageTitle;
585        }
586        $description = "Code from the page '" . $pageTitle . "' \n" . wl($ID, $absolute = true);
587        return '<form  method="post" action="' . $postURL . '" target="_blank">' .
588            '<input type="hidden" name="title" value="' . htmlentities($snippetTitle) . '">' .
589            '<input type="hidden" name="description" value="' . htmlentities($description) . '">' .
590            '<input type="hidden" name="css" value="' . htmlentities($codes['css']) . '">' .
591            '<input type="hidden" name="html" value="' . htmlentities("<!-- The HTML -->" . $codes['html']) . '">' .
592            '<input type="hidden" name="js" value="' . htmlentities($jsCode) . '">' .
593            '<input type="hidden" name="panel_js" value="' . htmlentities($jsPanel) . '">' .
594            '<input type="hidden" name="wrap" value="b">' .  //javascript no wrap in body
595            $externalResourcesInput .
596            '<button>Try the code</button>' .
597            '</form>';
598
599    }
600
601    /**
602     * @param $codes the array containing the codes
603     * @param $attributes the attributes of a call (for now the externalResources)
604     * @return string the HTML form code
605     */
606    public function addCodePenButton($codes, $attributes)
607    {
608        // TODO
609        // http://blog.codepen.io/documentation/api/prefill/
610    }
611
612
613}
614