"config-memory-bad": "'''Varoitus:''' PHP:n <code>memory_limit</code> on $1.\nTämä on luultavasti liian alhainen.\nAsennus saattaa epäonnistua!",
"config-xcache": "[http://xcache.lighttpd.net/ XCache] on asennettu",
"config-apc": "[http://www.php.net/apc APC] on asennettu.",
+ "config-apcu": "[http://www.php.net/apcu APCu] on asennettu",
"config-wincache": "[http://www.iis.net/download/WinCacheForPhp WinCache] on asennettu",
"config-diff3-bad": "GNU diff3:a ei löytynyt.",
"config-git": "Löydetty Git versionhallintaohjelmisto: <code>$1</code>",
foreach ( $modules as $name => $module ) {
try {
$content = $module->getModuleContent( $context );
+ $implementKey = $name . '@' . $module->getVersionHash( $context );
$strContent = '';
// Append output
$strContent = $scripts;
} elseif ( is_array( $scripts ) ) {
// ...except when $scripts is an array of URLs
- $strContent = self::makeLoaderImplementScript( $name, $scripts, [], [], [] );
+ $strContent = self::makeLoaderImplementScript( $implementKey, $scripts, [], [], [] );
}
break;
case 'styles':
}
}
$strContent = self::makeLoaderImplementScript(
- $name,
+ $implementKey,
$scripts,
isset( $content['styles'] ) ? $content['styles'] : [],
isset( $content['messagesBlob'] ) ? new XmlJsCode( $content['messagesBlob'] ) : [],
/**
* Return JS code that calls mw.loader.implement with given module properties.
*
- * @param string $name Module name
+ * @param string $name Module name or implement key (format "`[name]@[version]`")
* @param XmlJsCode|array|string $scripts Code as XmlJsCode (to be wrapped in a closure),
* list of URLs to JavaScript files, or a string of JavaScript for `$.globalEval`.
* @param mixed $styles Array of CSS strings keyed by media type, or an array of lists of URLs
}
if ( !$this->isSignup() && $this->showExtraInformation() ) {
$passwordReset = new PasswordReset( $this->getConfig(), AuthManager::singleton() );
- if ( $passwordReset->isAllowed( $this->getUser() ) ) {
+ if ( $passwordReset->isAllowed( $this->getUser() )->isGood() ) {
$fieldDefinitions['passwordReset'] = [
'type' => 'info',
'raw' => true,
"loginreqlink": "ўвайсьці",
"loginreqpagetext": "Вы мусіце $1, каб праглядаць іншыя старонкі.",
"accmailtitle": "Пароль адасланы",
- "accmailtext": "СÑ\82воÑ\80анÑ\8b адволÑ\8cнÑ\8b паÑ\80олÑ\8c длÑ\8f [[User talk:$1|$1]] бÑ\8bÑ\9e адаÑ\81ланÑ\8b па адÑ\80аÑ\81е $2. Яго можна зÑ\8cмÑ\8fнÑ\96Ñ\86Ñ\8c на Ñ\81Ñ\82аÑ\80онÑ\86Ñ\8b ''[[Special:ChangePassword|зÑ\8cменÑ\8b паÑ\80олÑ\8e]]'' пасьля ўваходу.",
+ "accmailtext": "Ð\92Ñ\8bпадковÑ\8b паÑ\80олÑ\8c длÑ\8f [[User talk:$1|$1]] бÑ\8bÑ\9e адаÑ\81ланÑ\8b па адÑ\80аÑ\81е $2. Яго можна зÑ\8cмÑ\8fнÑ\96Ñ\86Ñ\8c на Ñ\81Ñ\82аÑ\80онÑ\86Ñ\8b <em>[[Special:ChangePassword|зÑ\8cменÑ\8b паÑ\80олÑ\8e]]</em> пасьля ўваходу.",
"newarticle": "(Новая)",
"newarticletext": "Вы прыйшлі па спасылцы на старонку, якая яшчэ не існуе.\nКаб стварыць яе, напішыце тэкст у полі ніжэй (глядзіце [$1 старонку дапамогі] для дадатковай інфармацыі).\nКалі Вы трапілі сюды памылкова, націсьніце кнопку «<strong>назад</strong>» у вашым браўзэры.",
"anontalkpagetext": "----\n<em>Гэта старонка гутарак ананімнага ўдзельніка, які яшчэ не стварыў сабе рахунак альбо не ўжывае яго.</em>\nТаму мы вымушаныя ўжываць лічбавы IP-адрас дзеля ягонай ідэнтыфікацыі. Адзін IP-адрас можа выкарыстоўвацца некалькімі ўдзельнікамі. Калі Вы — ананімны ўдзельнік і лічыце, што атрымалі не прызначаныя Вам камэнтары, калі ласка, [[Special:CreateAccount|стварыце рахунак]] альбо [[Special:UserLogin|ўвайдзіце ў сыстэму]], каб у будучыні пазьбегнуць магчымай блытаніны зь іншымі ананімнымі ўдзельнікамі.",
"log-action-filter-delete-event": "Выдаленьне журналу",
"log-action-filter-delete-revision": "Выдаленьне вэрсіі",
"log-action-filter-import-interwiki": "Трансьвікі-імпарт",
+ "log-action-filter-import-upload": "Імпарт праз загрузку XML",
"log-action-filter-managetags-create": "Стварэньне метак",
"log-action-filter-managetags-delete": "Выдаленьне метак",
"log-action-filter-managetags-activate": "Актывацыя метак",
"talk": "Keskustelu",
"views": "Näkymät",
"toolbox": "Työkalut",
+ "tool-link-userrights": "Muokkaa {{GENDER:$1|käyttäjän}} ryhmiä",
+ "tool-link-emailuser": "Lähetä sähköpostia tälle {{GENDER:$1|käyttäjälle}}",
"userpage": "Näytä käyttäjäsivu",
"projectpage": "Näytä projektisivu",
"imagepage": "Näytä tiedostosivu",
"botpasswords-updated-body": "Bottisalasana käyttäjän \"$2\" bottinimelle \"$1\" päivitettiin.",
"botpasswords-deleted-title": "Bottisalasana poistettu",
"botpasswords-deleted-body": "Bottisalasana käyttäjän \"$2\" bottinimelle \"$1\" poistettiin.",
- "botpasswords-newpassword": "Uusi salasana kirjautumiseen käyttäjällä <strong>$1</strong> on <strong>$2</strong>. <em>Säilytä tämä myöhempää käyttöä varten.</em>",
+ "botpasswords-newpassword": "Uusi salasana kirjautumiseen käyttäjällä <strong>$1</strong> on <strong>$2</strong>. <em>Säilytä tämä myöhempää käyttöä varten.</em> <br> (Vanhoilla boteilla, jotka vaativat kirjautumisnimen olevan sama kuin lopullinen käyttäjänimi, voit myös käyttää nimeä <strong>$3</strong> ja salasanaa <strong>$4</strong>.)",
"botpasswords-no-provider": "BotPasswordsSessionProvider ei ole saatavilla.",
"botpasswords-restriction-failed": "Bottisalasanan rajoitukset estävät tämän sisäänkirjautumisen.",
"botpasswords-invalid-name": "Annetussa käyttäjätunnuksessa ei ole bottisalasanan erotinta (\"$1\").",
"passwordreset-emailsentusername": "Jos on olemassa vastaava rekisteröity sähköpostiosoite, salasanan uudistamisesta kertova viesti lähetetään.",
"passwordreset-emailsent-capture2": "Salasananpalautus{{PLURAL:$1|sähköposti|sähköpostit}} on lähetetty. {{PLURAL:$1|Käyttäjä ja salasana|Luettelo käyttäjistä ja salasanoista}} näytetään alapuolella.",
"passwordreset-emailerror-capture2": "Sähköpostin lähettäminen {{GENDER:$2|käyttäjälle}} epäonnistui: $1 {{PLURAL:$3|Käyttäjänimi ja salasana|Luettelo käyttäjänimistä ja salasanoista}} näytetään alla.",
+ "passwordreset-ignored": "Salasanan palauttamista ei käsitelty. Ehkä tarjoajaa ei ollut määritetty?",
"passwordreset-invalideamil": "Virheellinen sähköpostiosoite",
"passwordreset-nodata": "Käyttäjätunnusta ja salasanaa ei annettu",
"changeemail": "Muuta tai poista sähköpostiosoite",
"invalid-content-data": "Virheellinen sisältö",
"content-not-allowed-here": "Sivun [[$2]] sisältö ei voi olla tyyppiä $1.",
"editwarning-warning": "Tältä sivulta poistuminen saattaa aiheuttaa kaikkien tekemiesi muutosten katoamisen.\nJos olet kirjautunut sisään, voit poistaa tämän varoituksen käytöstä omien asetuksien osiossa \"{{int:prefs-editing}}\".",
+ "editpage-invalidcontentmodel-title": "Sisältömalli ei ole tuettu",
+ "editpage-invalidcontentmodel-text": "Sisältömalli \"$1\" ei ole tuettu.",
"editpage-notsupportedcontentformat-title": "Sisällön muotoa ei tueta",
"editpage-notsupportedcontentformat-text": "Sisällön muotoa $1 ei tueta sisältömallilla $2.",
"content-model-wikitext": "wikiteksti",
"content-model-css": "CSS",
"content-json-empty-object": "Tyhjä objekti",
"content-json-empty-array": "Tyhjä array",
+ "deprecated-self-close-category": "Sivut, joissa on virheellisiä itsensäsulkevia HTLM-tageja",
+ "deprecated-self-close-category-desc": "Sivulla on virheellisiä itsensäsulkevia HTML-tageja, kuten <code><b/></code> tai <code><span/></code>. Niiden käyttäytyminen muuttuu pian HTLM5:n määritysten mukaiseksi, joten niiden käyttö wikitekstissä on vanhentunut.",
"duplicate-args-warning": "<strong>Varoitus:</strong> [[:$1]] kutsuu mallinetta [[:$2]] niin, että parametrille \"$3\" on annettu enemmän kuin yksi arvo. Ainoastaan viimeksi annettu arvo otetaan huomioon.",
"duplicate-args-category": "Sivut, jotka käyttävät kaksinkertaisia argumentteja mallinekutsuissa",
"duplicate-args-category-desc": "Tämä sivu sisältää sellaisia mallinekutsuja, jotka käyttävät kaksi kertaa samaa argumenttia kuten <nowiki>{{foo|bar=1|bar=2}}</nowiki></code> taikka <code><nowiki>{{foo|bar|1=baz}}</nowiki></code>.",
"searchprofile-advanced-tooltip": "Etsi määritellyistä nimiavaruuksista",
"search-result-size": "$1 ({{PLURAL:$2|1 sana|$2 sanaa}})",
"search-result-category-size": "{{PLURAL:$1|1 jäsen|$1 jäsentä}} ({{PLURAL:$2|1 alaluokka|$2 alaluokkaa}}, {{PLURAL:$3|1 tiedosto|$3 tiedostoa}})",
- "search-redirect": "(ohjaus $1)",
+ "search-redirect": "(ohjaus sivulta $1)",
"search-section": "(osio $1)",
"search-category": "(luokka $1)",
"search-file-match": "(vastaa tiedoston sisältöä)",
"action-applychangetags": "käyttää merkkauksia muutostesi yhteydessä",
"action-changetags": "lisätä ja poistaa satunnaisia merkkauksia yksittäisissä sivuversioissa ja lokimerkinnöissä",
"action-deletechangetags": "poistaa merkkauksia tietokannasta",
+ "action-purge": "päivittää tämän sivun välimuistia",
"nchanges": "$1 {{PLURAL:$1|muutos|muutosta}}",
"enhancedrc-since-last-visit": "$1 {{PLURAL:$1|viimeisen käynnin jälkeen}}",
"enhancedrc-history": "historia",
"file-thumbnail-no": "Tiedostonimi alkaa merkkijonolla <strong>$1</strong>. Tiedosto näyttäisi olevan pienennetty kuva.\nJos sinulla on tämän kuvan alkuperäinen versio, tallenna se. Muussa tapauksessa nimeä tiedosto uudelleen.",
"fileexists-forbidden": "Samanniminen tiedosto on jo olemassa, eikä sen tilalle voi tallentaa uutta. \nJos kuitenkin haluat tallentaa tiedostosi, palaa takaisin ja käytä jotain toista nimeä. \n[[File:$1|thumb|center|$1]]",
"fileexists-shared-forbidden": "Samanniminen tiedosto on jo olemassa jaetussa mediavarastossa. Tallenna tiedosto jollakin toisella nimellä. [[File:$1|thumb|center|$1]]",
+ "fileexists-no-change": "Tallennettava tiedosto on tarkka kaksoiskappale tiedoston <strong>[[:$1]]</strong> nykyisestä versiosta.",
+ "fileexists-duplicate-version": "Tallennettava tiedosto on tarkka kaksoiskappale tiedoston <strong>[[:$1]]</strong> {{PLURAL:$2|vanhasta versiosta|vanhoista versioista}}.",
"file-exists-duplicate": "Tämä tiedosto on kaksoiskappale {{PLURAL:$1|seuraavasta tiedostosta|seuraavista tiedostoista}}:",
"file-deleted-duplicate": "Tiedosto, joka on identtinen tämän tiedoston kanssa ([[:$1]]) on aiemmin poistettu. Katso kyseisen tiedoston poistoloki ennen kuin jatkat uudelleentallentamista.",
"file-deleted-duplicate-notitle": "Tämän tiedoston kanssa samanlainen tiedosto on aikaisemmin poistettu ja tiedoston nimi on häivytetty.\nSinun on syytä pyytää jotakuta häivytettyjen tietojen näkemiseen oikeutettua käyttäjää katsomaan tiedoston tiedot asian arvioimiseksi ennen kuin jatkat tiedoston lataamista tietokantaan.",
"apisandbox-results-fixtoken": "Korjaa \"token\" ja lähetä uudelleen",
"apisandbox-alert-page": "Tällä sivulla olevat kentät eivät ole kelvollisia.",
"apisandbox-alert-field": "Tässä kentässä oleva arvo ei ole kelvollinen.",
+ "apisandbox-continue": "Jatka",
+ "apisandbox-continue-clear": "Tyhjennä",
"booksources": "Kirjalähteet",
"booksources-search-legend": "Etsi kirjalähteitä",
"booksources-isbn": "ISBN",
"undeletedrevisions": "{{PLURAL:$1|Yksi versio|$1 versiota}} palautettiin",
"undeletedrevisions-files": "{{PLURAL:$1|Yksi versio|$1 versiota}} ja {{PLURAL:$2|yksi tiedosto|$2 tiedostoa}} palautettiin",
"undeletedfiles": "{{PLURAL:$1|1 tiedosto|$1 tiedostoa}} palautettiin",
- "cannotundelete": "Palauttaminen epäonnistui:\n$1",
+ "cannotundelete": "Palauttaminen epäonnistui osittain tai kokonaan:\n$1",
"undeletedpage": "'''$1 on palautettu.'''\n\n[[Special:Log/delete|Poistolokista]] löydät listan viimeisimmistä poistoista ja palautuksista.",
"undelete-header": "[[Special:Log/delete|Poistolokissa]] on lista viimeisimmistä poistoista.",
"undelete-search-title": "Etsi poistettuja sivuja",
"sp-contributions-newbies-sub": "Uusien käyttäjien muokkaukset",
"sp-contributions-newbies-title": "Uusien käyttäjien muokkaukset",
"sp-contributions-blocklog": "estoloki",
- "sp-contributions-suppresslog": "häivytetyt käyttäjän muokkaukset",
- "sp-contributions-deleted": "poistetut muokkaukset",
+ "sp-contributions-suppresslog": "häivytetyt {{GENDER:$1|käyttäjän}} muokkaukset",
+ "sp-contributions-deleted": "poistetut {{GENDER:$1|käyttäjän}} muokkaukset",
"sp-contributions-uploads": "tallennukset",
"sp-contributions-logs": "lokit",
"sp-contributions-talk": "keskustelu",
"pageinfo-article-id": "Sivun tunnistenumero",
"pageinfo-language": "Sivun sisällön kieli",
"pageinfo-content-model": "Sivun sisältömalli",
+ "pageinfo-content-model-change": "muuta",
"pageinfo-robot-policy": "Hakukonemerkinnät",
"pageinfo-robot-index": "Indeksoitava",
"pageinfo-robot-noindex": "Ei indeksoitava",
"tag-filter": "[[Special:Tags|Merkkausten]] suodatin:",
"tag-filter-submit": "Suodata",
"tag-list-wrapper": "([[Special:Tags|{{PLURAL:$1|Merkkaus|Merkkaukset}}]]: $2)",
+ "tag-mw-contentmodelchange": "sisältömallin muutos",
+ "tag-mw-contentmodelchange-description": "Muokkaukset, jotka [https://www.mediawiki.org/wiki/Special:MyLanguage/Help:ChangeContentModel muuttavat sivun sisältömallia]",
"tags-title": "Merkkaukset",
"tags-intro": "Tämä sivu luetteloi ne merkkaukset (''engl.'' tags), joilla ohjelmisto voi merkitä muokkauksia, ja mitä ne tarkoittavat.",
"tags-tag": "Merkkauksen nimi",
"htmlform-date-placeholder": "VVVV-KK-PP",
"htmlform-time-placeholder": "TT:MM:SS",
"htmlform-datetime-placeholder": "VVVV-KK-PP TT:MM:SS",
+ "htmlform-date-invalid": "Annettu arvo ei ole tunnistettava päivämäärä. Kokeile muotoa VVVV-KK-PP.",
+ "htmlform-time-invalid": "Annettu arvo ei ole tunnistettava aika. Kokeile muotoa TT:MM:SS.",
+ "htmlform-datetime-invalid": "Annettu arvo ei ole tunnistettava päivämäärä ja aika. Kokeile muotoa VVVV-KK-PP TT:MM:SS.",
+ "htmlform-date-toolow": "Annettu arvo on ennen aikaisinta sallittua päivämäärää $1.",
+ "htmlform-date-toohigh": "Annettu arvo on viimeisen sallitun päivämäärän $1 jälkeen.",
+ "htmlform-time-toolow": "Annettu arvo on ennen aikaisinta sallittua aikaa $1.",
+ "htmlform-time-toohigh": "Annettu arvo on viimeisen sallitun ajan $1 jälkeen.",
+ "htmlform-datetime-toolow": "Annettu arvo on ennen aikaisinta sallittua päivämäärää ja aikaa $1.",
+ "htmlform-datetime-toohigh": "Annettu arvo on viimeisen sallitun päivämäärän ja ajan $1 jälkeen.",
"htmlform-title-badnamespace": "Sivu [[:$1]] ei ole nimiavaruudessa ”{{ns:$2}}”.",
"htmlform-title-not-creatable": "”$1” ei kelpaa sivun nimeksi.",
"htmlform-title-not-exists": "Sivua $1 ei ole olemassa.",
"pageinfo-redirectsto-info": "informação",
"pageinfo-contentpage": "Contada como página de conteúdo",
"pageinfo-contentpage-yes": "Sim",
- "pageinfo-protect-cascading": "A protecção é em cascata a partir daqui",
+ "pageinfo-protect-cascading": "A proteção é em cascata a partir daqui",
"pageinfo-protect-cascading-yes": "Sim",
"pageinfo-protect-cascading-from": "As proteções são em cascata a partir de",
"pageinfo-category-info": "Informações da categoria",
"autosumm-replace": "Pagină înlocuită cu „$1”",
"autoredircomment": "Redirecționat înspre [[$1]]",
"autosumm-new": "Pagină nouă: $1",
- "autosumm-newblank": "A creat o pagină goală",
+ "autosumm-newblank": "Creat o pagină goală",
"size-bytes": "{{PLURAL:$1|un octet|$1 octeți|$1 de octeți}}",
"size-pixel": "$1 {{PLURAL:$1|pixel|pixeli|de pixeli}}",
"lag-warn-normal": "Modificările mai noi de $1 {{PLURAL:$1|secondă|seconde}} pot să nu apară în listă.",
"upload-copy-upload-invalid-domain": "Примерци отпремања нису доступни на овом домену.",
"upload-dialog-title": "Отпремање датотека",
"upload-dialog-button-cancel": "Откажи",
+ "upload-dialog-button-back": "Назад",
"upload-dialog-button-done": "Готово",
"upload-dialog-button-save": "Сачувај",
"upload-dialog-button-upload": "Пошаљи",
@import "mediawiki.ui/variables";
@import "mediawiki.ui/mixins";
-// Placeholder text styling helper
-.field-placeholder-styling() {
- font-style: italic;
- font-weight: normal;
-}
// Text inputs
//
// Apply the mw-ui-input class to input and textarea fields.
//
// Styleguide 1.1.
.mw-ui-input {
+ background-color: #fff;
.box-sizing( border-box );
display: block;
width: 100%;
border: 1px solid @colorFieldBorder;
border-radius: @borderRadius;
padding: 0.3em 0.3em 0.3em 0.6em;
+ // necessary for smooth transition
+ box-shadow: inset 0 0 0 0.1em #fff;
font-family: inherit;
font-size: inherit;
line-height: inherit;
vertical-align: middle;
- // Placeholder text styling must be set individually for each browser @winter
- &::-webkit-input-placeholder { // webkit
- .field-placeholder-styling;
+ // Normalize & style placeholder text, see T139034
+ // Placeholder styles can't be grouped, otherwise they're ignored as invalid.
+
+ // Placeholder mixin
+ .mixin-placeholder() {
+ color: @colorGray7;
+ font-style: italic;
+ }
+ // Firefox 4-18
+ &:-moz-placeholder { // stylelint-disable-line selector-no-vendor-prefix
+ .mixin-placeholder;
+ opacity: 1;
+ }
+ // Firefox 19-
+ &::-moz-placeholder { // stylelint-disable-line selector-no-vendor-prefix
+ .mixin-placeholder;
+ opacity: 1;
}
- &::-moz-placeholder { // FF 4-18
- .field-placeholder-styling;
+ // Internet Explorer 10-11
+ &:-ms-input-placeholder { // stylelint-disable-line selector-no-vendor-prefix
+ .mixin-placeholder;
}
- &:-moz-placeholder { // FF >= 19
- .field-placeholder-styling;
+ // WebKit, Blink, Edge
+ // Don't set `opacity < 1`, see https://developer.microsoft.com/en-us/microsoft-edge/platform/issues/3901363/
+ &::-webkit-input-placeholder { // stylelint-disable-line selector-no-vendor-prefix
+ .mixin-placeholder;
}
- &:-ms-input-placeholder { // IE >= 10
- .field-placeholder-styling;
+ // W3C Standard Selectors Level 4
+ &:placeholder-shown {
+ .mixin-placeholder;
}
- // Remove red outline from inputs which have required field and invalid content.
- // This is a Firefox only issue
+ // Firefox: Remove red outline when `required` attribute set and invalid content.
// See https://developer.mozilla.org/en-US/docs/Web/CSS/:invalid
- // This should be above :focus so focus behaviour takes preference
+ // This should come before `:focus` so latter rules take preference.
&:invalid {
box-shadow: none;
}
+ &:hover {
+ border-color: @colorGray7;
+ }
+
&:focus {
border-color: @colorProgressive;
box-shadow: inset 0 0 0 1px @colorProgressive;
outline: 0;
}
+ // `:not()` is used exclusively for `transition`s as both are not supported by IE < 9.
+ &:not( :disabled ) {
+ .transition( ~'color 100ms, border-color 100ms, box-shadow 100ms' );
+ }
+
&:disabled {
border-color: @colorGray14;
color: @colorGray12;
// Correct the odd appearance in Chrome and Safari 5
-webkit-appearance: textfield;
- // Remove proprietary clear button in IE 10-11
+ // Remove proprietary clear button in IE 10-11, Edge 12+
&::-ms-clear {
display: none;
}
}
}
+ /**
+ * Make a versioned key for a specific module.
+ *
+ * @private
+ * @param {string} module Module name
+ * @return {string|null} Module key in format '`[name]@[version]`',
+ * or null if the module does not exist
+ */
+ function getModuleKey( module ) {
+ return hasOwn.call( registry, module ) ?
+ ( module + '@' + registry[ module ].version ) : null;
+ }
+
+ /**
+ * @private
+ * @param {string} key Module name or '`[name]@[version]`'
+ * @return {Object}
+ */
+ function splitModuleKey( key ) {
+ var index = key.indexOf( '@' );
+ if ( index === -1 ) {
+ return { name: key };
+ }
+ return {
+ name: key.slice( 0, index ),
+ version: key.slice( index )
+ };
+ }
+
/* Public Members */
return {
/**
* When #load() or #using() requests one or more modules, the server
* response contain calls to this function.
*
- * @param {string} module Name of module
+ * @param {string} module Name of module and current module version. Formatted
+ * as '`[name]@[version]`". This version should match the requested version
+ * (from #batchRequest and #registry). This avoids race conditions (T117587).
+ * For back-compat with MediaWiki 1.27 and earlier, the version may be omitted.
* @param {Function|Array|string} [script] Function with module code, list of URLs
* to load via `<script src>`, or string of module code for `$.globalEval()`.
* @param {Object} [style] Should follow one of the following patterns:
* @param {Object} [templates] List of key/value pairs to be added to mw#templates.
*/
implement: function ( module, script, style, messages, templates ) {
+ var split = splitModuleKey( module ),
+ name = split.name,
+ version = split.version;
// Automatically register module
- if ( !hasOwn.call( registry, module ) ) {
- mw.loader.register( module );
+ if ( !hasOwn.call( registry, name ) ) {
+ mw.loader.register( name );
}
// Check for duplicate implementation
- if ( hasOwn.call( registry, module ) && registry[ module ].script !== undefined ) {
- throw new Error( 'module already implemented: ' + module );
+ if ( hasOwn.call( registry, name ) && registry[ name ].script !== undefined ) {
+ throw new Error( 'module already implemented: ' + name );
+ }
+ if ( version ) {
+ // Without this reset, if there is a version mismatch between the
+ // requested and received module version, then mw.loader.store would
+ // cache the response under the requested key. Thus poisoning the cache
+ // indefinitely with a stale value. (T117587)
+ registry[ name ].version = version;
}
// Attach components
- registry[ module ].script = script || null;
- registry[ module ].style = style || null;
- registry[ module ].messages = messages || null;
- registry[ module ].templates = templates || null;
+ registry[ name ].script = script || null;
+ registry[ name ].style = style || null;
+ registry[ name ].messages = messages || null;
+ registry[ name ].templates = templates || null;
// The module may already have been marked as erroneous
- if ( $.inArray( registry[ module ].state, [ 'error', 'missing' ] ) === -1 ) {
- registry[ module ].state = 'loaded';
- if ( allReady( registry[ module ].dependencies ) ) {
- execute( module );
+ if ( $.inArray( registry[ name ].state, [ 'error', 'missing' ] ) === -1 ) {
+ registry[ name ].state = 'loaded';
+ if ( allReady( registry[ name ].dependencies ) ) {
+ execute( name );
}
}
},
MODULE_SIZE_MAX: 100 * 1000,
- // The contents of the store, mapping '[module name]@[version]' keys
+ // The contents of the store, mapping '[name]@[version]' keys
// to module implementations.
items: {},
].join( ':' );
},
- /**
- * Get a key for a specific module. The key format is '[name]@[version]'.
- *
- * @param {string} module Module name
- * @return {string|null} Module key or null if module does not exist
- */
- getModuleKey: function ( module ) {
- return hasOwn.call( registry, module ) ?
- ( module + '@' + registry[ module ].version ) : null;
- },
-
/**
* Initialize the store.
*
return false;
}
- key = mw.loader.store.getModuleKey( module );
+ key = getModuleKey( module );
if ( key in mw.loader.store.items ) {
mw.loader.store.stats.hits++;
return mw.loader.store.items[ key ];
return false;
}
- key = mw.loader.store.getModuleKey( module );
+ key = getModuleKey( module );
if (
// Already stored a copy of this exact version
try {
args = [
- JSON.stringify( module ),
+ JSON.stringify( key ),
typeof descriptor.script === 'function' ?
String( descriptor.script ) :
JSON.stringify( descriptor.script ),
for ( key in mw.loader.store.items ) {
module = key.slice( 0, key.indexOf( '@' ) );
- if ( mw.loader.store.getModuleKey( module ) !== key ) {
+ if ( getModuleKey( module ) !== key ) {
mw.loader.store.stats.expired++;
delete mw.loader.store.items[ key ];
} else if ( mw.loader.store.items[ key ].length > mw.loader.store.MODULE_SIZE_MAX ) {
use Psr\Log\NullLogger;
abstract class ResourceLoaderTestCase extends MediaWikiTestCase {
+ // Version hash for a blank file module.
+ // Result of ResourceLoader::makeHash(), ResourceLoaderTestModule
+ // and ResourceLoaderFileModule::getDefinitionSummary().
+ const BLANK_VERSION = '09p30q0';
+
/**
* @param string $lang
* @param string $dir
/**
* @dataProvider getMultiWithSetCallback_provider
- * @covers WANObjectCache::geMultitWithSetCallback()
+ * @covers WANObjectCache::getMultitWithSetCallback()
* @covers WANObjectCache::makeMultiKeys()
* @param array $extOpts
* @param bool $versioned
*/
class ResourceLoaderClientHtmlTest extends PHPUnit_Framework_TestCase {
+ protected static function expandVariables( $text ) {
+ return strtr( $text, [
+ '{blankVer}' => ResourceLoaderTestCase::BLANK_VERSION
+ ] );
+ }
+
protected static function makeContext( $extraQuery = [] ) {
$conf = new HashConfig( [
'ResourceLoaderSources' => [],
. '<script>(window.RLQ=window.RLQ||[]).push(function(){'
. 'mw.config.set({"key":"value"});'
. 'mw.loader.state({"test.exempt":"ready","test.private.top":"loading","test.styles.pure":"ready","test.styles.private":"ready","test.scripts.top":"loading"});'
- . 'mw.loader.implement("test.private.top",function($,jQuery,require,module){},{"css":[]});'
+ . 'mw.loader.implement("test.private.top@{blankVer}",function($,jQuery,require,module){},{"css":[]});'
. 'mw.loader.load(["test.top"]);'
. 'mw.loader.load("/w/load.php?debug=false\u0026lang=nl\u0026modules=test.scripts.top\u0026only=scripts\u0026skin=fallback");'
. '});</script>' . "\n"
. '<style>.private{}</style>' . "\n"
. '<script async="" src="/w/load.php?debug=false&lang=nl&modules=startup&only=scripts&skin=fallback"></script>';
// @codingStandardsIgnoreEnd
+ $expected = self::expandVariables( $expected );
$this->assertEquals( $expected, $client->getHeadHtml() );
}
// @codingStandardsIgnoreStart Generic.Files.LineLength
$expected = '<script>(window.RLQ=window.RLQ||[]).push(function(){'
- . 'mw.loader.implement("test.private.bottom",function($,jQuery,require,module){},{"css":[]});'
+ . 'mw.loader.implement("test.private.bottom@{blankVer}",function($,jQuery,require,module){},{"css":[]});'
. 'mw.loader.load("/w/load.php?debug=false\u0026lang=nl\u0026modules=test.scripts\u0026only=scripts\u0026skin=fallback");'
. 'mw.loader.load(["test"]);'
. '});</script>';
// @codingStandardsIgnoreEnd
+ $expected = self::expandVariables( $expected );
$this->assertEquals( $expected, $client->getBodyHtml() );
}
'context' => [],
'modules' => [ 'test.private.top' ],
'only' => ResourceLoaderModule::TYPE_COMBINED,
- 'output' => '<script>(window.RLQ=window.RLQ||[]).push(function(){mw.loader.implement("test.private.top",function($,jQuery,require,module){},{"css":[]});});</script>',
+ 'output' => '<script>(window.RLQ=window.RLQ||[]).push(function(){mw.loader.implement("test.private.top@{blankVer}",function($,jQuery,require,module){},{"css":[]});});</script>',
],
[
'context' => [],
$context = self::makeContext( $extraQuery );
$context->getResourceLoader()->register( self::makeSampleModules() );
$actual = ResourceLoaderClientHtml::makeLoad( $context, $modules, $type );
+ $expected = self::expandVariables( $expected );
$this->assertEquals( $expected, (string)$actual );
}
}
class ResourceLoaderStartUpModuleTest extends ResourceLoaderTestCase {
- // Version hash for a blank file module.
- // Result of ResourceLoader::makeHash(), ResourceLoaderTestModule
- // and ResourceLoaderFileModule::getDefinitionSummary().
- protected static $blankVersion = '09p30q0';
-
protected static function expandPlaceholders( $text ) {
return strtr( $text, [
- '{blankVer}' => self::$blankVersion
+ '{blankVer}' => self::BLANK_VERSION
] );
}
( function ( mw, $ ) {
- QUnit.module( 'mediawiki (mw.loader)' );
+ QUnit.module( 'mediawiki (mw.loader)', QUnit.newMwEnvironment( {
+ setup: function () {
+ mw.loader.store.enabled = false;
+ },
+ teardown: function () {
+ mw.loader.store.enabled = false;
+ }
+ } ) );
mw.loader.addSource(
'testloader',
} );
} );
+ QUnit.test( 'Stale response caching - T117587', function ( assert ) {
+ var count = 0;
+ mw.loader.store.enabled = true;
+ mw.loader.register( 'test.stale', 'v2' );
+ assert.strictEqual( mw.loader.store.get( 'test.stale' ), false, 'Not in store' );
+
+ mw.loader.implement( 'test.stale@v1', function () {
+ count++;
+ } );
+
+ return mw.loader.using( 'test.stale' )
+ .then( function () {
+ assert.strictEqual( count, 1 );
+ assert.strictEqual( mw.loader.getState( 'test.stale' ), 'ready' );
+ assert.ok( mw.loader.store.get( 'test.stale' ), 'In store' );
+ } )
+ .then( function () {
+ // Reset run time, but keep mw.loader.store
+ mw.loader.moduleRegistry[ 'test.stale' ].script = undefined;
+ mw.loader.moduleRegistry[ 'test.stale' ].state = 'registered';
+ mw.loader.moduleRegistry[ 'test.stale' ].version = 'v2';
+
+ // Module was stored correctly as v1
+ // On future navigations, it will be ignored until evicted
+ assert.strictEqual( mw.loader.store.get( 'test.stale' ), false, 'Not in store' );
+ } );
+ } );
+
+ QUnit.test( 'Stale response caching - backcompat', function ( assert ) {
+ var count = 0;
+ mw.loader.store.enabled = true;
+ mw.loader.register( 'test.stalebc', 'v2' );
+ assert.strictEqual( mw.loader.store.get( 'test.stalebc' ), false, 'Not in store' );
+
+ mw.loader.implement( 'test.stalebc', function () {
+ count++;
+ } );
+
+ return mw.loader.using( 'test.stalebc' )
+ .then( function () {
+ assert.strictEqual( count, 1 );
+ assert.strictEqual( mw.loader.getState( 'test.stalebc' ), 'ready' );
+ assert.ok( mw.loader.store.get( 'test.stalebc' ), 'In store' );
+ } )
+ .then( function () {
+ // Reset run time, but keep mw.loader.store
+ mw.loader.moduleRegistry[ 'test.stalebc' ].script = undefined;
+ mw.loader.moduleRegistry[ 'test.stalebc' ].state = 'registered';
+ mw.loader.moduleRegistry[ 'test.stalebc' ].version = 'v2';
+
+ // Legacy behaviour is storing under the expected version,
+ // which woudl lead to whitewashing and stale values (T117587).
+ assert.ok( mw.loader.store.get( 'test.stalebc' ), 'In store' );
+ } );
+ } );
+
QUnit.test( 'require()', 6, function ( assert ) {
mw.loader.register( [
[ 'test.require1', '0' ],