jenkins-bot has submitted this change and it was merged.

Change subject: Add Special:ApiSandbox
......................................................................


Add Special:ApiSandbox

Like Extension:ApiSandbox, but rewritten to use OOJS-UI and to add many
long-requested features.

Bug: T89386
Bug: T92893
Bug: T98457
Bug: T98083
Bug: T89229
Bug: T66008
Bug: T50607
Bug: T47811
Bug: T38875
Bug: T36962
Bug: T34740
Change-Id: Ic42a6c5ef54b811cd63cfef2132942b27a626fe5
Depends-On: I85c0eedcd31a0e419d8055eca0d9cb1ba872ae62
Depends-On: Ic85ff4abbbcd2076ebf5cdfaa0e95e98878e2308
---
M RELEASE-NOTES-1.27
M autoload.php
M includes/api/ApiFormatBase.php
M includes/api/ApiFormatJson.php
M includes/api/ApiFormatPhp.php
M includes/api/ApiFormatXml.php
M includes/api/ApiHelp.php
M includes/api/i18n/en.json
M includes/api/i18n/qqq.json
M includes/specialpage/SpecialPageFactory.php
A includes/specials/SpecialApiSandbox.php
M languages/i18n/en.json
M languages/i18n/qqq.json
M languages/messages/MessagesEn.php
M resources/Resources.php
A resources/src/mediawiki.special/mediawiki.special.apisandbox.css
A resources/src/mediawiki.special/mediawiki.special.apisandbox.js
A resources/src/mediawiki.special/mediawiki.special.apisandbox.top.css
M resources/src/mediawiki/mediawiki.apipretty.css
19 files changed, 2,010 insertions(+), 24 deletions(-)

Approvals:
  Bartosz Dziewoński: Looks good to me, approved
  jenkins-bot: Verified



diff --git a/RELEASE-NOTES-1.27 b/RELEASE-NOTES-1.27
index f4e4815..fbbacab 100644
--- a/RELEASE-NOTES-1.27
+++ b/RELEASE-NOTES-1.27
@@ -65,6 +65,8 @@
 * $wgAllowAsyncCopyUploads and $CopyUploadAsyncTimeout were removed. This was 
an
   experimental feature that has never worked.
 * $wgEnotifUseJobQ was removed and the job queue is always used.
+* The functionality of the ApiSandbox extension has been merged into core. The
+  extension should no longer be used.
 
 === New features in 1.27 ===
 * $wgDataCenterUpdateStickTTL was also added. This decides how long a user
diff --git a/autoload.php b/autoload.php
index cd12fc1..b055574 100644
--- a/autoload.php
+++ b/autoload.php
@@ -1153,6 +1153,7 @@
        'SpecialAllMyUploads' => __DIR__ . 
'/includes/specials/SpecialMyRedirectPages.php',
        'SpecialAllPages' => __DIR__ . '/includes/specials/SpecialAllPages.php',
        'SpecialApiHelp' => __DIR__ . '/includes/specials/SpecialApiHelp.php',
+       'SpecialApiSandbox' => __DIR__ . 
'/includes/specials/SpecialApiSandbox.php',
        'SpecialBlankpage' => __DIR__ . 
'/includes/specials/SpecialBlankpage.php',
        'SpecialBlock' => __DIR__ . '/includes/specials/SpecialBlock.php',
        'SpecialBlockList' => __DIR__ . 
