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