diff --git a/Scribunto.i18n.php b/Scribunto.i18n.php index bb8dde00..ac7fca2e 100644 --- a/Scribunto.i18n.php +++ b/Scribunto.i18n.php @@ -22,6 +22,18 @@ $messages['en'] = array( 'scribunto-error-long' => 'Script errors: $1', + + 'scribunto-console-intro' => '* The module exports are available as the variable "p", including unsaved modifications. +* Precede a line with "=" to evaluate it as an expression, or use print(). +* Use mw.log() in module code to send messages to this console.', + 'scribunto-console-title' => 'Debug console', + 'scribunto-console-too-large' => 'This console session is too large. Please clear the console history or reduce the size of the module.', + 'scribunto-console-current-src' => 'console input', + 'scribunto-console-previous-src' => 'previous console input', + 'scribunto-console-clear' => 'Clear', + 'scribunto-console-cleared' => 'The console state was cleared because the module was updated.', + + 'scribunto-common-nosuchmodule' => 'Script error: No such module.', 'scribunto-common-nofunction' => 'Script error: You must specify a function to call.', 'scribunto-common-nosuchfunction' => 'Script error: The function you specified did not exist.', diff --git a/Scribunto.php b/Scribunto.php index 124f5327..cc0bbb7f 100644 --- a/Scribunto.php +++ b/Scribunto.php @@ -41,6 +41,7 @@ $wgAutoloadClasses['ScribuntoModuleBase'] = $dir.'common/Base.php'; $wgAutoloadClasses['ScribuntoHooks'] = $dir.'common/Hooks.php'; $wgAutoloadClasses['ScribuntoException'] = $dir.'common/Common.php'; $wgAutoloadClasses['Scribunto'] = $dir.'common/Common.php'; +$wgAutoloadClasses['ApiScribuntoConsole'] = $dir.'common/ApiScribuntoConsole.php'; $wgHooks['ParserFirstCallInit'][] = 'ScribuntoHooks::setupParserHook'; $wgHooks['ParserLimitReport'][] = 'ScribuntoHooks::reportLimits'; @@ -51,6 +52,7 @@ $wgHooks['ArticleViewCustom'][] = 'ScribuntoHooks::handleScriptView'; $wgHooks['TitleIsWikitextPage'][] = 'ScribuntoHooks::isWikitextPage'; $wgHooks['CodeEditorGetPageLanguage'][] = 'ScribuntoHooks::getCodeLanguage'; $wgHooks['EditPageBeforeEditChecks'][] = 'ScribuntoHooks::beforeEditChecks'; +$wgHooks['EditPageBeforeEditButtons'][] = 'ScribuntoHooks::beforeEditButtons'; $wgHooks['EditFilterMerged'][] = 'ScribuntoHooks::validateScript'; $wgHooks['UnitTestsList'][] = 'ScribuntoHooks::unitTestsList'; @@ -58,15 +60,30 @@ $wgParserTestFiles[] = $dir . 'tests/engines/LuaCommon/luaParserTests.txt'; $wgParserOutputHooks['ScribuntoError'] = 'ScribuntoHooks::parserOutputHook'; -$wgResourceModules['ext.scribunto'] = array( +$sbtpl = array( 'localBasePath' => dirname( __FILE__ ) . '/modules', 'remoteExtPath' => 'Scribunto/modules', +); + +$wgResourceModules['ext.scribunto'] = $sbtpl + array( 'scripts' => 'ext.scribunto.js', 'dependencies' => array( 'jquery.ui.dialog' ), 'messages' => array( 'scribunto-parser-dialog-title' ), ); +$wgResourceModules['ext.scribunto.edit'] = $sbtpl + array( + 'scripts' => 'ext.scribunto.edit.js', + 'styles' => 'ext.scribunto.edit.css', + 'dependencies' => array( 'ext.scribunto' ), + 'messages' => array( + 'scribunto-console-title', + 'scribunto-console-intro', + 'scribunto-console-clear', + 'scribunto-console-cleared', + ), +); +$wgAPIModules['scribunto-console'] = 'ApiScribuntoConsole'; /***** Individual engines and their configurations *****/ diff --git a/common/ApiScribuntoConsole.php b/common/ApiScribuntoConsole.php new file mode 100644 index 00000000..aa21fbe2 --- /dev/null +++ b/common/ApiScribuntoConsole.php @@ -0,0 +1,153 @@ +extractRequestParams(); + + $title = Title::newFromText( $params['title'] ); + if ( !$title ) { + $this->dieUsageMsg( array( 'invalidtitle', $params['title'] ) ); + } + + if ( $params['session'] ) { + $sessionId = $params['session']; + } else { + $sessionId = mt_rand( 0, 0x7fffffff ); + } + + global $wgUser; + $sessionKey = wfMemcKey( 'scribunto-console', $wgUser->getId(), $sessionId ); + $cache = ObjectCache::getInstance( CACHE_ANYTHING ); + $session = null; + if ( $params['session'] ) { + $session = $cache->get( $sessionKey ); + } + if ( !isset( $session['version'] ) ) { + $session = $this->newSession(); + } + + // Create a variable holding the session which will be stored if there + // are no errors. If there are errors, we don't want to store the current + // question to the state builder array, since that will cause subsequent + // requests to fail. + $newSession = $session; + + if ( !empty( $params['clear'] ) ) { + $newSession['size'] -= strlen( implode( '', $newSession['questions'] ) ); + $newSession['questions'] = array(); + $session['questions'] = array(); + } + if ( strlen( $params['question'] ) ) { + $newSession['size'] += strlen( $params['question'] ); + $newSession['questions'][] = $params['question']; + } + if ( $params['content'] ) { + $newSession['size'] += strlen( $params['content'] ) - strlen( $newSession['content'] ); + $newSession['content'] = $params['content']; + } + + if ( $newSession['size'] > self::SC_MAX_SIZE ) { + $this->dieUsage( wfMsg( 'scribunto-console-too-large' ), 'scribunto-console-too-large' ); + } + $result = $this->runConsole( array( + 'title' => $title, + 'content' => $newSession['content'], + 'prevQuestions' => $session['questions'], + 'question' => $params['question'] ) ); + + if ( $result['type'] === 'error' ) { + // Restore the questions array + $newSession['questions'] = $session['questions']; + } + $cache->set( $sessionKey, $newSession, self::SC_SESSION_EXPIRY ); + $result['session'] = $sessionId; + $result['sessionSize'] = $newSession['size']; + $result['sessionMaxSize'] = self::SC_MAX_SIZE; + foreach ( $result as $key => $value ) { + $this->getResult()->addValue( null, $key, $value ); + } + } + + protected function runConsole( $params ) { + global $wgParser; + $options = new ParserOptions; + $options->setTemplateCallback( array( $this, 'templateCallback' ) ); + $wgParser->startExternalParse( $params['title'], $options, Parser::OT_HTML, true ); + $engine = Scribunto::getParserEngine( $wgParser ); + try { + $result = $engine->runConsole( $params ); + } catch ( ScribuntoException $e ) { + $trace = $e->getScriptTraceHtml(); + $message = $e->getMessage(); + $html = Html::element( 'p', array(), $message ); + if ( $trace !== false ) { + $html .= Html::element( 'p', array(), wfMsgForContent( 'scribunto-common-backtrace' ) ) . $trace; + } + + return array( + 'type' => 'error', + 'html' => $html, + 'message' => $message, + 'messagename' => $e->getMessageName() ); + } + return array( + 'type' => 'normal', + 'print' => strval( $result['print'] ), + 'return' => strval( $result['return'] ) + ); + } + + protected function newSession() { + return array( + 'content' => '', + 'questions' => array(), + 'size' => 0, + 'version' => 1, + ); + } + + public function getAllowedParams() { + return array( + 'title' => array( + ApiBase::PARAM_TYPE => 'string', + ), + 'content' => array( + ApiBase::PARAM_TYPE => 'string' + ), + 'session' => array( + ApiBase::PARAM_TYPE => 'integer', + ), + 'question' => array( + ApiBase::PARAM_TYPE => 'string', + ApiBase::PARAM_REQUIRED => true, + ), + 'clear' => array( + ApiBase::PARAM_TYPE => 'boolean', + ), + ); + } + + public function getParamDescription() { + return array( + 'title' => 'The module title to test', + 'content' => 'The new content of the module', + 'question' => 'The next line to evaluate as a script', + 'clear' => 'Set this to true to clear the current session state', + ); + } + + public function getDescription() { + return 'Internal module for servicing XHR requests from the Scribunto console'; + } + + public function getVersion() { + return __CLASS__.': 1'; + } +} diff --git a/common/Base.php b/common/Base.php index 7712dd81..3310f627 100644 --- a/common/Base.php +++ b/common/Base.php @@ -38,6 +38,21 @@ abstract class ScribuntoEngineBase { */ abstract protected function newModule( $text, $chunkName ); + /** + * Run an interactive console request + * + * @param $params Associative array. Options are: + * - title: The title object for the module being debugged + * - content: The text content of the module + * - precedingQuestions: An array of previous "questions" used to establish the state + * - question: The current "question", a string script + * + * @return array containing: + * - print: The resulting print buffer + * - return: The resulting return value + */ + abstract function runConsole( $params ); + /** * Constructor. * diff --git a/common/Hooks.php b/common/Hooks.php index 65b6e09f..5ad6425f 100644 --- a/common/Hooks.php +++ b/common/Hooks.php @@ -213,6 +213,10 @@ class ScribuntoHooks { * @param $tabindex Current tabindex */ public static function beforeEditChecks( &$editor, &$checkboxes, &$tabindex ) { + if ( $editor->getTitle()->getNamespace() !== NS_MODULE ) { + return true; + } + $req = RequestContext::getMain()->getRequest(); $name = 'scribunto_ignore_errors'; @@ -224,6 +228,26 @@ class ScribuntoHooks { Xml::check( $name, $req->getCheck( $name ), $attribs ) . ' ' . Xml::label( wfMsg( 'scribunto-ignore-errors' ), "mw-$name" ); + + // While we're here, lets set up the edit module + global $wgOut; + $wgOut->addModules( 'ext.scribunto.edit' ); + $editor->editFormTextAfterTools = '
'; + return true; + } + + /** + * EditPageBeforeEditButtons hook + * @param $editor EditPage + * @param $buttons Button array + * @param $tabindex Current tabindex + */ + public static function beforeEditButtons( &$editor, &$buttons, &$tabindex ) { + if ( $editor->getTitle()->getNamespace() !== NS_MODULE ) { + return true; + } + + unset( $buttons['preview'] ); return true; } diff --git a/engines/LuaCommon/LuaCommon.php b/engines/LuaCommon/LuaCommon.php index cce7d771..cb4bee3a 100644 --- a/engines/LuaCommon/LuaCommon.php +++ b/engines/LuaCommon/LuaCommon.php @@ -107,6 +107,51 @@ abstract class Scribunto_LuaEngine extends ScribuntoEngineBase { return 'lua'; } + public function runConsole( $params ) { + /** + * TODO: provide some means for giving correct line numbers for errors + * in console input, and for producing an informative error message + * if there is an error in prevQuestions. + * + * Maybe each console line could be evaluated as a different chunk, + * apparently that's what lua.c does. + */ + $code = "return function (__init)\n" . + "local p = mw.executeModule(__init)\n" . + "local print = mw.log\n"; + foreach ( $params['prevQuestions'] as $q ) { + if ( substr( $q, 0, 1 ) === '=' ) { + $code .= "print(" . substr( $q, 1 ) . ")"; + } else { + $code .= $q; + } + $code .= "\n"; + } + $code .= "mw.clearLogBuffer()\n"; + if ( substr( $params['question'], 0, 1 ) === '=' ) { + // Treat a statement starting with "=" as a return statement, like in lua.c + $code .= "return tostring(" . substr( $params['question'], 1 ) . "), mw.getLogBuffer()\n"; + } else { + $code .= $params['question'] . "\n" . + "return nil, mw.getLogBuffer()\n"; + } + $code .= "end\n"; + + $contentModule = $this->newModule( + $params['content'], $params['title']->getPrefixedDBkey() ); + $contentInit = $contentModule->getInitChunk(); + + $consoleModule = $this->newModule( $code, wfMsg( 'scribunto-console-current-src' ) ); + $consoleInit = $consoleModule->getInitChunk(); + $ret = $this->getInterpreter()->callFunction( $consoleInit ); + $func = $ret[0]; + $ret = $this->getInterpreter()->callFunction( $func, $contentInit ); + return array( + 'return' => isset( $ret[0] ) ? $ret[0] : null, + 'print' => isset( $ret[1] ) ? $ret[1] : '', + ); + } + /** * Workalike for luaL_checktype() * @@ -376,7 +421,7 @@ class Scribunto_LuaModule extends ScribuntoModuleBase { } class Scribunto_LuaError extends ScribuntoException { - var $luaMessage; + var $luaMessage, $lineMap = array(); function __construct( $message, $options = array() ) { $this->luaMessage = $message; @@ -394,6 +439,10 @@ class Scribunto_LuaError extends ScribuntoException { return $this->luaMessage; } + function setLineMap( $map ) { + $this->lineMap = $map; + } + function getScriptTraceHtml( $options = array() ) { if ( !isset( $this->params['trace'] ) ) { return false; @@ -406,13 +455,17 @@ class Scribunto_LuaError extends ScribuntoException { $s = '
    '; foreach ( $this->params['trace'] as $info ) { - $src = htmlspecialchars( $info['short_src'] ); - if ( $info['currentline'] > 0 ) { - $src .= ':' . htmlspecialchars( $info['currentline'] ); + $short_src = $srcdefined = $info['short_src']; + $currentline = $info['currentline']; + $linedefined = $info['linedefined']; - $title = Title::newFromText( $info['short_src'] ); + $src = htmlspecialchars( $short_src ); + if ( $currentline > 0 ) { + $src .= ':' . htmlspecialchars( $currentline ); + + $title = Title::newFromText( $short_src ); if ( $title && $title->getNamespace() === NS_MODULE ) { - $title->setFragment( '#mw-ce-l' . $info['currentline'] ); + $title->setFragment( '#mw-ce-l' . $currentline ); $src = Html::rawElement( 'a', array( 'href' => $title->getFullURL( 'action=edit' ) ), $src ); @@ -427,7 +480,7 @@ class Scribunto_LuaError extends ScribuntoException { $function = '?'; } else { $function = wfMsgExt( 'scribunto-lua-in-function-at', - $msgOptions, $info['short_src'], $info['linedefined'] ); + $msgOptions, $srcdefined, $linedefined ); } $s .= "
  1. \n\t" . wfMsgExt( 'scribunto-lua-backtrace-line', $msgOptions, "$src", $function ) . diff --git a/engines/LuaCommon/lualib/mw.lua b/engines/LuaCommon/lualib/mw.lua index d61d29e5..698d9bc2 100644 --- a/engines/LuaCommon/lualib/mw.lua +++ b/engines/LuaCommon/lualib/mw.lua @@ -5,6 +5,7 @@ local packageModuleFunc local php local setupDone local allowEnvFuncs = false +local logBuffer = '' --- Put an isolation-friendly package module into the specified environment -- table. The package module will have an empty cache, because caching of @@ -382,4 +383,17 @@ function mw.executeFunction( chunk ) return table.concat( stringResults ) end +function mw.log( msg ) + logBuffer = logBuffer .. tostring( msg ) .. '\n' +end + +function mw.clearLogBuffer() + logBuffer = '' +end + +function mw.getLogBuffer() + return logBuffer +end + + return mw diff --git a/modules/ext.scribunto.edit.css b/modules/ext.scribunto.edit.css new file mode 100644 index 00000000..4d8d441d --- /dev/null +++ b/modules/ext.scribunto.edit.css @@ -0,0 +1,55 @@ + +.mw-scribunto-console-fieldset { + background: white; + color: black; +} + +/* Preserve line breaks, but wrap too if browser supports it */ +#mw-scribunto-output { + white-space: pre; + white-space: -moz-pre-wrap; +} + +#mw-scribunto-input { + width: 100%; + border: none; + padding: 0; + overflow: auto; + background: #e0e0e0; +} +.mw-scribunto-input { + color: blue; + font: inherit; + font-weight: bold; + margin-top: .5em; +} +.mw-scribunto-normalOutput { + color: black; + background: white; +} +.mw-scribunto-print { + color: #630; + background: white; +} +.mw-scribunto-error { + color: red; + background: white; +} +.mw-scribunto-propList { + color: green; + background: white; +} +.mw-scribunto-message { + color: green; + background: white; +} +.mw-scribunto-tabcomplete { + color: purple; + background: white; +} +.mw-scribunto-clear { + color: red; + text-align: center; + margin-top: 1em; + border-bottom: 1px solid red; +} diff --git a/modules/ext.scribunto.edit.js b/modules/ext.scribunto.edit.js new file mode 100644 index 00000000..13a8074b --- /dev/null +++ b/modules/ext.scribunto.edit.js @@ -0,0 +1,380 @@ +(function ( $, mw ) { + +/** + * Debug console + * Based on JavaScript Shell 1.4 by Jesse Ruderman (GPL/LGPL/MPL tri-license) + * + * TODO: + * * Refactor, more jQuery, etc. + * * Spinner? + * * A prompt in front of input lines and the textarea + * * Collapsible backtrace display + */ + +var + histList = [""], + histPos = 0, + question, + _in, + _out, + lastError = null, + sessionContent = null, + sessionKey = null, + pending = false, + clearNextRequest = false; + +function refocus() +{ + _in.blur(); // Needed for Mozilla to scroll correctly. + _in.focus(); +} + +function initConsole() +{ + _in = document.getElementById( "mw-scribunto-input" ); + _out = document.getElementById( "mw-scribunto-output" ); + + recalculateInputHeight(); + println( mw.msg( 'scribunto-console-intro' ), 'mw-scribunto-message' ); +} + +function inputKeydown( e ) { + // Use onkeydown because IE doesn't support onkeypress for arrow keys + + if ( e.shiftKey && e.keyCode == 13 ) { // shift-enter + // don't do anything; allow the shift-enter to insert a line break as normal + } else if ( e.keyCode == 13 ) { // enter + // execute the input on enter + go(); + } else if ( e.keyCode == 38 ) { // up + // go up in history if at top or ctrl-up + if ( e.ctrlKey || caretInFirstLine( _in ) ) + hist( 'up' ); + } else if ( e.keyCode == 40 ) { // down + // go down in history if at end or ctrl-down + if ( e.ctrlKey || caretInLastLine( _in ) ) + hist( 'down' ); + } else { } + + setTimeout( recalculateInputHeight, 0 ); + + //return true; +} + +function inputFocus( e ) { + if ( sessionContent === null ) { + // No previous state to clear + return; + } + + if ( clearNextRequest ) { + // User already knows + return; + } + + if ( getContent() !== sessionContent ) { + printClearBar(); + clearNextRequest = true; + } +} + +function caretInFirstLine( textbox ) +{ + // IE doesn't support selectionStart/selectionEnd + if ( textbox.selectionStart == undefined ) + return true; + + var firstLineBreak = textbox.value.indexOf( "\n" ); + + return ((firstLineBreak == -1) || (textbox.selectionStart <= firstLineBreak)); +} + +function caretInLastLine( textbox ) +{ + // IE doesn't support selectionStart/selectionEnd + if ( textbox.selectionEnd == undefined ) + return true; + + var lastLineBreak = textbox.value.lastIndexOf( "\n" ); + + return ( textbox.selectionEnd > lastLineBreak ); +} + +function recalculateInputHeight() +{ + var rows = _in.value.split( /\n/ ).length + + 1 // prevent scrollbar flickering in Mozilla + + ( window.opera ? 1 : 0 ); // leave room for scrollbar in Opera + + // without this check, it is impossible to select text in Opera 7.60 or Opera 8.0. + if ( _in.rows != rows ) + _in.rows = rows; +} + +function println( s, type ) +{ + if( ( s = String( s ) ) ) + { + var newdiv = document.createElement( "div" ); + newdiv.appendChild( document.createTextNode( s ) ); + newdiv.className = type; + _out.appendChild( newdiv ); + return newdiv; + } +} + +function printWithRunin( h, s, type ) +{ + var div = println( s, type ); + var head = document.createElement( "strong" ); + head.appendChild( document.createTextNode( h + ": " ) ); + div.insertBefore( head, div.firstChild ); +} + +function printClearBar() +{ + $( '
    ' ) + .attr( 'class', 'mw-scribunto-clear' ) + .text( mw.msg( 'scribunto-console-cleared' ) ) + .appendTo( _out ); +} + +function hist( direction ) +{ + // histList[0] = first command entered, [1] = second, etc. + // type something, press up --> thing typed is now in "limbo" + // (last item in histList) and should be reachable by pressing + // down again. + + var L = histList.length; + + if ( L == 1 ) + return; + + if ( direction === 'up' ) + { + if ( histPos == L-1 ) + { + // Save this entry in case the user hits the down key. + histList[histPos] = _in.value; + } + + if ( histPos > 0 ) + { + histPos--; + // Use a timeout to prevent up from moving cursor within new text + // Set to nothing first for the same reason + setTimeout( + function() { + _in.value = ''; + _in.value = histList[histPos]; + var caretPos = _in.value.length; + if (_in.setSelectionRange) + _in.setSelectionRange(caretPos, caretPos); + }, + 0 + ); + } + } + else // down + { + if ( histPos < L-1 ) + { + histPos++; + _in.value = histList[histPos]; + } + else if ( histPos == L-1 ) + { + // Already on the current entry: clear but save + if ( _in.value ) + { + histList[histPos] = _in.value; + ++histPos; + _in.value = ""; + } + } + } +} + +function printQuestion( q ) +{ + println( q, "mw-scribunto-input" ); +} + +function printError( er ) +{ + var lineNumberString; + + lastError = er; // for debugging the shell + if ( er.name ) + { + // lineNumberString should not be "", to avoid a very wacky bug in IE 6. + lineNumberString = (er.lineNumber != undefined) ? (" on line " + er.lineNumber + ": ") : ": "; + // Because IE doesn't have error.toString. + println( er.name + lineNumberString + er.message, "mw-scribunto-error" ); + } + else + println( er, "mw-scribunto-error" ); // Because security errors in Moz /only/ have toString. +} + +function setPending() { + pending = true; + _in.readOnly = true; +} + +function clearPending() { + pending = false; + _in.readOnly = false; +} + +function go() +{ + if ( pending ) { + // If there is an XHR request pending, don't send another one + // We set readOnly on the textarea to give a UI indication, this is + // just for paranoia. + return; + } + + question = _in.value; + + if ( question == "" ) + return; + + histList[histList.length-1] = question; + histList[histList.length] = ""; + histPos = histList.length - 1; + + // Unfortunately, this has to happen *before* the script is run, so that + // print() output will go in the right place. + _in.value = ''; + // can't preventDefault on input, so also clear it later + setTimeout( function() { _in.value = ""; }, 0 ); + + recalculateInputHeight(); + printQuestion(question); + + var params = { + action: 'scribunto-console', + title: mw.config.get( 'wgTitle' ), + question: question, + } + + var content = getContent(); + if ( !sessionKey || sessionContent !== content ) { + params.clear = true; + params.content = content; + } + if ( sessionKey ) { + params.session = sessionKey; + } + if ( clearNextRequest ) { + params.clear = true; + clearNextRequest = false; + } + + var api = new mw.Api(); + setPending(); + + api.post( params, { + ok: function( result ) { + sessionKey = result.session; + sessionContent = content; + if ( result.type === 'error' ) { + printError( result.message ); + } else { + if ( result.print !== '' ) { + println( result.print, 'mw-scribunto-print' ); + } + if ( result['return'] !== '' ) { + println( result['return'], "mw-scribunto-normalOutput" ); + } + } + clearPending(); + setTimeout( refocus, 0 ); + }, + + err: function( code, result ) { + if ( 'error' in result && 'info' in result.error ) { + printError( result.error.info ); + } else if ( 'exception' in result ) { + printError( result.exception.message ); + } else { + console.log( result ); + printError( 'error' ); + } + clearPending(); + setTimeout( refocus, 0 ); + }, + } ); +} + +function getContent() { + var $textarea = $( '#wpTextbox1' ); + var context = $textarea.data( 'wikiEditor-context' ); + if ( context == undefined || context.codeEditor == undefined ) { + return $textarea.val(); + } else { + return $textarea.textSelection( 'getContents' ); + } +} + +function onClearClick( e ) { + $( '#mw-scribunto-output' ).empty(); + clearNextRequest = true; + refocus(); +} + +mw.scribunto.edit = { + 'init': function () { + var action = mw.config.get( 'wgAction' ); + if ( action == 'edit' || action == 'submit' || action == 'editredlink' ) { + this.initEditPage(); + } + }, + + 'initEditPage': function() { + var console = document.getElementById( 'mw-scribunto-console' ); + if ( !console ) { + return; + } + + $( '
    ' ) + .attr( 'class', 'mw-scribunto-console-fieldset' ) + .append( $( '' ).text( mw.msg( 'scribunto-console-title' ) ) ) + .append( $( '
    ' ) ) + .append( + $( '
    ' ).append( + $( '