'/includes/specials/SpecialBlockList.php',
diff --git a/includes/api/ApiFormatBase.php b/includes/api/ApiFormatBase.php
index be68310..69cedd7 100644
--- a/includes/api/ApiFormatBase.php
+++ b/includes/api/ApiFormatBase.php
@@ -32,6 +32,7 @@
 abstract class ApiFormatBase extends ApiBase {
        private $mIsHtml, $mFormat, $mUnescapeAmps, $mHelp;
        private $mBuffer, $mDisabled = false;
+       private $mIsWrappedHtml = false;
        protected $mForceDefaultParams = false;
 
        /**
@@ -45,6 +46,7 @@
                $this->mIsHtml = ( substr( $format, -2, 2 ) === 'fm' ); // ends 
with 'fm'
                if ( $this->mIsHtml ) {
                        $this->mFormat = substr( $format, 0, -2 ); // remove 
ending 'fm'
+                       $this->mIsWrappedHtml = $this->getMain()->getCheck( 
'wrappedhtml' );
                } else {
                        $this->mFormat = $format;
                }
@@ -77,6 +79,15 @@
         */
        public function getIsHtml() {
                return $this->mIsHtml;
+       }
+
+       /**
+        * Returns true when the special wrapped mode is enabled.
+        * @since 1.27
+        * @return bool
+        */
+       protected function getIsWrappedHtml() {
+               return $this->mIsWrappedHtml;
        }
 
        /**
@@ -145,7 +156,9 @@
                        return;
                }
 
-               $mime = $this->getIsHtml() ? 'text/html' : $this->getMimeType();
+               $mime = $this->getIsWrappedHtml()
+                       ? 'text/mediawiki-api-prettyprint-wrapped'
+                       : ( $this->getIsHtml() ? 'text/html' : 
$this->getMimeType() );
 
                // Some printers (ex. Feed) do their own header settings,
                // in which case $mime will be set to null
@@ -185,19 +198,21 @@
                        $out->addModuleStyles( 'mediawiki.apipretty' );
                        $out->setPageTitle( $context->msg( 'api-format-title' ) 
);
 
-                       // When the format without suffix 'fm' is defined, 
there is a non-html version
-                       if ( $this->getMain()->getModuleManager()->isDefined( 
$lcformat, 'format' ) ) {
-                               $msg = $context->msg( 
'api-format-prettyprint-header' )->params( $format, $lcformat );
-                       } else {
-                               $msg = $context->msg( 
'api-format-prettyprint-header-only-html' )->params( $format );
-                       }
+                       if ( !$this->getIsWrappedHtml() ) {
+                               // When the format without suffix 'fm' is 
defined, there is a non-html version
+                               if ( 
$this->getMain()->getModuleManager()->isDefined( $lcformat, 'format' ) ) {
+                                       $msg = $context->msg( 
'api-format-prettyprint-header' )->params( $format, $lcformat );
+                               } else {
+                                       $msg = $context->msg( 
'api-format-prettyprint-header-only-html' )->params( $format );
+                               }
 
-                       $header = $msg->parseAsBlock();
-                       $out->addHTML(
-                               Html::rawElement( 'div', array( 'class' => 
'api-pretty-header' ),
-                                       ApiHelp::fixHelpLinks( $header )
-                               )
-                       );
+                               $header = $msg->parseAsBlock();
+                               $out->addHTML(
+                                       Html::rawElement( 'div', array( 'class' 
=> 'api-pretty-header' ),
+                                               ApiHelp::fixHelpLinks( $header )
+                                       )
+                               );
+                       }
 
                        if ( Hooks::run( 'ApiFormatHighlight', array( $context, 
$result, $mime, $format ) ) ) {
                                $out->addHTML(
@@ -205,10 +220,38 @@
                                );
                        }
 
-                       // API handles its own clickjacking protection.
-                       // Note, that $wgBreakFrames will still override 
$wgApiFrameOptions for format mode.
-                       $out->allowClickjacking();
-                       $out->output();
+                       if ( $this->getIsWrappedHtml() ) {
+                               // This is a special output mode mainly 
intended for ApiSandbox use
+                               $time = microtime( true ) - 
$this->getConfig()->get( 'RequestTime' );
+                               $json = FormatJson::encode(
+                                       array(
+                                               'html' => $out->getHTML(),
+                                               'modules' => array_values( 
array_unique( array_merge(
+                                                       $out->getModules(),
+                                                       
$out->getModuleScripts(),
+                                                       $out->getModuleStyles()
+                                               ) ) ),
+                                               'time' => round( $time * 1000 ),
+                                       ),
+                                       false, FormatJson::ALL_OK
+                               );
+
+                               // Bug 66776: wfMangleFlashPolicy() is needed 
to avoid a nasty bug in
+                               // Flash, but what it does isn't friendly for 
the API, so we need to
+                               // work around it.
+                               if ( preg_match( 
'/\<\s*cross-domain-policy\s*\>/i', $json ) ) {
+                                       $json = preg_replace(
+                                               
'/\<(\s*cross-domain-policy\s*)\>/i', '\\u003C$1\\u003E', $json
+                                       );
+                               }
+
+                               echo $json;
+                       } else {
+                               // API handles its own clickjacking protection.
+                               // Note, that $wgBreakFrames will still 
override $wgApiFrameOptions for format mode.
+                               $out->allowClickjacking();
+                               $out->output();
+                       }
                } else {
                        // For non-HTML output, clear all errors that might 
have been
                        // displayed if display_errors=On
@@ -234,6 +277,18 @@
                return $this->mBuffer;
        }
 
+       public function getAllowedParams() {
+               $ret = array();
+               if ( $this->getIsHtml() ) {
+                       $ret['wrappedhtml'] = array(
+                               ApiBase::PARAM_DFLT => false,
+                               ApiBase::PARAM_HELP_MSG => 
'apihelp-format-param-wrappedhtml',
+
+                       );
+               }
+               return $ret;
+       }
+
        protected function getExamplesMessages() {
                return array(
                        'action=query&meta=siteinfo&siprop=namespaces&format=' 
. $this->getModuleName()
diff --git a/includes/api/ApiFormatJson.php b/includes/api/ApiFormatJson.php
index a319be3..1566a0f 100644
--- a/includes/api/ApiFormatJson.php
+++ b/includes/api/ApiFormatJson.php
@@ -121,10 +121,10 @@
 
        public function getAllowedParams() {
                if ( $this->isRaw ) {
-                       return array();
+                       return parent::getAllowedParams();
                }
 
-               $ret = array(
+               $ret = parent::getAllowedParams() + array(
                        'callback' => array(
                                ApiBase::PARAM_HELP_MSG => 
'apihelp-json-param-callback',
                        ),
diff --git a/includes/api/ApiFormatPhp.php b/includes/api/ApiFormatPhp.php
index df9d581..f5f2504 100644
--- a/includes/api/ApiFormatPhp.php
+++ b/includes/api/ApiFormatPhp.php
@@ -78,7 +78,7 @@
        }
 
        public function getAllowedParams() {
-               $ret = array(
+               $ret = parent::getAllowedParams() + array(
                        'formatversion' => array(
                                ApiBase::PARAM_TYPE => array( 1, 2, 'latest' ),
                                ApiBase::PARAM_DFLT => 1,
diff --git a/includes/api/ApiFormatXml.php b/includes/api/ApiFormatXml.php
index e8ad387..b4a478c 100644
--- a/includes/api/ApiFormatXml.php
+++ b/includes/api/ApiFormatXml.php
@@ -288,7 +288,7 @@
        }
 
        public function getAllowedParams() {
-               return array(
+               return parent::getAllowedParams() + array(
                        'xslt' => array(
                                ApiBase::PARAM_HELP_MSG => 
'apihelp-xml-param-xslt',
                        ),
diff --git a/includes/api/ApiHelp.php b/includes/api/ApiHelp.php
index bbea20b..ecd6eb6 100644
--- a/includes/api/ApiHelp.php
+++ b/includes/api/ApiHelp.php
@@ -690,9 +690,12 @@
                                        ) );
 
                                        $link = wfAppendQuery( wfScript( 'api' 
), $qs );
+                                       $sandbox = SpecialPage::getTitleFor( 
'ApiSandbox' )->getLocalURL() . '#' . $qs;
                                        $help['examples'] .= Html::rawElement( 
'dt', null, $msg->parse() );
                                        $help['examples'] .= Html::rawElement( 
'dd', null,
-                                               Html::element( 'a', array( 
'href' => $link ), "api.php?$qs" )
+                                               Html::element( 'a', array( 
'href' => $link ), "api.php?$qs" ) . ' ' .
+                                               Html::rawElement( 'a', array( 
'href' => $sandbox ),
+                                                       $context->msg( 
'api-help-open-in-apisandbox' )->parse() )
                                        );
                                }
                                $help['examples'] .= Html::closeElement( 'dl' );
diff --git a/includes/api/i18n/en.json b/includes/api/i18n/en.json
index 1af53fa..a1b303f 100644
--- a/includes/api/i18n/en.json
+++ b/includes/api/i18n/en.json
@@ -6,7 +6,7 @@
                ]
        },
 
-       "apihelp-main-description": "<div class=\"hlist plainlinks 
api-main-links\">\n* [[mw:API:Main_page|Documentation]]\n* 
[[mw:API:FAQ|FAQ]]\n* 
[https://lists.wikimedia.org/mailman/listinfo/mediawiki-api Mailing list]\n* 
[https://lists.wikimedia.org/mailman/listinfo/mediawiki-api-announce API 
Announcements]\n* 
[https://phabricator.wikimedia.org/maniphest/query/GebfyV4uCaLd/#R Bugs & 
requests]\n</div>\n<strong>Status:</strong> All features shown on this page 
should be working, but the API is still in active development, and may change 
at any time. Subscribe to 
[https://lists.wikimedia.org/pipermail/mediawiki-api-announce/ the 
mediawiki-api-announce mailing list] for notice of 
updates.\n\n<strong>Erroneous requests:</strong> When erroneous requests are 
sent to the API, an HTTP header will be sent with the key 
\"MediaWiki-API-Error\" and then both the value of the header and the error 
code sent back will be set to the same value. For more information see 
[[mw:API:Errors_and_warnings|API: Errors and warnings]].",
+       "apihelp-main-description": "<div class=\"hlist plainlinks 
api-main-links\">\n* [[mw:API:Main_page|Documentation]]\n* 
[[mw:API:FAQ|FAQ]]\n* 
[https://lists.wikimedia.org/mailman/listinfo/mediawiki-api Mailing list]\n* 
[https://lists.wikimedia.org/mailman/listinfo/mediawiki-api-announce API 
Announcements]\n* 
[https://phabricator.wikimedia.org/maniphest/query/GebfyV4uCaLd/#R Bugs & 
requests]\n</div>\n<strong>Status:</strong> All features shown on this page 
should be working, but the API is still in active development, and may change 
at any time. Subscribe to 
[https://lists.wikimedia.org/pipermail/mediawiki-api-announce/ the 
mediawiki-api-announce mailing list] for notice of 
updates.\n\n<strong>Erroneous requests:</strong> When erroneous requests are 
sent to the API, an HTTP header will be sent with the key 
\"MediaWiki-API-Error\" and then both the value of the header and the error 
code sent back will be set to the same value. For more information see 
[[mw:API:Errors_and_warnings|API: Errors and 
warnings]].\n\n<strong>Testing:</strong> For ease of testing API requests, see 
[[Special:ApiSandbox]].",
        "apihelp-main-param-action": "Which action to perform.",
        "apihelp-main-param-format": "The format of the output.",
        "apihelp-main-param-maxlag": "Maximum lag can be used when MediaWiki is 
installed on a database replicated cluster. To save actions causing any more 
site replication lag, this parameter can make the client wait until the 
replication lag is less than the specified value. In case of excessive lag, 
error code <samp>maxlag</samp> is returned with a message like <samp>Waiting 
for $host: $lag seconds lagged</samp>.<br />See 
[[mw:Manual:Maxlag_parameter|Manual: Maxlag parameter]] for more information.",
@@ -1370,6 +1370,7 @@
        "apihelp-watch-example-generator": "Watch the first few pages in the 
main namespace.",
 
        "apihelp-format-example-generic": "Return the query result in the $1 
format.",
+       "apihelp-format-param-wrappedhtml": "Return the pretty-printed HTML and 
associated ResourceLoader modules as a JSON object.",
        "apihelp-json-description": "Output data in JSON format.",
        "apihelp-json-param-callback": "If specified, wraps the output into a 
given function call. For safety, all user-specific data will be restricted.",
        "apihelp-json-param-utf8": "If specified, encodes most (but not all) 
non-ASCII characters as UTF-8 instead of replacing them with hexadecimal escape 
sequences. Default when <var>formatversion</var> is not <kbd>1</kbd>.",
@@ -1451,6 +1452,7 @@
        "api-help-permissions": "{{PLURAL:$1|Permission|Permissions}}:",
        "api-help-permissions-granted-to": "{{PLURAL:$1|Granted to}}: $2",
        "api-help-right-apihighlimits": "Use higher limits in API queries (slow 
queries: $1; fast queries: $2). The limits for slow queries also apply to 
multivalue parameters.",
+       "api-help-open-in-apisandbox": "<small>[open in sandbox]</small>",
 
        "api-credits-header": "Credits",
        "api-credits": "API developers:\n* Yuri Astrakhan (creator, lead 
developer Sep 2006–Sep 2007)\n* Roan Kattouw (lead developer Sep 2007–2009)\n* 
Victor Vasiliev\n* Bryan Tong Minh\n* Sam Reed\n* Brad Jorsch (lead developer 
2013–present)\n\nPlease send your comments, suggestions and questions to 
[email protected]\nor file a bug report at 
https://phabricator.wikimedia.org/.";
diff --git a/includes/api/i18n/qqq.json b/includes/api/i18n/qqq.json
index 4d4614c..e3354aa 100644
--- a/includes/api/i18n/qqq.json
+++ b/includes/api/i18n/qqq.json
@@ -1275,6 +1275,7 @@
        "apihelp-watch-example-unwatch": "{{doc-apihelp-example|watch}}",
        "apihelp-watch-example-generator": "{{doc-apihelp-example|watch}}",
        "apihelp-format-example-generic": 
"{{doc-apihelp-example|format|params=* $1 - Format 
name|paramstart=2|noseealso=1}}",
+       "apihelp-format-param-wrappedhtml": 
"{{doc-apihelp-param|format|wrappedhtml|description=the \"wrappedhtml\" 
parameter in pretty-printing format modules}}",
        "apihelp-json-description": "{{doc-apihelp-description|json|seealso=* 
{{msg-mw|apihelp-jsonfm-description}}}}",
        "apihelp-json-param-callback": "{{doc-apihelp-param|json|callback}}",
        "apihelp-json-param-utf8": "{{doc-apihelp-param|json|utf8}}",
@@ -1353,6 +1354,7 @@
        "api-help-permissions": "Label for the \"permissions\" section in the 
main module's help output.\n\nParameters:\n* $1 - Number of permissions 
displayed\n{{Identical|Permission}}",
        "api-help-permissions-granted-to": "Used to introduce the list of 
groups each permission is assigned to.\n\nParameters:\n* $1 - Number of 
groups\n* $2 - List of group names, comma-separated",
        "api-help-right-apihighlimits": 
"{{technical}}{{doc-right|apihighlimits|prefix=api-help}}\nThis message is used 
instead of {{msg-mw|right-apihighlimits}} in the API help to display the actual 
limits.\n\nParameters:\n* $1 - Limit for slow queries\n* $2 - Limit for fast 
queries",
+       "api-help-open-in-apisandbox": "Text for the link to open an API 
example in [[Special:ApiSandbox]].",
        "api-credits-header": "Header for the API credits section in the API 
help output\n{{Identical|Credit}}",
        "api-credits": "API credits text, displayed in the API help output"
 }
diff --git a/includes/specialpage/SpecialPageFactory.php 
b/includes/specialpage/SpecialPageFactory.php
index 3babafd..8f2194e 100644
--- a/includes/specialpage/SpecialPageFactory.php
+++ b/includes/specialpage/SpecialPageFactory.php
@@ -123,6 +123,7 @@
                'ListDuplicatedFiles' => 'ListDuplicatedFilesPage',
 
                // Data and tools
+               'ApiSandbox' => 'SpecialApiSandbox',
                'Statistics' => 'SpecialStatistics',
                'Allmessages' => 'SpecialAllMessages',
                'Version' => 'SpecialVersion',
diff --git a/includes/specials/SpecialApiSandbox.php 
b/includes/specials/SpecialApiSandbox.php
new file mode 100644
index 0000000..42101ba
--- /dev/null
+++ b/includes/specials/SpecialApiSandbox.php
@@ -0,0 +1,58 @@
+<?php
+/**
+ * Implements Special:ApiSandbox
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License along
+ * with this program; if not, write to the Free Software Foundation, Inc.,
+ * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup SpecialPage
+ */
+
+/**
+ * @ingroup SpecialPage
+ * @since 1.27
+ */
+class SpecialApiSandbox extends SpecialPage {
+       public function __construct() {
+               parent::__construct( 'ApiSandbox' );
+       }
+
+       public function execute( $par ) {
+               $this->setHeaders();
+               $out = $this->getOutput();
+
+               if ( !$this->getConfig()->get( 'EnableAPI' ) ) {
+                       $out->showErrorPage( 'error', 'apisandbox-api-disabled' 
);
+               }
+
+               $out->addJsConfigVars( 'apihighlimits', 
$this->getUser()->isAllowed( 'apihighlimits' ) );
+               $out->addModuleStyles( array(
+                       'mediawiki.special.apisandbox.styles',
+               ) );
+               $out->addModules( array(
+                       'mediawiki.special.apisandbox',
+                       'mediawiki.apipretty',
+               ) );
+               $out->wrapWikiMsg(
+                       "<div id='mw-apisandbox'><div class='mw-apisandbox-nojs 
error'>\n$1\n</div></div>",
+                       'apisandbox-jsonly'
+               );
+       }
+
+       protected function getGroupName() {
+               return 'wiki';
+       }
+}
diff --git a/languages/i18n/en.json b/languages/i18n/en.json
index cb9f7c4..99ef3c3 100644
--- a/languages/i18n/en.json
+++ b/languages/i18n/en.json
@@ -1822,6 +1822,41 @@
        "apihelp-summary": "",
        "apihelp-no-such-module": "Module \"$1\" not found.",
        "apihelp-link": "[[Special:ApiHelp/$1|$2]]",
+       "apisandbox": "API sandbox",
+       "apisandbox-summary": "",
+       "apisandbox-jsonly": "JavaScript is required to use the API sandbox.",
+       "apisandbox-api-disabled": "The API is disabled on this site.",
+       "apisandbox-intro": "Use this page to experiment with the 
<strong>MediaWiki web service API</strong>.\nRefer to [[mw:API:Main page|the 
API documentation]] for further details of API usage. Example: 
[//www.mediawiki.org/wiki/API#A_simple_example get the content of a Main Page]. 
Select an action to see more examples.\n\nNote that, although this is a 
sandbox, actions you carry out on this page may modify the wiki.",
+       "apisandbox-fullscreen": "Expand panel",
+       "apisandbox-fullscreen-tooltip": "Expand the sandbox panel to fill the 
browser window.",
+       "apisandbox-unfullscreen": "Show page",
+       "apisandbox-unfullscreen-tooltip": "Reduce the sandbox panel, so 
MediaWiki navigation links are available.",
+       "apisandbox-submit": "Make request",
+       "apisandbox-reset": "Clear",
+       "apisandbox-retry": "Retry",
+       "apisandbox-loading": "Loading information for API module \"$1\"...",
+       "apisandbox-load-error": "An error occurred while loading information 
for API module \"$1\": $2",
+       "apisandbox-no-parameters": "This API module has no parameters.",
+       "apisandbox-helpurls": "Help links",
+       "apisandbox-examples": "Examples",
+       "apisandbox-dynamic-parameters": "Additional parameters",
+       "apisandbox-dynamic-parameters-add-label": "Add parameter:",
+       "apisandbox-dynamic-parameters-add-placeholder": "Parameter name",
+       "apisandbox-dynamic-error-exists": "A parameter named \"$1\" already 
exists.",
+       "apisandbox-deprecated-parameters": "Deprecated parameters",
+       "apisandbox-fetch-token": "Auto-fill the token",
+       "apisandbox-submit-invalid-fields-title": "Some fields are invalid",
+       "apisandbox-submit-invalid-fields-message": "Please correct the marked 
fields and try again.",
+       "apisandbox-results": "Results",
+       "apisandbox-sending-request": "Sending API request...",
+       "apisandbox-loading-results": "Receiving API results...",
+       "apisandbox-results-error": "An error occurred while loading the API 
query response: $1.",
+       "apisandbox-request-url-label": "Request URL:",
+       "apisandbox-request-time": "Request time: {{PLURAL:$1|$1 ms}}",
+       "apisandbox-results-fixtoken": "Correct token and resubmit",
+       "apisandbox-results-fixtoken-fail": "Failed to fetch \"$1\" token.",
+       "apisandbox-alert-page": "Fields on this page are not valid.",
+       "apisandbox-alert-field": "The value of this field is not valid.",
        "booksources": "Book sources",
        "booksources-summary": "",
        "booksources-search-legend": "Search for book sources",
diff --git a/languages/i18n/qqq.json b/languages/i18n/qqq.json
index 388ec67..fb05f50 100644
--- a/languages/i18n/qqq.json
+++ b/languages/i18n/qqq.json
@@ -1997,6 +1997,41 @@
        "apihelp-summary": "{{doc-specialpagesummary|ApiHelp}}",
        "apihelp-no-such-module": "Used as an error message if the requested 
API module is not found.\n\nParameters:\n* $1 - Requested module name",
        "apihelp-link": "{{notranslate}} Used to construct a link to 
[[Special:ApiHelp]]\n\nParameters:\n* $1 - module to link\n* $2 - link text",
+       "apisandbox": "{{doc-special|ApiSandbox}}",
+       "apisandbox-summary": "{{doc-specialpagesummary|ApiSandbox}}",
+       "apisandbox-jsonly": "Displayed as an error message if the browser does 
not have JavaScript enabled.",
+       "apisandbox-api-disabled": "Displayed as an error message if the API is 
disabled on this site.",
+       "apisandbox-intro": "Displayed (from JavaScript) as a header on 
[[Special:ApiSandbox]].",
+       "apisandbox-fullscreen": "JavaScript button label for enabling 
full-page mode.",
+       "apisandbox-fullscreen-tooltip": "Tooltip for the 
{{msg-mw|apisandbox-fullscreen}} button.",
+       "apisandbox-unfullscreen": "JavaScript button label for disabling 
full-page mode.",
+       "apisandbox-unfullscreen-tooltip": "Tooltip for the 
{{msg-mw|apisandbox-unfullscreen}} button.",
+       "apisandbox-submit": "JavaScript button label for submitting the 
request.",
+       "apisandbox-reset": "JavaScript button label for clearing the form.",
+       "apisandbox-retry": "JavaScript button label for retrying the 
submission.",
+       "apisandbox-loading": "JavaScript message displayed while data is 
loading.\n\nParameters:\n* $1 - Module being loaded",
+       "apisandbox-load-error": "Displayed as an error message from JavaScript 
when data failed to load.\n\nParameters:\n* $1 - Module being loaded\n* $2 - 
Error message from the API",
+       "apisandbox-no-parameters": "Displayed (from JavaScript) when the 
loaded API module has no parameters.",
+       "apisandbox-helpurls": "JavaScript button label for showing help URLs.",
+       "apisandbox-examples": "JavaScript button label for showing example 
queries.",
+       "apisandbox-dynamic-parameters": "JavaScript fieldset legend for the 
section containing the widgets to add arbitrary parameters to a module that can 
accept dynamic parameters.",
+       "apisandbox-dynamic-parameters-add-label": "JavaScript label for the 
widget to add a new arbitrary parameter.",
+       "apisandbox-dynamic-parameters-add-placeholder": "JavaScript text field 
placeholder for the widget to add a new arbitrary parameter.",
+       "apisandbox-dynamic-error-exists": "Displayed as an error message from 
JavaScript when trying to add a new arbitrary parameter with a name that 
already exists. Parameters:\n* $1 - Parameter name that failed.",
+       "apisandbox-deprecated-parameters": "JavaScript button label and 
fieldset legend for separating deprecated parameters in the UI.",
+       "apisandbox-fetch-token": "Tooltop for the button that fetches a CSRF 
token.",
+       "apisandbox-submit-invalid-fields-title": "Title for a JavaScript error 
message when fields are invalid.",
+       "apisandbox-submit-invalid-fields-message": "Content for a JavaScript 
error message when fields are invalid.",
+       "apisandbox-results": "JavaScript tab label for the tab displaying the 
API query results.",
+       "apisandbox-sending-request": "JavaScript message displayed while the 
request is being sent.",
+       "apisandbox-loading-results": "JavaScript message displayed while the 
response is being read.",
+       "apisandbox-results-error": "Displayed as an error message from 
JavaScript when the request failed.\n\nParameters:\n* $1 - Error message",
+       "apisandbox-request-url-label": "Label for the text field displaying 
the URL used to make this request.",
+       "apisandbox-request-time": "Label and value for displaying the time 
taken by the request.\n\nParameters:\n* $1 - Time taken in milliseconds",
+       "apisandbox-results-fixtoken": "JavaScript button label",
+       "apisandbox-results-fixtoken-fail": "Displayed as an error message from 
JavaScript when a CSRF token could not be fetched.\n\nParameters:\n* $1 - Token 
type",
+       "apisandbox-alert-page": "Tooltip for the alert icon on a module's page 
tab when the page contains fields with issues.",
+       "apisandbox-alert-field": "Tooltip for the alert icon on a field when 
the field has issues.",
        "booksources": "{{doc-special|BookSources}}\n\n'''This message 
shouldn't be changed unless it has serious mistakes.'''\n\nIt's used as the 
page name of the configuration page of [[Special:BookSources]]. Changing it 
breaks existing sites using the default version of this message.\n\nSee 
also:\n* {{msg-mw|Booksources|title}}\n* {{msg-mw|Booksources-text|text}}",
        "booksources-summary": "{{doc-specialpagesummary|booksources}}",
        "booksources-search-legend": "Box heading on [[Special:BookSources|book 
sources]] special page. The box is for searching for places where a particular 
book can be bought or viewed.",
diff --git a/languages/messages/MessagesEn.php 
b/languages/messages/MessagesEn.php
index 8fa13c6..f366057 100644
--- a/languages/messages/MessagesEn.php
+++ b/languages/messages/MessagesEn.php
@@ -390,6 +390,7 @@
        'AllMyUploads'              => array( 'AllMyUploads', 'AllMyFiles' ),
        'Allpages'                  => array( 'AllPages' ),
        'ApiHelp'                   => array( 'ApiHelp' ),
