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