+       'ApiSandbox'                => array( 'ApiSandbox' ),
        'Ancientpages'              => array( 'AncientPages' ),
        'Badtitle'                  => array( 'Badtitle' ),
        'Blankpage'                 => array( 'BlankPage' ),
diff --git a/resources/Resources.php b/resources/Resources.php
index 458d5f1..9b1b166 100644
--- a/resources/Resources.php
+++ b/resources/Resources.php
@@ -1695,6 +1695,61 @@
                'scripts' => 
'resources/src/mediawiki.special/mediawiki.special.js',
                'styles' => 
'resources/src/mediawiki.special/mediawiki.special.css',
        ),
+       'mediawiki.special.apisandbox.styles' => array(
+               'styles' => 
'resources/src/mediawiki.special/mediawiki.special.apisandbox.top.css',
+       ),
+       'mediawiki.special.apisandbox' => array(
+               'styles' => 
'resources/src/mediawiki.special/mediawiki.special.apisandbox.css',
+               'scripts' => 
'resources/src/mediawiki.special/mediawiki.special.apisandbox.js',
+               'dependencies' => array(
+                       'mediawiki.special',
+                       'mediawiki.api',
+                       'mediawiki.jqueryMsg',
+                       'oojs-ui',
+                       'mediawiki.widgets.datetime',
+               ),
+               'messages' => array(
+                       'apisandbox-intro',
+                       'apisandbox-submit',
+                       'apisandbox-reset',
+                       'apisandbox-fullscreen',
+                       'apisandbox-fullscreen-tooltip',
+                       'apisandbox-unfullscreen',
+                       'apisandbox-unfullscreen-tooltip',
+                       'apisandbox-retry',
+                       'apisandbox-loading',
+                       'apisandbox-load-error',
+                       'apisandbox-fetch-token',
+                       'apisandbox-helpurls',
+                       'apisandbox-examples',
+                       'apisandbox-dynamic-parameters',
+                       'apisandbox-dynamic-parameters-add-label',
+                       'apisandbox-dynamic-parameters-add-placeholder',
+                       'apisandbox-dynamic-error-exists',
+                       'apisandbox-deprecated-parameters',
+                       'apisandbox-no-parameters',
+                       'api-help-param-limit',
+                       'api-help-param-limit2',
+                       'api-help-param-integer-min',
+                       'api-help-param-integer-max',
+                       'api-help-param-integer-minmax',
+                       'api-help-param-multi-separate',
+                       'api-help-param-multi-max',
+                       'apisandbox-submit-invalid-fields-title',
+                       'apisandbox-submit-invalid-fields-message',
+                       'apisandbox-results',
+                       'apisandbox-sending-request',
+                       'apisandbox-loading-results',
+                       'apisandbox-results-error',
+                       'apisandbox-request-url-label',
+                       'apisandbox-request-time',
+                       'apisandbox-results-fixtoken',
+                       'apisandbox-results-fixtoken-fail',
+                       'apisandbox-alert-page',
+                       'apisandbox-alert-field',
+                       'blanknamespace',
+               ),
+       ),
        'mediawiki.special.block' => array(
                'scripts' => 
'resources/src/mediawiki.special/mediawiki.special.block.js',
                'styles' => 
'resources/src/mediawiki.special/mediawiki.special.block.css',
diff --git a/resources/src/mediawiki.special/mediawiki.special.apisandbox.css 
b/resources/src/mediawiki.special/mediawiki.special.apisandbox.css
new file mode 100644
index 0000000..e52955f
--- /dev/null
+++ b/resources/src/mediawiki.special/mediawiki.special.apisandbox.css
@@ -0,0 +1,74 @@
+.mw-apisandbox-fullscreen {
+       overflow: hidden;
+}
+
+.mw-apisandbox-toolbar {
+       text-align: right;
+       padding: 0.5em;
+}
+
+.mw-apisandbox-popup .oo-ui-popupWidget-body > .oo-ui-widget {
+       vertical-align: middle;
+}
+
+/* So DateTimeInputWidget's calendar popup works... */
+.mw-apisandbox-popup .oo-ui-popupWidget-popup,
+.mw-apisandbox-popup .oo-ui-popupWidget-body {
+       overflow: visible;
+}
+
+.mw-apisandbox-fullscreen #mw-apisandbox-ui {
+       position: fixed;
+       top: 0;
+       left: 0;
+       bottom: 0;
+       right: 0;
+       background: #fff;
+       z-index: 100;
+}
+
+.mw-apisandbox-spacer {
+       display: inline-block;
+       height: 1px;
+       width: 5em;
+}
+
+.mw-apisandbox-optionalWidget {
+       width: 100%;
+}
+
+.mw-apisandbox-optionalWidget.oo-ui-widget-disabled {
+       position: relative;
+       z-index: 0; /* New stacking context to prevent the overlay from leaking 
out */
+}
+
+.mw-apisandbox-optionalWidget-overlay {
+       position: absolute;
+       left: 0;
+       right: 0;
+       top: 0;
+       bottom: 0;
+       z-index: 2;
+       cursor: pointer;
+}
+
+.mw-apisandbox-optionalWidget-fields {
+       display: table;
+       width: 100%;
+}
+
+.mw-apisandbox-optionalWidget-widget,
+.mw-apisandbox-optionalWidget-checkbox {
+       display: table-cell;
+       vertical-align: middle;
+}
+
+.mw-apisandbox-optionalWidget-checkbox {
+       width: 1%; /* Will be expanded by content */
+       white-space: nowrap;
+       padding-left: 0.5em;
+}
+
+.oo-ui-textInputWidget.oo-ui-widget-enabled > 
.oo-ui-indicatorElement-indicator.mw-apisandbox-clickable-indicator {
+       cursor: pointer;
+}
diff --git a/resources/src/mediawiki.special/mediawiki.special.apisandbox.js 
b/resources/src/mediawiki.special/mediawiki.special.apisandbox.js
new file mode 100644
index 0000000..32ccdcd
--- /dev/null
+++ b/resources/src/mediawiki.special/mediawiki.special.apisandbox.js
@@ -0,0 +1,1659 @@
+/*global OO */
+( function ( $, mw, OO ) {
+       'use strict';
+       var ApiSandbox, Util, WidgetMethods, Validators,
+               $content, panel, booklet, oldhash, windowManager, 
fullscreenButton,
+               api = new mw.Api(),
+               bookletPages = [],
+               availableFormats = {},
+               resultPage = null,
+               suppressErrors = true,
+               updatingBooklet = false,
+               pages = {},
+               moduleInfoCache = {};
+
+       WidgetMethods = {
+               textInputWidget: {
+                       getApiValue: function () {
+                               return this.getValue();
+                       },
+                       setApiValue: function ( v ) {
+                               if ( v === undefined ) {
+                                       v = this.paramInfo[ 'default' ];
+                               }
+                               this.setValue( v );
+                       },
+                       apiCheckValid: function () {
+                               var that = this;
+                               return this.isValid().done( function ( ok ) {
+                                       ok = ok || suppressErrors;
+                                       that.setIcon( ok ? null : 'alert' );
+                                       that.setIconTitle( ok ? '' : 
mw.message( 'apisandbox-alert-field' ).plain() );
+                               } );
+                       }
+               },
+
+               dateTimeInputWidget: {
+                       isValid: function () {
+                               var ok = !Util.apiBool( this.paramInfo.required 
) || this.getApiValue() !== '';
+                               return $.Deferred().resolve( ok ).promise();
+                       }
+               },
+
+               tokenWidget: {
+                       alertTokenError: function ( code, error ) {
+                               windowManager.openWindow( 'errorAlert', {
+                                       title: mw.message(
+                                               
'apisandbox-results-fixtoken-fail', this.paramInfo.tokentype
+                                       ).parse(),
+                                       message: error,
+                                       actions: [
+                                               {
+                                                       action: 'accept',
+                                                       label: OO.ui.msg( 
'ooui-dialog-process-dismiss' ),
+                                                       flags: 'primary'
+                                               }
+                                       ]
+                               } );
+                       },
+                       fetchToken: function () {
+                               this.pushPending();
+                               return api.getToken( this.paramInfo.tokentype )
+                                       .done( this.setApiValue.bind( this ) )
+                                       .fail( this.alertTokenError.bind( this 
) )
+                                       .always( this.popPending.bind( this ) );
+                       },
+                       setApiValue: function ( v ) {
+                               WidgetMethods.textInputWidget.setApiValue.call( 
this, v );
+                               if ( v === '123ABC' ) {
+                                       this.fetchToken();
+                               }
+                       }
+               },
+
+               passwordWidget: {
+                       getApiValueForDisplay: function () {
+                               return '';
+                       }
+               },
+
+               toggleSwitchWidget: {
+                       getApiValue: function () {
+                               return this.getValue() ? 1 : undefined;
+                       },
+                       setApiValue: function ( v ) {
+                               this.setValue( Util.apiBool( v ) );
+                       },
+                       apiCheckValid: function () {
+                               return $.Deferred().resolve( true ).promise();
+                       }
+               },
+
+               dropdownWidget: {
+                       getApiValue: function () {
+                               var item = this.getMenu().getSelectedItem();
+                               return item === null ? undefined : 
item.getData();
+                       },
+                       setApiValue: function ( v ) {
+                               var menu = this.getMenu();
+
+                               if ( v === undefined ) {
+                                       v = this.paramInfo[ 'default' ];
+                               }
+                               if ( v === undefined ) {
+                                       menu.selectItem();
+                               } else {
+                                       menu.selectItemByData( String( v ) );
+                               }
+                       },
+                       apiCheckValid: function () {
+                               var ok = this.getApiValue() !== undefined || 
suppressErrors;
+                               this.setIcon( ok ? null : 'alert' );
+                               this.setIconTitle( ok ? '' : mw.message( 
'apisandbox-alert-field' ).plain() );
+                               return $.Deferred().resolve( ok ).promise();
+                       }
+               },
+
+               capsuleWidget: {
+                       getApiValue: function () {
+                               return this.getItemsData().join( '|' );
+                       },
+                       setApiValue: function ( v ) {
+                               this.setItemsFromData( v === undefined || v === 
'' ? [] : String( v ).split( '|' ) );
+                       },
+                       apiCheckValid: function () {
+                               var ok = this.getApiValue() !== undefined || 
suppressErrors;
+                               this.setIcon( ok ? null : 'alert' );
+                               this.setIconTitle( ok ? '' : mw.message( 
'apisandbox-alert-field' ).plain() );
+                               return $.Deferred().resolve( ok ).promise();
+                       }
+               },
+
+               optionalWidget: {
+                       getApiValue: function () {
+                               return this.isDisabled() ? undefined : 
this.widget.getApiValue();
+                       },
+                       setApiValue: function ( v ) {
+                               this.setDisabled( v === undefined );
+                               this.widget.setApiValue( v );
+                       },
+                       apiCheckValid: function () {
+                               if ( this.isDisabled() ) {
+                                       return $.Deferred().resolve( true 
).promise();
+                               } else {
+                                       return this.widget.apiCheckValid();
+                               }
+                       }
+               },
+
+               submoduleWidget: {
+                       single: function () {
+                               var v = this.isDisabled() ? this.paramInfo[ 
'default' ] : this.getApiValue();
+                               return v === undefined ? [] : [ { value: v, 
path: this.paramInfo.submodules[ v ] } ];
+                       },
+                       multi: function () {
+                               var map = this.paramInfo.submodules,
+                                       v = this.isDisabled() ? this.paramInfo[ 
'default' ] : this.getApiValue();
+                               return v === undefined || v === '' ? [] : 
$.map( String( v ).split( '|' ), function ( v ) {
+                                       return { value: v, path: map[ v ] };
+                               } );
+                       }
+               },
+
+               uploadWidget: {
+                       getApiValueForDisplay: function () {
+                               return '...';
+                       },
+                       getApiValue: function () {
+                               return this.getValue();
+                       },
+                       setApiValue: function () {
+                               // Can't, sorry.
+                       },
+                       apiCheckValid: function () {
+                               var ok = this.getValue() !== null || 
suppressErrors;
+                               this.setIcon( ok ? null : 'alert' );
+                               this.setIconTitle( ok ? '' : mw.message( 
'apisandbox-alert-field' ).plain() );
+                               return $.Deferred().resolve( ok ).promise();
+                       }
+               }
+       };
+
+       Validators = {
+               generic: function () {
+                       return !Util.apiBool( this.paramInfo.required ) || 
this.getApiValue() !== '';
+               }
+       };
+
+       /**
+        * @class mw.special.ApiSandbox.Utils
+        * @private
+        */
+       Util = {
+               /**
+                * Fetch API module info
+                *
+                * @param {string} module Module to fetch data for
+                * @return {jQuery.Promise}
+                */
+               fetchModuleInfo: function ( module ) {
+                       var apiPromise,
+                               deferred = $.Deferred();
+
+                       if ( moduleInfoCache.hasOwnProperty( module ) ) {
+                               return deferred
+                                       .resolve( moduleInfoCache[ module ] )
+                                       .promise( { abort: function () {} } );
+                       } else {
+                               apiPromise = api.post( {
+                                       action: 'paraminfo',
+                                       modules: module,
+                                       helpformat: 'html',
+                                       uselang: mw.config.get( 
'wgUserLanguage' )
+                               } ).done( function ( data ) {
+                                       var info;
+
+                                       if ( data.warnings && 
data.warnings.paraminfo ) {
+                                               deferred.reject( '???', 
data.warnings.paraminfo[ '*' ] );
+                                               return;
+                                       }
+
+                                       info = data.paraminfo.modules;
+                                       if ( !info || info.length !== 1 || 
info[ 0 ].path !== module ) {
+                                               deferred.reject( '???', 'No 
module data returned' );
+                                               return;
+                                       }
+
+                                       moduleInfoCache[ module ] = info[ 0 ];
+                                       deferred.resolve( info[ 0 ] );
+                               } ).fail( function ( code, details ) {
+                                       if ( code === 'http' ) {
+                                               details = 'HTTP error: ' + 
details.exception;
+                                       } else if ( details.error ) {
+                                               details = details.error.info;
+                                       }
+                                       deferred.reject( code, details );
+                               } );
+                               return deferred
+                                       .promise( { abort: apiPromise.abort } );
+                       }
+               },
+
+               /**
+                * Mark all currently-in-use tokens as bad
+                */
+               markTokensBad: function () {
+                       var page, subpages, i,
+                               checkPages = [ pages.main ];
+
+                       while ( checkPages.length ) {
+                               page = checkPages.shift();
+
+                               if ( page.tokenWidget ) {
+                                       api.badToken( 
page.tokenWidget.paramInfo.tokentype );
+                               }
+
+                               subpages = page.getSubpages();
+                               for ( i = 0; i < subpages.length; i++ ) {
+                                       if ( pages.hasOwnProperty( subpages[ i 
].key ) ) {
+                                               checkPages.push( pages[ 
subpages[ i ].key ] );
+                                       }
+                               }
+                       }
+               },
+
+               /**
+                * Test an API boolean
+                *
+                * @param {Mixed} value
+                * @return {boolean}
+                */
+               apiBool: function ( value ) {
+                       return value !== undefined && value !== false;
+               },
+
+               /**
+                * Create a widget for a parameter.
+                *
+                * @param {Object} pi Parameter info from API
+                * @param {Object} opts Additional options
+                * @return {OO.ui.Widget}
+                */
+               createWidgetForParameter: function ( pi, opts ) {
+                       var widget, innerWidget, finalWidget, items, $button, 
$content, func,
+                               multiMode = 'none';
+
+                       opts = opts || {};
+
+                       switch ( pi.type ) {
+                               case 'boolean':
+                                       widget = new OO.ui.ToggleSwitchWidget();
+                                       widget.paramInfo = pi;
+                                       $.extend( widget, 
WidgetMethods.toggleSwitchWidget );
+                                       pi.required = true; // Avoid wrapping 
in the non-required widget
+                                       break;
+
+                               case 'string':
+                               case 'user':
+                                       if ( pi.tokentype ) {
+                                               widget = new 
TextInputWithIndicatorWidget( {
+                                                       input: {
+                                                               indicator: 
'previous',
+                                                               indicatorTitle: 
mw.message( 'apisandbox-fetch-token' ).text(),
+                                                               required: 
Util.apiBool( pi.required )
+                                                       }
+                                               } );
+                                       } else if ( Util.apiBool( pi.multi ) ) {
+                                               widget = new 
OO.ui.CapsuleMultiSelectWidget( {
+                                                       allowArbitrary: true
+                                               } );
+                                               widget.paramInfo = pi;
+                                               $.extend( widget, 
WidgetMethods.capsuleWidget );
+                                       } else {
+                                               widget = new 
OO.ui.TextInputWidget( {
+                                                       required: Util.apiBool( 
pi.required )
+                                               } );
+                                       }
+                                       if ( !Util.apiBool( pi.multi ) ) {
+                                               widget.paramInfo = pi;
+                                               $.extend( widget, 
WidgetMethods.textInputWidget );
+                                               widget.setValidation( 
Validators.generic );
+                                       }
+                                       if ( pi.tokentype ) {
+                                               $.extend( widget, 
WidgetMethods.tokenWidget );
+                                               widget.input.paramInfo = pi;
+                                               $.extend( widget.input, 
WidgetMethods.textInputWidget );
+                                               $.extend( widget.input, 
WidgetMethods.tokenWidget );
+                                               widget.on( 'indicator', 
widget.fetchToken, [], widget );
+                                       }
+                                       break;
+
+                               case 'text':
+                                       widget = new OO.ui.TextInputWidget( {
+                                               multiline: true,
+                                               required: Util.apiBool( 
pi.required )
+                                       } );
+                                       widget.paramInfo = pi;
+                                       $.extend( widget, 
WidgetMethods.textInputWidget );
+                                       widget.setValidation( 
Validators.generic );
+                                       break;
+
+                               case 'password':
+                                       widget = new OO.ui.TextInputWidget( {
+                                               type: 'password',
+                                               required: Util.apiBool( 
pi.required )
+                                       } );
+                                       widget.paramInfo = pi;
+                                       $.extend( widget, 
WidgetMethods.textInputWidget );
+                                       $.extend( widget, 
WidgetMethods.passwordWidget );
+                                       widget.setValidation( 
Validators.generic );
+                                       multiMode = 'enter';
+                                       break;
+
+                               case 'integer':
+                                       widget = new OO.ui.NumberInputWidget( {
+                                               required: Util.apiBool( 
pi.required ),
+                                               isInteger: true
+                                       } );
+                                       widget.setIcon = 
widget.input.setIcon.bind( widget.input );
+                                       widget.setIconTitle = 
widget.input.setIconTitle.bind( widget.input );
+                                       widget.isValid = 
widget.input.isValid.bind( widget.input );
+                                       widget.paramInfo = pi;
+                                       $.extend( widget, 
WidgetMethods.textInputWidget );
+                                       if ( Util.apiBool( pi.enforcerange ) ) {
+                                               widget.setRange( pi.min || 
-Infinity, pi.max || Infinity );
+                                       }
+                                       multiMode = 'enter';
+                                       break;
+
+                               case 'limit':
+                                       widget = new OO.ui.NumberInputWidget( {
+                                               required: Util.apiBool( 
pi.required ),
+                                               isInteger: true
+                                       } );
+                                       widget.setIcon = 
widget.input.setIcon.bind( widget.input );
+                                       widget.setIconTitle = 
widget.input.setIconTitle.bind( widget.input );
+                                       widget.isValid = 
widget.input.isValid.bind( widget.input );
+                                       widget.input.setValidation( function ( 
value ) {
+                                               return value === 'max' || 
widget.validateNumber( value );
+                                       } );
+                                       widget.paramInfo = pi;
+                                       $.extend( widget, 
WidgetMethods.textInputWidget );
+                                       widget.setRange( pi.min || 0, 
mw.config.get( 'apihighlimits' ) ? pi.highmax : pi.max );
+                                       multiMode = 'enter';
+                                       break;
+
+                               case 'timestamp':
+                                       widget = new 
mw.widgets.datetime.DateTimeInputWidget( {
+                                               formatter: {
+                                                       format: 
'${year|0}-${month|0}-${day|0}T${hour|0}:${minute|0}:${second|0}${zone|short}'
+                                               },
+                                               required: Util.apiBool( 
pi.required ),
+                                               clearable: false
+                                       } );
+                                       widget.paramInfo = pi;
+                                       $.extend( widget, 
WidgetMethods.textInputWidget );
+                                       $.extend( widget, 
WidgetMethods.dateTimeInputWidget );
+                                       multiMode = 'indicator';
+                                       break;
+
+                               case 'upload':
+                                       widget = new OO.ui.SelectFileWidget();
+                                       widget.paramInfo = pi;
+                                       $.extend( widget, 
WidgetMethods.uploadWidget );
+                                       break;
+
+                               case 'namespace':
+                                       items = $.map( mw.config.get( 
'wgFormattedNamespaces' ), function ( name, ns ) {
+                                               if ( ns === '0' ) {
+                                                       name = mw.message( 
'blanknamespace' ).text();
+                                               }
+                                               return new 
OO.ui.MenuOptionWidget( { data: ns, label: name } );
+                                       } ).sort( function ( a, b ) {
+                                               return a.data - b.data;
+                                       } );
+                                       if ( Util.apiBool( pi.multi ) ) {
+                                               widget = new 
OO.ui.CapsuleMultiSelectWidget( {
+                                                       menu: { items: items }
+                                               } );
+                                               widget.paramInfo = pi;
+                                               $.extend( widget, 
WidgetMethods.capsuleWidget );
+                                       } else {
+                                               widget = new 
OO.ui.DropdownWidget( {
+                                                       menu: { items: items }
+                                               } );
+                                               widget.paramInfo = pi;
+                                               $.extend( widget, 
WidgetMethods.dropdownWidget );
+                                       }
+                                       break;
+
+                               default:
+                                       if ( !$.isArray( pi.type ) ) {
+                                               throw new Error( 'Unknown 
parameter type ' + pi.type );
+                                       }
+
+                                       items = $.map( pi.type, function ( v ) {
+                                               return new 
OO.ui.MenuOptionWidget( { data: String( v ), label: String( v ) } );
+                                       } );
+                                       if ( Util.apiBool( pi.multi ) ) {
+                                               widget = new 
OO.ui.CapsuleMultiSelectWidget( {
+                                                       menu: { items: items }
+                                               } );
+                                               widget.paramInfo = pi;
+                                               $.extend( widget, 
WidgetMethods.capsuleWidget );
+                                               if ( Util.apiBool( 
pi.submodules ) ) {
+                                                       widget.getSubmodules = 
WidgetMethods.submoduleWidget.multi;
+                                                       widget.on( 'change', 
ApiSandbox.updateUI );
+                                               }
+                                       } else {
+                                               widget = new 
OO.ui.DropdownWidget( {
+                                                       menu: { items: items }
+                                               } );
+                                               widget.paramInfo = pi;
+                                               $.extend( widget, 
WidgetMethods.dropdownWidget );
+                                               if ( Util.apiBool( 
pi.submodules ) ) {
+                                                       widget.getSubmodules = 
WidgetMethods.submoduleWidget.single;
+                                                       widget.getMenu().on( 
'choose', ApiSandbox.updateUI );
+                                               }
+                                       }
+
+                                       break;
+                       }
+
+                       if ( Util.apiBool( pi.multi ) && multiMode !== 'none' ) 
{
+                               innerWidget = widget;
+                               switch ( multiMode ) {
+                                       case 'enter':
+                                               $content = innerWidget.$element;
+                                               break;
+
+                                       case 'indicator':
+                                               $button = 
innerWidget.$indicator;
+                                               $button.css( 'cursor', 
'pointer' );
+                                               $button.attr( 'tabindex', 0 );
+                                               $button.parent().append( 
$button );
+                                               innerWidget.setIndicator( 
'next' );
+                                               $content = innerWidget.$element;
+                                               break;
+
+                                       default:
+                                               throw new Error( 'Unknown 
multiMode "' + multiMode + '"' );
+                               }
+
+                               widget = new OO.ui.CapsuleMultiSelectWidget( {
+                                       allowArbitrary: true,
+                                       popup: {
+                                               classes: [ 
'mw-apisandbox-popup' ],
+                                               $content: $content
+                                       }
+                               } );
+                               widget.paramInfo = pi;
+                               $.extend( widget, WidgetMethods.capsuleWidget );
+
+                               func = function () {
+                                       if ( !innerWidget.isDisabled() ) {
+                                               
innerWidget.apiCheckValid().done( function ( ok ) {
+                                                       if ( ok ) {
+                                                               
widget.addItemsFromData( [ innerWidget.getApiValue() ] );
+                                                               
innerWidget.setApiValue( undefined );
+                                                       }
+                                               } );
+                                               return false;
+                                       }
+                               };
+                               switch ( multiMode ) {
+                                       case 'enter':
+                                               innerWidget.connect( null, { 
enter: func } );
+                                               break;
+
+                                       case 'indicator':
+                                               $button.on( {
+                                                       click: func,
+                                                       keypress: function ( e 
) {
+                                                               if ( e.which 
=== OO.ui.Keys.SPACE || e.which === OO.ui.Keys.ENTER ) {
+                                                                       func();
+                                                               }
+                                                       }
+                                               } );
+                                               break;
+                               }
+                       }
+
+                       if ( Util.apiBool( pi.required ) || opts.nooptional ) {
+                               finalWidget = widget;
+                       } else {
+                               finalWidget = new OptionalWidget( widget );
+                               finalWidget.paramInfo = pi;
+                               $.extend( finalWidget, 
WidgetMethods.optionalWidget );
+                               if ( widget.getSubmodules ) {
+                                       finalWidget.getSubmodules = 
widget.getSubmodules.bind( widget );
+                                       finalWidget.on( 'disable', function () 
{ setTimeout( ApiSandbox.updateUI ); } );
+                               }
+                               finalWidget.setDisabled( true );
+                       }
+
+                       widget.setApiValue( pi[ 'default' ] );
+
+                       return finalWidget;
+               },
+
+               /**
+                * Parse an HTML string, adding target="_blank" to any links
+                *
+                * @param {string} html HTML to parse
+                * @return {jQuery}
+                */
+               parseHTML: function ( html ) {
+                       var $ret = $( $.parseHTML( html ) );
+                       $ret.filter( 'a' ).add( $ret.find( 'a' ) )
+                               .filter( '[href]:not([target])' )
+                               .attr( 'target', '_blank' );
+                       return $ret;
+               }
+       };
+
+       /**
+       * Interface to ApiSandbox UI
+       *
+       * @class mw.special.ApiSandbox
+       */
+       mw.special.ApiSandbox = ApiSandbox = {
+               /**
+                * Initialize the UI
+                *
+                * Automatically called on $.ready()
+                */
+               init: function () {
+                       var $toolbar;
+
+                       $content = $( '#mw-apisandbox' );
+
+                       windowManager = new OO.ui.WindowManager();
+                       $( 'body' ).append( windowManager.$element );
+                       windowManager.addWindows( {
+                               errorAlert: new OO.ui.MessageDialog()
+                       } );
+
+                       fullscreenButton = new OO.ui.ButtonWidget( {
+                               label: mw.message( 'apisandbox-fullscreen' 
).text(),
+                               title: mw.message( 
'apisandbox-fullscreen-tooltip' ).text()
+                       } ).on( 'click', ApiSandbox.toggleFullscreen );
+
+                       $toolbar = $( '<div>' )
+                               .addClass( 'mw-apisandbox-toolbar' )
+                               .append(
+                                       fullscreenButton.$element,
+                                       new OO.ui.ButtonWidget( {
+                                               label: mw.message( 
'apisandbox-submit' ).text(),
+                                               flags: [ 'primary', 
'constructive' ]
+                                       } ).on( 'click', ApiSandbox.sendRequest 
).$element,
+                                       new OO.ui.ButtonWidget( {
+                                               label: mw.message( 
'apisandbox-reset' ).text(),
+                                               flags: 'destructive'
+                                       } ).on( 'click', ApiSandbox.resetUI 
).$element
+                               );
+
+                       booklet = new OO.ui.BookletLayout( {
+                               outlined: true,
+                               autoFocus: false
+                       } );
+
+                       panel = new OO.ui.PanelLayout( {
+                               classes: [ 'mw-apisandbox-container' ],
+                               content: [ booklet ],
+                               expanded: false,
+                               framed: true
+                       } );
+
+                       pages.main = new ApiSandbox.PageLayout( { key: 'main', 
path: 'main' } );
+
+                       // Parse the current hash string
+                       if ( !ApiSandbox.loadFromHash() ) {
+                               ApiSandbox.updateUI();
+                       }
+
+                       // If the hashchange event exists, use it. Otherwise, 
fake it.
+                       // And, of course, IE has to be dumb.
+                       if ( 'onhashchange' in window &&
+                               ( document.documentMode === undefined || 
document.documentMode >= 8 )
+                       ) {
+                               $( window ).on( 'hashchange', 
ApiSandbox.loadFromHash );
+                       } else {
+                               setInterval( function () {
+                                       if ( oldhash !== location.hash ) {
+                                               ApiSandbox.loadFromHash();
+                                       }
+                               }, 1000 );
+                       }
+
+                       $content
+                               .empty()
+                               .append( $( '<p>' ).append( mw.message( 
'apisandbox-intro' ).parse() ) )
+                               .append(
+                                       $( '<div>', { id: 'mw-apisandbox-ui' } )
+                                               .append( $toolbar )
+                                               .append( panel.$element )
+                               );
+
+                       $( window ).on( 'resize', ApiSandbox.resizePanel );
+
+                       ApiSandbox.resizePanel();
+               },
+
+               /**
+                * Toggle "fullscreen" mode
+                */
+               toggleFullscreen: function () {
+                       var $body = $( document.body );
+
+                       $body.toggleClass( 'mw-apisandbox-fullscreen' );
+                       if ( $body.hasClass( 'mw-apisandbox-fullscreen' ) ) {
+                               fullscreenButton.setLabel( mw.message( 
'apisandbox-unfullscreen' ).text() );
+                               fullscreenButton.setTitle( mw.message( 
'apisandbox-unfullscreen-tooltip' ).text() );
+                               $body.append( $( '#mw-apisandbox-ui' ) );
+                       } else {
+                               fullscreenButton.setLabel( mw.message( 
'apisandbox-fullscreen' ).text() );
+                               fullscreenButton.setTitle( mw.message( 
'apisandbox-fullscreen-tooltip' ).text() );
+                               $content.append( $( '#mw-apisandbox-ui' ) );
+                       }
+                       ApiSandbox.resizePanel();
+               },
+
+               /**
+                * Set the height of the panel based on the current viewport.
+                */
+               resizePanel: function () {
+                       var height = $( window ).height(),
+                               contentTop = $content.offset().top;
+
+                       if ( $( document.body ).hasClass( 
'mw-apisandbox-fullscreen' ) ) {
+                               height -= panel.$element.offset().top - $( 
'#mw-apisandbox-ui' ).offset().top;
+                               panel.$element.height( height - 1 );
+                       } else {
+                               // Subtract the height of the intro text
+                               height -= panel.$element.offset().top - 
contentTop;
+
+                               panel.$element.height( height - 10 );
+                               $( window ).scrollTop( contentTop - 5 );
+                       }
+               },
+
+               /**
+                * Update the current query when the page hash changes
+                */
+               loadFromHash: function () {
+                       var params, m, re,
+                               hash = location.hash;
+
+                       if ( oldhash === hash ) {
+                               return false;
+                       }
+                       oldhash = hash;
+                       if ( hash === '' ) {
+                               return false;
+                       }
+
+                       // I'm surprised this doesn't seem to exist in jQuery 
or mw.util.
+                       params = {};
+                       hash = hash.replace( '+', '%20' );
+                       re = /([^&=#]+)=?([^&#]*)/g;
+                       while ( ( m = re.exec( hash ) ) ) {
+                               params[ decodeURIComponent( m[ 1 ] ) ] = 
decodeURIComponent( m[ 2 ] );
+                       }
+
+                       ApiSandbox.updateUI( params );
+                       return true;
+               },
+
+               /**
+                * Update the pages in the booklet
+                *
+                * @param {Object} [params] Optional query parameters to load
+                */
+               updateUI: function ( params ) {
+                       var i, page, subpages, j, removePages,
+                               addPages = [];
+
+                       if ( !$.isPlainObject( params ) ) {
+                               params = undefined;
+                       }
+
+                       if ( updatingBooklet ) {
+                               return;
+                       }
+                       updatingBooklet = true;
+                       try {
+                               if ( params !== undefined ) {
+                                       pages.main.loadQueryParams( params );
+                               }
+                               addPages.push( pages.main );
+                               if ( resultPage !== null ) {
+                                       addPages.push( resultPage );
+                               }
+                               pages.main.apiCheckValid();
+
+                               i = 0;
+                               while ( addPages.length ) {
+                                       page = addPages.shift();
+                                       if ( bookletPages[ i ] !== page ) {
+                                               for ( j = i; j < 
bookletPages.length; j++ ) {
+                                                       if ( bookletPages[ j 
].getName() === page.getName() ) {
+                                                               
bookletPages.splice( j, 1 );
+                                                       }
+                                               }
+                                               bookletPages.splice( i, 0, page 
);
+                                               booklet.addPages( [ page ], i );
+                                       }
+                                       i++;
+
+                                       if ( page.getSubpages ) {
+                                               subpages = page.getSubpages();
+                                               for ( j = 0; j < 
subpages.length; j++ ) {
+                                                       if ( 
!pages.hasOwnProperty( subpages[ j ].key ) ) {
+                                                               subpages[ j 
].indentLevel = page.indentLevel + 1;
+                                                               pages[ 
subpages[ j ].key ] = new ApiSandbox.PageLayout( subpages[ j ] );
+                                                       }
+                                                       if ( params !== 
undefined ) {
+                                                               pages[ 
subpages[ j ].key ].loadQueryParams( params );
+                                                       }
+                                                       addPages.splice( j, 0, 
pages[ subpages[ j ].key ] );
+                                                       pages[ subpages[ j 
].key ].apiCheckValid();
+                                               }
+                                       }
+                               }
+
+                               if ( bookletPages.length > i ) {
+                                       removePages = bookletPages.splice( i, 
bookletPages.length - i );
+                                       booklet.removePages( removePages );
+                               }
+
+                               if ( !booklet.getCurrentPageName() ) {
+                                       booklet.selectFirstSelectablePage();
+                               }
+                       } finally {
+                               updatingBooklet = false;
+                       }
+               },
+
+               /**
+                * Reset button handler
+                */
+               resetUI: function () {
+                       suppressErrors = true;
+                       pages = {
+                               main: new ApiSandbox.PageLayout( { key: 'main', 
path: 'main' } )
+                       };
+                       resultPage = null;
+                       ApiSandbox.updateUI();
+               },
+
+               /**
+                * Submit button handler
+                */
+               sendRequest: function () {
+                       var page, subpages, i, query, $result,
+                               progress, $progressText, progressLoading,
+                               deferreds = [],
+                               params = {},
+                               displayParams = {},
+                               checkPages = [ pages.main ];
+
+                       suppressErrors = false;
+
+                       while ( checkPages.length ) {
+                               page = checkPages.shift();
+                               deferreds.push( page.apiCheckValid() );
+                               page.getQueryParams( params, displayParams );
+                               subpages = page.getSubpages();
+                               for ( i = 0; i < subpages.length; i++ ) {
+                                       if ( pages.hasOwnProperty( subpages[ i 
].key ) ) {
+                                               checkPages.push( pages[ 
subpages[ i ].key ] );
+                                       }
+                               }
+                       }
+
+                       $.when.apply( $, deferreds ).done( function () {
+                               if ( $.inArray( false, arguments ) !== -1 ) {
+                                       windowManager.openWindow( 'errorAlert', 
{
+                                               title: mw.message( 
'apisandbox-submit-invalid-fields-title' ).parse(),
+                                               message: mw.message( 
'apisandbox-submit-invalid-fields-message' ).parse(),
+                                               actions: [
+                                                       {
+                                                               action: 
'accept',
+                                                               label: 
OO.ui.msg( 'ooui-dialog-process-dismiss' ),
+                                                               flags: 'primary'
+                                                       }
+                                               ]
+                                       } );
+                                       return;
+                               }
+
+                               query = $.param( displayParams );
+
+                               // Force a 'fm' format with wrappedhtml=1, if 
available
+                               if ( params.format !== undefined ) {
+                                       if ( availableFormats.hasOwnProperty( 
params.format + 'fm' ) ) {
+                                               params.format = params.format + 
'fm';
+                                       }
+                                       if ( params.format.substr( -2 ) === 
'fm' ) {
+                                               params.wrappedhtml = 1;
+                                       }
+                               }
+
+                               progressLoading = false;
+                               $progressText = $( '<span>' ).text( mw.message( 
'apisandbox-sending-request' ).text() );
+                               progress = new OO.ui.ProgressBarWidget( {
+                                       progress: false,
+                                       $content: $progressText
+                               } );
+
+                               $result = $( '<div>' )
+                                       .append( progress.$element );
+
+                               resultPage = page = new OO.ui.PageLayout( 
'|results|' );
+                               page.setupOutlineItem = function () {
+                                       this.outlineItem.setLabel( mw.message( 
'apisandbox-results' ).text() );
+                               };
+                               page.$element.empty()
+                                       .append(
+                                               new OO.ui.FieldLayout(
+                                                       new 
OO.ui.TextInputWidget( {
+                                                               readOnly: true,
+                                                               value: 
mw.util.wikiScript( 'api' ) + '?' + query
+                                                       } ), {
+                                                               label: 
mw.message( 'apisandbox-request-url-label' ).parse()
+                                                       }
+                                               ).$element,
+                                               $result
+                                       );
+                               ApiSandbox.updateUI();
+                               booklet.setPage( '|results|' );
+
+                               location.href = oldhash = '#' + query;
+
+                               api.post( params, {
+                                       contentType: 'multipart/form-data',
+                                       dataType: 'text',
+                                       xhr: function () {
+                                               var xhr = new 
window.XMLHttpRequest();
+                                               xhr.upload.addEventListener( 
'progress', function ( e ) {
+                                                       if ( !progressLoading ) 
{
+                                                               if ( 
e.lengthComputable ) {
+                                                                       
progress.setProgress( e.loaded * 100 / e.total );
+                                                               } else {
+                                                                       
progress.setProgress( false );
+                                                               }
+                                                       }
+                                               } );
+                                               xhr.addEventListener( 
'progress', function ( e ) {
+                                                       if ( !progressLoading ) 
{
+                                                               progressLoading 
= true;
+                                                               
$progressText.text( mw.message( 'apisandbox-loading-results' ).text() );
+                                                       }
+                                                       if ( e.lengthComputable 
) {
+                                                               
progress.setProgress( e.loaded * 100 / e.total );
+                                                       } else {
+                                                               
progress.setProgress( false );
+                                                       }
+                                               } );
+                                               return xhr;
+                                       }
+                               } )
+                                       .fail( function ( code, data ) {
+                                               var details = 'HTTP error: ' + 
data.exception;
+                                               $result.empty()
+                                                       .append(
+                                                               new 
OO.ui.LabelWidget( {
+                                                                       label: 
mw.message( 'apisandbox-results-error', details ).text(),
+                                                                       
classes: [ 'error' ]
+                                                               } ).$element
+                                                       );
+                                       } )
+                                       .done( function ( data, jqXHR ) {
+                                               var m, loadTime, button,
+                                                       ct = 
jqXHR.getResponseHeader( 'Content-Type' );
+
+                                               $result.empty();
+                                               if ( 
/^text\/mediawiki-api-prettyprint-wrapped(?:;|$)/.test( ct ) ) {
+                                                       data = $.parseJSON( 
data );
+                                                       if ( 
data.modules.length ) {
+                                                               mw.loader.load( 
data.modules );
+                                                       }
+                                                       $result.append( 
Util.parseHTML( data.html ) );
+                                                       loadTime = data.time;
+                                               } else if ( ( m = data.match( 
/<pre[ >][\s\S]*<\/pre>/ ) ) ) {
+                                                       $result.append( 
Util.parseHTML( m[ 0 ] ) );
+                                                       if ( ( m = data.match( 
/"wgBackendResponseTime":\s*(\d+)/ ) ) ) {
+                                                               loadTime = 
parseInt( m[ 1 ], 10 );
+                                                       }
+                                               } else {
+                                                       $( '<pre>' )
+                                                               .addClass( 
'api-pretty-content' )
+                                                               .text( data )
+                                                               .appendTo( 
$result );
+                                               }
+                                               if ( typeof loadTime === 
'number' ) {
+                                                       $result.append(
+                                                               $( '<div>' 
).append(
+                                                                       new 
OO.ui.LabelWidget( {
+                                                                               
label: mw.message( 'apisandbox-request-time', loadTime ).text()
+                                                                       } 
).$element
+                                                               )
+                                                       );
+                                               }
+
+                                               if ( jqXHR.getResponseHeader( 
'MediaWiki-API-Error' ) === 'badtoken' ) {
+                                                       // Flush all saved 
tokens in case one of them is the bad one.
+                                                       Util.markTokensBad();
+                                                       button = new 
OO.ui.ButtonWidget( {
+                                                               label: 
mw.message( 'apisandbox-results-fixtoken' ).text()
+                                                       } );
+                                                       button.on( 'click', 
ApiSandbox.fixTokenAndResend )
+                                                               .on( 'click', 
button.setDisabled, [ true ], button )
+                                                               
.$element.appendTo( $result );
+                                               }
+                                       } );
+                       } );
+               },
+
+               /**
+                * Handler for the "Correct token and resubmit" button
+                *
+                * Used on a 'badtoken' error, it re-fetches token parameters 
for all
+                * pages and then re-submits the query.
+                */
+               fixTokenAndResend: function () {
+                       var page, subpages, i, k,
+                               ok = true,
+                               tokenWait = { dummy: true },
+                               checkPages = [ pages.main ],
+                               success = function ( k ) {
+                                       delete tokenWait[ k ];
+                                       if ( ok && $.isEmptyObject( tokenWait ) 
) {
+                                               ApiSandbox.sendRequest();
+                                       }
+                               },
+                               failure = function ( k ) {
+                                       delete tokenWait[ k ];
+                                       ok = false;
+                               };
+
+                       while ( checkPages.length ) {
+                               page = checkPages.shift();
+
+                               if ( page.tokenWidget ) {
+                                       k = page.apiModule + 
page.tokenWidget.paramInfo.name;
+                                       tokenWait[ k ] = 
page.tokenWidget.fetchToken()
+                                               .done( success.bind( 
page.tokenWidget, k ) )
+                                               .fail( failure.bind( 
page.tokenWidget, k ) );
+                               }
+
+                               subpages = page.getSubpages();
+                               for ( i = 0; i < subpages.length; i++ ) {
+                                       if ( pages.hasOwnProperty( subpages[ i 
].key ) ) {
+                                               checkPages.push( pages[ 
subpages[ i ].key ] );
+                                       }
+                               }
+                       }
+
+                       success( 'dummy', '' );
+               },
+
+               /**
+                * Reset validity indicators for all widgets
+                */
+               updateValidityIndicators: function () {
+                       var page, subpages, i,
+                               checkPages = [ pages.main ];
+
+                       while ( checkPages.length ) {
+                               page = checkPages.shift();
+                               page.apiCheckValid();
+                               subpages = page.getSubpages();
+                               for ( i = 0; i < subpages.length; i++ ) {
+                                       if ( pages.hasOwnProperty( subpages[ i 
].key ) ) {
+                                               checkPages.push( pages[ 
subpages[ i ].key ] );
+                                       }
+                               }
+                       }
+               }
+       };
+
+       /**
+        * PageLayout for API modules
+        *
+        * @class
+        * @private
+        * @extends OO.ui.PageLayout
+        * @constructor
+        * @param {Object} [config] Configuration options
+        */
+       ApiSandbox.PageLayout = function ( config ) {
+               config = $.extend( { prefix: '' }, config );
+               this.displayText = config.key;
+               this.apiModule = config.path;
+               this.prefix = config.prefix;
+               this.paramInfo = null;
+               this.apiIsValid = true;
+               this.loadFromQueryParams = null;
+               this.widgets = {};
+               this.tokenWidget = null;
+               this.indentLevel = config.indentLevel ? config.indentLevel : 0;
+               ApiSandbox.PageLayout[ 'super' ].call( this, config.key, config 
);
+               this.loadParamInfo();
+       };
+       OO.inheritClass( ApiSandbox.PageLayout, OO.ui.PageLayout );
+       ApiSandbox.PageLayout.prototype.setupOutlineItem = function () {
+               this.outlineItem.setLevel( this.indentLevel );
+               this.outlineItem.setLabel( this.displayText );
+               this.outlineItem.setIcon( this.apiIsValid || suppressErrors ? 
null : 'alert' );
+               this.outlineItem.setIconTitle(
+                       this.apiIsValid || suppressErrors ? '' : mw.message( 
'apisandbox-alert-page' ).plain()
+               );
+       };
+
+       /**
+        * Fetch module information for this page's module, then create UI
+        */
+       ApiSandbox.PageLayout.prototype.loadParamInfo = function () {
+               var dynamicFieldset, dynamicParamNameWidget,
+                       that = this,
+                       addDynamicParamWidget = function () {
+                               var name, layout, widget, button;
+
+                               // Check name is filled in
+                               name = dynamicParamNameWidget.getValue().trim();
+                               if ( name === '' ) {
+                                       dynamicParamNameWidget.focus();
+                                       return;
+                               }
+
+                               if ( that.widgets[ name ] !== undefined ) {
+                                       windowManager.openWindow( 'errorAlert', 
{
+                                               title: mw.message(
+                                                       
'apisandbox-dynamic-error-exists', name
+                                               ).parse(),
+                                               actions: [
+                                                       {
+                                                               action: 
'accept',
+                                                               label: 
OO.ui.msg( 'ooui-dialog-process-dismiss' ),
+                                                               flags: 'primary'
+                                                       }
+                                               ]
+                                       } );
+                                       return;
+                               }
+
+                               widget = Util.createWidgetForParameter( {
+                                       name: name,
+                                       type: 'string',
+                                       'default': ''
+                               }, {
+                                       nooptional: true
+                               } );
+                               button = new OO.ui.ButtonWidget( {
+                                       icon: 'remove',
+                                       flags: 'destructive'
+                               } );
+                               layout = new OO.ui.ActionFieldLayout(
+                                       widget,
+                                       button,
+                                       {
+                                               label: name,
+                                               align: 'left'
+                                       }
+                               );
+                               button.on( 'click', removeDynamicParamWidget, [ 
name, layout ] );
+                               that.widgets[ name ] = widget;
+                               dynamicFieldset.addItems( [ layout ], 
dynamicFieldset.getItems().length - 1 );
+                               widget.focus();
+
+                               dynamicParamNameWidget.setValue( '' );
+                       },
+                       removeDynamicParamWidget = function ( name, layout ) {
+                               dynamicFieldset.removeItems( [ layout ] );
+                               delete that.widgets[ name ];
+                       };
+
+               this.$element.empty()
+                       .append( new OO.ui.ProgressBarWidget( {
+                               progress: false,
+                               text: mw.message( 'apisandbox-loading', 
this.displayText ).text()
+                       } ).$element );
+
+               Util.fetchModuleInfo( this.apiModule )
+                       .done( function ( pi ) {
+                               var prefix, i, j, dl, widget, $widgetLabel, 
widgetField, helpField, tmp, flag, count,
+                                       items = [],
+                                       deprecatedItems = [],
+                                       buttons = [],
+                                       filterFmModules = function ( v ) {
+                                               return v.substr( -2 ) !== 'fm' 
||
+                                                       
!availableFormats.hasOwnProperty( v.substr( 0, v.length - 2 ) );
+                                       },
+                                       widgetLabelOnClick = function () {
+                                               var f = this.getField();
+                                               if ( $.isFunction( 
f.setDisabled ) ) {
+                                                       f.setDisabled( false );
+                                               }
+                                               if ( $.isFunction( f.focus ) ) {
+                                                       f.focus();
+                                               }
+                                       },
+                                       doNothing = function () {};
+
+                               // This is something of a hack. We always want 
the 'format' and
+                               // 'action' parameters from the main module to 
be specified,
+                               // and for 'format' we also want to simplify 
the dropdown since
+                               // we always send the 'fm' variant.
+                               if ( that.apiModule === 'main' ) {
+                                       for ( i = 0; i < pi.parameters.length; 
i++ ) {
+                                               if ( pi.parameters[ i ].name 
=== 'action' ) {
+                                                       pi.parameters[ i 
].required = true;
+                                                       delete pi.parameters[ i 
][ 'default' ];
+                                               }
+                                               if ( pi.parameters[ i ].name 
=== 'format' ) {
+                                                       tmp = pi.parameters[ i 
].type;
+                                                       for ( j = 0; j < 
tmp.length; j++ ) {
+                                                               
availableFormats[ tmp[ j ] ] = true;
+                                                       }
+                                                       pi.parameters[ i ].type 
= $.grep( tmp, filterFmModules );
+                                                       pi.parameters[ i ][ 
'default' ] = 'json';
+                                                       pi.parameters[ i 
].required = true;
+                                               }
+                                       }
+                               }
+
+                               // Hide the 'wrappedhtml' parameter on format 
modules
+                               if ( pi.group === 'format' ) {
+                                       pi.parameters = $.grep( pi.parameters, 
function ( p ) {
+                                               return p.name !== 'wrappedhtml';
+                                       } );
+                               }
+
+                               that.paramInfo = pi;
+
+                               items.push( new OO.ui.FieldLayout(
+                                       new OO.ui.Widget( {} ).toggle( false ), 
{
+                                               align: 'top',
+                                               label: Util.parseHTML( 
pi.description )
+                                       }
+                               ) );
+
+                               if ( pi.helpurls.length ) {
+                                       buttons.push( new 
OO.ui.PopupButtonWidget( {
+                                               label: mw.message( 
'apisandbox-helpurls' ).text(),
+                                               icon: 'help',
+                                               popup: {
+                                                       $content: $( '<ul>' 
).append( $.map( pi.helpurls, function ( link ) {
+                                                               return $( 
'<li>' ).append( $( '<a>', {
+                                                                       href: 
link,
+                                                                       target: 
'_blank',
+                                                                       text: 
link
+                                                               } ) );
+                                                       } ) )
+                                               }
+                                       } ) );
+                               }
+
+                               if ( pi.examples.length ) {
+                                       buttons.push( new 
OO.ui.PopupButtonWidget( {
+                                               label: mw.message( 
'apisandbox-examples' ).text(),
+                                               icon: 'code',
+                                               popup: {
+                                                       $content: $( '<ul>' 
).append( $.map( pi.examples, function ( example ) {
+                                                               var a = $( 
'<a>', {
+                                                                       href: 
'#' + example.query,
+                                                                       html: 
example.description
+                                                               } );
+                                                               a.find( 'a' 
).contents().unwrap(); // Can't nest links
+                                                               return $( 
'<li>' ).append( a );
+                                                       } ) )
+                                               }
+                                       } ) );
+                               }
+
+                               if ( buttons.length ) {
+                                       items.push( new OO.ui.FieldLayout(
+                                               new OO.ui.ButtonGroupWidget( {
+                                                       items: buttons
+                                               } ), { align: 'top' }
+                                       ) );
+                               }
+
+                               if ( pi.parameters.length ) {
+                                       prefix = that.prefix + pi.prefix;
+                                       for ( i = 0; i < pi.parameters.length; 
i++ ) {
+                                               widget = 
Util.createWidgetForParameter( pi.parameters[ i ] );
+                                               that.widgets[ prefix + 
pi.parameters[ i ].name ] = widget;
+                                               if ( pi.parameters[ i 
].tokentype ) {
+                                                       that.tokenWidget = 
widget;
+                                               }
+
+                                               dl = $( '<dl>' );
+                                               dl.append( $( '<dd>', {
+                                                       addClass: 'description',
+                                                       append: Util.parseHTML( 
pi.parameters[ i ].description )
+                                               } ) );
+                                               if ( pi.parameters[ i ].info && 
pi.parameters[ i ].info.length ) {
+                                                       for ( j = 0; j < 
pi.parameters[ i ].info.length; j++ ) {
+                                                               dl.append( $( 
'<dd>', {
+                                                                       
addClass: 'info',
+                                                                       append: 
Util.parseHTML( pi.parameters[ i ].info[ j ] )
+                                                               } ) );
+                                                       }
+                                               }
+                                               flag = true;
+                                               count = 1e100;
+                                               switch ( pi.parameters[ i 
].type ) {
+                                                       case 'namespace':
+                                                               flag = false;
+                                                               count = 
mw.config.get( 'wgFormattedNamespaces' ).length;
+                                                               break;
+
+                                                       case 'limit':
+                                                               if ( 
pi.parameters[ i ].highmax !== undefined ) {
+                                                                       
dl.append( $( '<dd>', {
+                                                                               
addClass: 'info',
+                                                                               
append: Util.parseHTML( mw.message(
+                                                                               
        'api-help-param-limit2', pi.parameters[ i ].max, pi.parameters[ i 
].highmax
+                                                                               
).parse() )
+                                                                       } ) );
+                                                               } else {
+                                                                       
dl.append( $( '<dd>', {
+                                                                               
addClass: 'info',
+                                                                               
append: Util.parseHTML( mw.message(
+                                                                               
        'api-help-param-limit', pi.parameters[ i ].max
+                                                                               
).parse() )
+                                                                       } ) );
+                                                               }
+                                                               break;
+
+                                                       case 'integer':
+                                                               tmp = '';
+                                                               if ( 
pi.parameters[ i ].min !== undefined ) {
+                                                                       tmp += 
'min';
+                                                               }
+                                                               if ( 
pi.parameters[ i ].max !== undefined ) {
+                                                                       tmp += 
'max';
+                                                               }
+                                                               if ( tmp !== '' 
) {
+                                                                       
dl.append( $( '<dd>', {
+                                                                               
addClass: 'info',
+                                                                               
append: Util.parseHTML( mw.message(
+                                                                               
        'api-help-param-integer-' + tmp,
+                                                                               
        Util.apiBool( pi.parameters[ i ].multi ) ? 2 : 1,
+                                                                               
        pi.parameters[ i ].min, pi.parameters[ i ].max
+                                                                               
).parse() )
+                                                                       } ) );
+                                                               }
+                                                               break;
+
+                                                       default:
+                                                               if ( $.isArray( 
pi.parameters[ i ].type ) ) {
+                                                                       flag = 
false;
+                                                                       count = 
pi.parameters[ i ].type.length;
+                                                               }
+                                                               break;
+                                               }
+                                               if ( Util.apiBool( 
pi.parameters[ i ].multi ) ) {
+                                                       tmp = [];
+                                                       if ( flag && !( widget 
instanceof OO.ui.CapsuleMultiSelectWidget ) &&
+                                                               !(
+                                                                       widget 
instanceof OptionalWidget &&
+                                                                       
widget.widget instanceof OO.ui.CapsuleMultiSelectWidget
+                                                               )
+                                                       ) {
+                                                               tmp.push( 
mw.message( 'api-help-param-multi-separate' ).parse() );
+                                                       }
+                                                       if ( count > 
pi.parameters[ i ].lowlimit ) {
+                                                               tmp.push(
+                                                                       
mw.message( 'api-help-param-multi-max',
+                                                                               
pi.parameters[ i ].lowlimit, pi.parameters[ i ].highlimit
+                                                                       
).parse()
+                                                               );
+                                                       }
+                                                       if ( tmp.length ) {
+                                                               dl.append( $( 
'<dd>', {
+                                                                       
addClass: 'info',
+                                                                       append: 
Util.parseHTML( tmp.join( ' ' ) )
+                                                               } ) );
+                                                       }
+                                               }
+                                               helpField = new 
OO.ui.FieldLayout(
+                                                       new OO.ui.Widget( {
+                                                               $content: 
'\xa0',
+                                                               classes: [ 
'mw-apisandbox-spacer' ]
+                                                       } ), {
+                                                               align: 'inline',
+                                                               label: dl
+                                                       }
+                                               );
+
+                                               $widgetLabel = $( '<span>' );
+                                               widgetField = new 
OO.ui.FieldLayout(
+                                                       widget,
+                                                       {
+                                                               align: 'left',
+                                                               label: prefix + 
pi.parameters[ i ].name,
+                                                               $label: 
$widgetLabel
+                                                       }
+                                               );
+
+                                               // FieldLayout only does click 
for InputElement
+                                               // widgets. So supply our own 
click handler.
+                                               $widgetLabel.on( 'click', 
widgetLabelOnClick.bind( widgetField ) );
+
+                                               // Don't grey out the label 
when the field is disabled,
+                                               // it makes it too hard to read 
and our "disabled"
+                                               // isn't really disabled.
+                                               widgetField.onFieldDisable = 
doNothing;
+
+                                               if ( Util.apiBool( 
pi.parameters[ i ].deprecated ) ) {
+                                                       deprecatedItems.push( 
widgetField, helpField );
+                                               } else {
+                                                       items.push( 
widgetField, helpField );
+                                               }
+                                       }
+                               }
+
+                               if ( !pi.parameters.length && !Util.apiBool( 
pi.dynamicparameters ) ) {
+                                       items.push( new OO.ui.FieldLayout(
+                                               new OO.ui.Widget( {} ).toggle( 
false ), {
+                                                       align: 'top',
+                                                       label: Util.parseHTML( 
mw.message( 'apisandbox-no-parameters' ).parse() )
+                                               }
+                                       ) );
+                               }
+
+                               that.$element.empty();
+
+                               new OO.ui.FieldsetLayout( {
+                                       label: that.displayText
+                               } ).addItems( items )
+                                       .$element.appendTo( that.$element );
+
+                               if ( Util.apiBool( pi.dynamicparameters ) ) {
+                                       dynamicFieldset = new 
OO.ui.FieldsetLayout();
+                                       dynamicParamNameWidget = new 
OO.ui.TextInputWidget( {
+                                               placeholder: mw.message( 
'apisandbox-dynamic-parameters-add-placeholder' ).text()
+                                       } ).on( 'enter', addDynamicParamWidget 
);
+                                       dynamicFieldset.addItems( [
+                                               new OO.ui.FieldLayout(
+                                                       new OO.ui.Widget( {} 
).toggle( false ), {
+                                                               align: 'top',
+                                                               label: 
Util.parseHTML( pi.dynamicparameters )
+                                                       }
+                                               ),
+                                               new OO.ui.ActionFieldLayout(
+                                                       dynamicParamNameWidget,
+                                                       new OO.ui.ButtonWidget( 
{
+                                                               icon: 'add',
+                                                               flags: 
'constructive'
+                                                       } ).on( 'click', 
addDynamicParamWidget ),
+                                                       {
+                                                               label: 
mw.message( 'apisandbox-dynamic-parameters-add-label' ).text(),
+                                                               align: 'left'
+                                                       }
+                                               )
+                                       ] );
+                                       $( '<fieldset>' )
+                                               .append(
+                                                       $( '<legend>' ).text( 
mw.message( 'apisandbox-dynamic-parameters' ).text() ),
+                                                       dynamicFieldset.$element
+                                               )
+                                               .appendTo( that.$element );
+                               }
+
+                               if ( deprecatedItems.length ) {
+                                       tmp = new 
OO.ui.FieldsetLayout().addItems( deprecatedItems ).toggle( false );
+                                       $( '<fieldset>' )
+                                               .append(
+                                                       $( '<legend>' ).append(
+                                                               new 
OO.ui.ToggleButtonWidget( {
+                                                                       label: 
mw.message( 'apisandbox-deprecated-parameters' ).text()
+                                                               } ).on( 
'change', tmp.toggle, [], tmp ).$element
+                                                       ),
+                                                       tmp.$element
+                                               )
+                                               .appendTo( that.$element );
+                               }
+
+                               // Load stored params, if any, then update the 
booklet if we
+                               // have subpages (or else just update our 
valid-indicator).
+                               tmp = that.loadFromQueryParams;
+                               that.loadFromQueryParams = null;
+                               if ( $.isPlainObject( tmp ) ) {
+                                       that.loadQueryParams( tmp );
+                               }
+                               if ( that.getSubpages().length > 0 ) {
+                                       ApiSandbox.updateUI( tmp );
+                               } else {
+                                       that.apiCheckValid();
+                               }
+                       } ).fail( function ( code, detail ) {
+                               that.$element.empty()
+                                       .append(
+                                               new OO.ui.LabelWidget( {
+                                                       label: mw.message( 
'apisandbox-load-error', that.apiModule, detail ).text(),
+                                                       classes: [ 'error' ]
+                                               } ).$element,
+                                               new OO.ui.ButtonWidget( {
+                                                       label: mw.message( 
'apisandbox-retry' ).text()
+                                               } ).on( 'click', 
that.loadParamInfo, [], that ).$element
+                                       );
+                       } );
+       };
+
+       /**
+        * Check that all widgets on the page are in a valid state.
+        *
+        * @return {boolean}
+        */
+       ApiSandbox.PageLayout.prototype.apiCheckValid = function () {
+               var that = this;
+
+               if ( this.paramInfo === null ) {
+                       return $.Deferred().resolve( false ).promise();
+               } else {
+                       return $.when.apply( $, $.map( this.widgets, function ( 
widget ) {
+                               return widget.apiCheckValid();
+                       } ) ).then( function () {
+                               that.apiIsValid = $.inArray( false, arguments ) 
=== -1;
+                               if ( that.getOutlineItem() ) {
+                                       that.getOutlineItem().setIcon( 
that.apiIsValid || suppressErrors ? null : 'alert' );
+                                       that.getOutlineItem().setIconTitle(
+                                               that.apiIsValid || 
suppressErrors ? '' : mw.message( 'apisandbox-alert-page' ).plain()
+                                       );
+                               }
+                               return $.Deferred().resolve( that.apiIsValid 
).promise();
+                       } );
+               }
+       };
+
+       /**
+        * Load form fields from query parameters
+        *
+        * @param {Object} params
+        */
+       ApiSandbox.PageLayout.prototype.loadQueryParams = function ( params ) {
+               if ( this.paramInfo === null ) {
+                       this.loadFromQueryParams = params;
+               } else {
+                       $.each( this.widgets, function ( name, widget ) {
+                               var v = params.hasOwnProperty( name ) ? params[ 
name ] : undefined;
+                               widget.setApiValue( v );
+                       } );
+               }
+       };
+
+       /**
+        * Load query params from form fields
+        *
+        * @param {Object} params Write query parameters into this object
+        * @param {Object} displayParams Write query parameters for display 
into this object
+        */
+       ApiSandbox.PageLayout.prototype.getQueryParams = function ( params, 
displayParams ) {
+               $.each( this.widgets, function ( name, widget ) {
+                       var value = widget.getApiValue();
+                       if ( value !== undefined ) {
+                               params[ name ] = value;
+                               if ( $.isFunction( widget.getApiValueForDisplay 
) ) {
+                                       value = widget.getApiValueForDisplay();
+                               }
+                               displayParams[ name ] = value;
+                       }
+               } );
+       };
+
+       /**
+        * Fetch a list of subpage names loaded by this page
+        *
+        * @return {Array}
+        */
+       ApiSandbox.PageLayout.prototype.getSubpages = function () {
+               var ret = [];
+               $.each( this.widgets, function ( name, widget ) {
+                       var submodules, i;
+                       if ( $.isFunction( widget.getSubmodules ) ) {
+                               submodules = widget.getSubmodules();
+                               for ( i = 0; i < submodules.length; i++ ) {
+                                       ret.push( {
+                                               key: name + '=' + submodules[ i 
].value,
+                                               path: submodules[ i ].path,
+                                               prefix: 
widget.paramInfo.submoduleparamprefix || ''
+                                       } );
+                               }
+                       }
+               } );
+               return ret;
+       };
+
+       /**
+        * A text input with a clickable indicator
+        *
+        * @class
+        * @private
+        * @constructor
+        * @param {Object} [config] Configuration options
+        */
+       function TextInputWithIndicatorWidget( config ) {
+               var k;
+
+               config = config || {};
+               TextInputWithIndicatorWidget[ 'super' ].call( this, config );
+
+               this.$indicator = $( '<span>' ).addClass( 
'mw-apisandbox-clickable-indicator' );
+               OO.ui.mixin.TabIndexedElement.call(
+                       this, $.extend( {}, config, { $tabIndexed: 
this.$indicator } )
+               );
+
+               this.input = new OO.ui.TextInputWidget( $.extend( {
+                       $indicator: this.$indicator,
+                       disabled: this.isDisabled()
+               }, config.input ) );
+
+               // Forward most methods for convenience
+               for ( k in this.input ) {
+                       if ( $.isFunction( this.input[ k ] ) && !this[ k ] ) {
+                               this[ k ] = this.input[ k ].bind( this.input );
+                       }
+               }
+
+               this.$indicator.on( {
+                       click: this.onIndicatorClick.bind( this ),
+                       keypress: this.onIndicatorKeyPress.bind( this )
+               } );
+
+               this.$element.append( this.input.$element );
+       }
+       OO.inheritClass( TextInputWithIndicatorWidget, OO.ui.Widget );
+       OO.mixinClass( TextInputWithIndicatorWidget, 
OO.ui.mixin.TabIndexedElement );
+       TextInputWithIndicatorWidget.prototype.onIndicatorClick = function ( e 
) {
+               if ( !this.isDisabled() && e.which === 1 ) {
+                       this.emit( 'indicator' );
+               }
+               return false;
+       };
+       TextInputWithIndicatorWidget.prototype.onIndicatorKeyPress = function ( 
e ) {
+               if ( !this.isDisabled() && ( e.which === OO.ui.Keys.SPACE || 
e.which === OO.ui.Keys.ENTER ) ) {
+                       this.emit( 'indicator' );
+                       return false;
+               }
+       };
+       TextInputWithIndicatorWidget.prototype.setDisabled = function ( 
disabled ) {
+               TextInputWithIndicatorWidget[ 'super' 
].prototype.setDisabled.call( this, disabled );
+               if ( this.input ) {
+                       this.input.setDisabled( this.isDisabled() );
+               }
+               return this;
+       };
+
+       /**
+        * A wrapper for a widget that provides an enable/disable button
+        *
+        * @class
+        * @private
+        * @constructor
+        * @param {OO.ui.Widget} widget
+        * @param {Object} [config] Configuration options
+        */
+       function OptionalWidget( widget, config ) {
+               var k;
+
+               config = config || {};
+
+               this.widget = widget;
+               this.$overlay = config.$overlay ||
+                       $( '<div>' ).addClass( 
'mw-apisandbox-optionalWidget-overlay' );
+               this.checkbox = new OO.ui.CheckboxInputWidget( config.checkbox )
+                       .on( 'change', this.onCheckboxChange, [], this );
+
+               OptionalWidget[ 'super' ].call( this, config );
+
+               // Forward most methods for convenience
+               for ( k in this.widget ) {
+                       if ( $.isFunction( this.widget[ k ] ) && !this[ k ] ) {
+                               this[ k ] = this.widget[ k ].bind( this.widget 
);
+                       }
+               }
+
+               this.$overlay.on( 'click', this.onOverlayClick.bind( this ) );
+
+               this.$element
+                       .addClass( 'mw-apisandbox-optionalWidget' )
+                       .append(
+                               this.$overlay,
+                               $( '<div>' ).addClass( 
'mw-apisandbox-optionalWidget-fields' ).append(
+                                       $( '<div>' ).addClass( 
'mw-apisandbox-optionalWidget-widget' ).append(
+                                               widget.$element
+                                       ),
+                                       $( '<div>' ).addClass( 
'mw-apisandbox-optionalWidget-checkbox' ).append(
+                                               this.checkbox.$element
+                                       )
+                               )
+                       );
+
+               this.setDisabled( widget.isDisabled() );
+       }
+       OO.inheritClass( OptionalWidget, OO.ui.Widget );
+       OptionalWidget.prototype.onCheckboxChange = function ( checked ) {
+               this.setDisabled( !checked );
+       };
+       OptionalWidget.prototype.onOverlayClick = function () {
+               this.setDisabled( false );
+               if ( $.isFunction( this.widget.focus ) ) {
+                       this.widget.focus();
+               }
+       };
+       OptionalWidget.prototype.setDisabled = function ( disabled ) {
+               OptionalWidget[ 'super' ].prototype.setDisabled.call( this, 
disabled );
+               this.widget.setDisabled( this.isDisabled() );
+               this.checkbox.setSelected( !this.isDisabled() );
+               this.$overlay.toggle( this.isDisabled() );
+               return this;
+       };
+
+       $( ApiSandbox.init );
+
+}( jQuery, mediaWiki, OO ) );
diff --git 
a/resources/src/mediawiki.special/mediawiki.special.apisandbox.top.css 
b/resources/src/mediawiki.special/mediawiki.special.apisandbox.top.css
new file mode 100644
index 0000000..4dc4c27
--- /dev/null
+++ b/resources/src/mediawiki.special/mediawiki.special.apisandbox.top.css
@@ -0,0 +1,3 @@
+.client-js .mw-apisandbox-nojs {
+       display: none;
+}
diff --git a/resources/src/mediawiki/mediawiki.apipretty.css 
b/resources/src/mediawiki/mediawiki.apipretty.css
index fe5e634..99e4569 100644
--- a/resources/src/mediawiki/mediawiki.apipretty.css
+++ b/resources/src/mediawiki/mediawiki.apipretty.css
@@ -1,4 +1,4 @@
-h1.firstHeading {
+.mw-special-ApiHelp h1.firstHeading {
        display: none;
 }
 

-- 
To view, visit https://gerrit.wikimedia.org/r/209570
To unsubscribe, visit https://gerrit.wikimedia.org/r/settings

Gerrit-MessageType: merged
Gerrit-Change-Id: Ic42a6c5ef54b811cd63cfef2132942b27a626fe5
Gerrit-PatchSet: 31
Gerrit-Project: mediawiki/core
Gerrit-Branch: master
Gerrit-Owner: Anomie <[email protected]>
Gerrit-Reviewer: Addshore <[email protected]>
Gerrit-Reviewer: Alex Monk <[email protected]>
Gerrit-Reviewer: Anomie <[email protected]>
Gerrit-Reviewer: Bartosz Dziewoński <[email protected]>
Gerrit-Reviewer: Edokter <[email protected]>
Gerrit-Reviewer: Gergő Tisza <[email protected]>
Gerrit-Reviewer: Jack Phoenix <[email protected]>
Gerrit-Reviewer: Legoktm <[email protected]>
Gerrit-Reviewer: MaxSem <[email protected]>
Gerrit-Reviewer: Siebrand <[email protected]>
Gerrit-Reviewer: jenkins-bot <>

_______________________________________________
MediaWiki-commits mailing list
[email protected]
https://lists.wikimedia.org/mailman/listinfo/mediawiki-commits

Reply via email to