Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions docs/release-notes.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,16 @@
## Release Notes

### BootstrapComponents 6.1.0

Released on TBD

Fixes:
* fix dropdown menus, accordions and other click-driven components on Medik and Tweeki
* skins that provide their own Bootstrap (Chameleon, Medik and Tweeki) no longer also load Extension:Bootstrap's copy

Changes:
* `api.php?action=parse` no longer lists the `ext.bootstrap.*` modules; anything rendering parsed content outside a normal page view must load Bootstrap itself

### BootstrapComponents 6.0.1

Released on 20-July-2026
Expand Down
4 changes: 3 additions & 1 deletion extension.json
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,9 @@
}
},
"Hooks": {
"BeforePageDisplay": {
"handler": "BootStrapHooks"
},
"GalleryGetModes": {
"handler": "BootStrapHooks"
},
Expand Down Expand Up @@ -92,7 +95,6 @@
"styles": "ext.bootstrapComponents.bootstrap.fix.css"
},
"ext.bootstrapComponents.bootstrap": {
"dependencies": "ext.bootstrap.scripts",
"packageFiles": [ "ext.bootstrapComponents.bootstrap.js" ]
},
"ext.bootstrapComponents.accordion.fix": {
Expand Down
25 changes: 24 additions & 1 deletion modules/ext.bootstrapComponents.bootstrap.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,29 @@ function getComponentClass( name ) {
return ( plugin && plugin.Constructor ) || null;
}

// Defers component initialization until the module carrying the active skin's Bootstrap has
// loaded. A static ResourceLoader dependency cannot express this, since the module depends on
// the skin. The modules are provided in a config variable instead. The callback runs for the
// initial page content and again for every re-render (VisualEditor, live preview).
function whenReady( callback ) {
const bootstrapModules = mw.config.get( 'wgBootstrapComponentsBootstrapModules' );
if ( !bootstrapModules ) {
mw.log.warn( 'BootstrapComponents: no Bootstrap modules were configured for this skin.' );
return;
}
if ( bootstrapModules.styles ) {
mw.loader.load( bootstrapModules.styles );
}
mw.loader.using( bootstrapModules.scripts ).then( function () {
mw.hook( 'wikipage.content' ).add( function ( $content ) {
callback( ( $content && $content[ 0 ] ) || document );
} );
}, function () {
mw.log.warn( 'BootstrapComponents: Bootstrap module "' + bootstrapModules.scripts + '" failed to load.' );
} );
}

module.exports = {
getComponentClass: getComponentClass
getComponentClass: getComponentClass,
whenReady: whenReady
};
18 changes: 5 additions & 13 deletions modules/ext.bootstrapComponents.carousel.js
Original file line number Diff line number Diff line change
Expand Up @@ -26,25 +26,17 @@
( function () {
'use strict';

const { getComponentClass } = require( 'ext.bootstrapComponents.bootstrap' );
const { getComponentClass, whenReady } = require( 'ext.bootstrapComponents.bootstrap' );

// Wait for DOM to be ready
if ( document.readyState === 'loading' ) {
document.addEventListener( 'DOMContentLoaded', initCarousels );
} else {
initCarousels();
}

function initCarousels() {
whenReady( function ( content ) {
const Carousel = getComponentClass( 'Carousel' );
if ( !Carousel ) {
// eslint-disable-next-line no-console
console.warn( 'BootstrapComponents: bootstrap.Carousel is not available; carousels will not cycle.' );
return;
}
const carouselElements = document.querySelectorAll( '.carousel' );
carouselElements.forEach( function ( element ) {
new Carousel( element );
content.querySelectorAll( '.carousel' ).forEach( function ( element ) {
Carousel.getOrCreateInstance( element );
} );
}
} );
}() );
16 changes: 4 additions & 12 deletions modules/ext.bootstrapComponents.modal.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,16 +5,9 @@
( function () {
'use strict';

const { getComponentClass } = require( 'ext.bootstrapComponents.bootstrap' );
const { getComponentClass, whenReady } = require( 'ext.bootstrapComponents.bootstrap' );

// Wait for DOM to be ready
if ( document.readyState === 'loading' ) {
document.addEventListener( 'DOMContentLoaded', initModals );
} else {
initModals();
}

function initModals() {
whenReady( function ( content ) {
const Modal = getComponentClass( 'Modal' );
if ( !Modal ) {
// eslint-disable-next-line no-console
Expand All @@ -23,9 +16,8 @@
}
// Instantiate every .modal element so trigger clicks (or programmatic
// bootstrap.Modal.getOrCreateInstance(el).show()) work as expected.
const modalList = document.querySelectorAll( '.modal' );
modalList.forEach( function ( modalEl ) {
content.querySelectorAll( '.modal' ).forEach( function ( modalEl ) {
Modal.getOrCreateInstance( modalEl );
} );
}
} );
}() );
20 changes: 5 additions & 15 deletions modules/ext.bootstrapComponents.popover.js
Original file line number Diff line number Diff line change
Expand Up @@ -26,27 +26,17 @@
( function () {
'use strict';

const { getComponentClass } = require( 'ext.bootstrapComponents.bootstrap' );
const { getComponentClass, whenReady } = require( 'ext.bootstrapComponents.bootstrap' );

// Wait for DOM to be ready
if ( document.readyState === 'loading' ) {
document.addEventListener( 'DOMContentLoaded', initPopovers );
} else {
initPopovers();
}

function initPopovers() {
whenReady( function ( content ) {
const Popover = getComponentClass( 'Popover' );
if ( !Popover ) {
// eslint-disable-next-line no-console
console.warn( 'BootstrapComponents: bootstrap.Popover is not available; popover triggers will not work.' );
return;
}
const popoverTriggerList = document.querySelectorAll( '[data-bs-toggle="popover"]' );
popoverTriggerList.forEach( function ( popoverTriggerEl ) {
new Popover( popoverTriggerEl, {
html: true
} );
content.querySelectorAll( '[data-bs-toggle="popover"]' ).forEach( function ( el ) {
Popover.getOrCreateInstance( el, { html: true } );
} );
}
} );
}() );
18 changes: 5 additions & 13 deletions modules/ext.bootstrapComponents.tooltip.js
Original file line number Diff line number Diff line change
Expand Up @@ -26,25 +26,17 @@
( function () {
'use strict';

const { getComponentClass } = require( 'ext.bootstrapComponents.bootstrap' );
const { getComponentClass, whenReady } = require( 'ext.bootstrapComponents.bootstrap' );

// Wait for DOM to be ready
if ( document.readyState === 'loading' ) {
document.addEventListener( 'DOMContentLoaded', initTooltips );
} else {
initTooltips();
}

function initTooltips() {
whenReady( function ( content ) {
const Tooltip = getComponentClass( 'Tooltip' );
if ( !Tooltip ) {
// eslint-disable-next-line no-console
console.warn( 'BootstrapComponents: bootstrap.Tooltip is not available; tooltip triggers will not work.' );
return;
}
const tooltipTriggerList = document.querySelectorAll( '[data-bs-toggle="tooltip"]' );
tooltipTriggerList.forEach( function ( tooltipTriggerEl ) {
new Tooltip( tooltipTriggerEl );
content.querySelectorAll( '[data-bs-toggle="tooltip"]' ).forEach( function ( el ) {
Tooltip.getOrCreateInstance( el );
} );
}
} );
}() );
56 changes: 56 additions & 0 deletions src/BootstrapComponentsService.php
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,62 @@ public function vectorSkinInUse(): bool {
return in_array( strtolower( $this->getNameOfActiveSkin() ), [ 'vector', 'vector-2022' ] ) ;
}

/**
* Whether the given skin ships its own Bootstrap JavaScript.
*/
public function skinProvidesBootstrapScripts( string $skin ): bool {
return $this->getBootstrapScriptsModule( $skin ) !== 'ext.bootstrap.scripts';
}

/**
* The ResourceLoader script module carrying the Bootstrap this skin should use: the skin's own on
* skins that ship Bootstrap, Extension:Bootstrap's `ext.bootstrap.scripts` otherwise. The
* component initialization waits on this module.
*/
public function getBootstrapScriptsModule( string $skin ): string {
return match ( strtolower( $skin ) ) {
'medik' => 'skins.medik.js',
'tweeki' => $this->getTweekiScriptModule(),
default => 'ext.bootstrap.scripts',
};
}

/**
* Mirrors Tweeki's own script-module selection ({@see \SkinTweeki::initPage}), so we depend on
* whichever module the running wiki actually loads Bootstrap through.
*/
private function getTweekiScriptModule(): string {
if ( $this->mainConfig->has( 'TweekiSkinCustomScriptModule' )
&& $this->mainConfig->get( 'TweekiSkinCustomScriptModule' )
) {
$module = $this->mainConfig->get( 'TweekiSkinCustomScriptModule' );
// A shape we do not mirror; fall back to Extension:Bootstrap's copy.
return is_string( $module ) ? $module : 'ext.bootstrap.scripts';
}
if ( $this->mainConfig->has( 'TweekiSkinUseCustomFiles' )
&& $this->mainConfig->get( 'TweekiSkinUseCustomFiles' )
) {
return 'skins.tweeki.custom.scripts';
}
return 'skins.tweeki.scripts';
}

public function getBootstrapStylesModule( string $skin ): ?string {
return $this->skinProvidesBootstrapStyles( $skin ) ? null : 'ext.bootstrap.styles';
}

/**
* Skins that put the Bootstrap stylesheet on the page themselves. Chameleon belongs here but
* not in the scripts map: it registers Extension:Bootstrap's stylesheet under its own module
* name (zzz.ext.bootstrap.styles), which ResourceLoader cannot deduplicate against
* ext.bootstrap.styles, while its Bootstrap JavaScript is ext.bootstrap.scripts itself.
*/
private const SKINS_WITH_OWN_BOOTSTRAP_STYLES = [ 'chameleon', 'medik', 'tweeki' ];

public function skinProvidesBootstrapStyles( string $skin ): bool {
return in_array( strtolower( $skin ), self::SKINS_WITH_OWN_BOOTSTRAP_STYLES, true );
}

/**
* @param bool $useConfig set this to true, if we can't rely on {@see \RequestContext::getSkin}
*
Expand Down
51 changes: 46 additions & 5 deletions src/HooksHandler.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
use MediaWiki\Config\Config;
use MediaWiki\Extension\BootstrapComponents\Hooks\OutputPageParserOutput;
use MediaWiki\Extension\BootstrapComponents\Hooks\ParserFirstCallInit;
use MediaWiki\Hook\BeforePageDisplayHook;
use MediaWiki\Hook\GalleryGetModesHook;
use MediaWiki\Hook\ImageBeforeProduceHTMLHook;
use MediaWiki\Hook\InternalParseBeforeLinksHook;
Expand Down Expand Up @@ -37,6 +38,7 @@
* @since 5.0
*/
class HooksHandler implements
BeforePageDisplayHook,
GalleryGetModesHook,
ImageBeforeProduceHTMLHook,
InternalParseBeforeLinksHook,
Expand All @@ -45,6 +47,12 @@ class HooksHandler implements
ParserFirstCallInitHook,
SetupAfterCacheHook
{
/**
* The styles fix module added on every parsed page. onBeforePageDisplay reads it as the
* marker that the page rendered parsed content.
*/
private const BOOTSTRAP_FIX_MODULE = 'ext.bootstrapComponents.bootstrap.fix';

private Config $config;

public function __construct(
Expand All @@ -69,6 +77,40 @@ public static function onScribuntoExternalLibraries( $engine, array &$extraLibra
return true;
}

/**
* Hook: BeforePageDisplay
*
* Loads Bootstrap for the active skin: Extension:Bootstrap's stylesheet and script are each
* added only when the skin does not put its own on the page, so a single copy of each is on
* the page, and only where the page rendered parsed content, the same scope in which
* onParserAfterParse adds the styles fix. Component initialization waits on whichever module
* carries this skin's Bootstrap, the modules are named in a config variable set on every page:
* content can also arrive after page display (live preview on an edit page), and the init
* scripts loaded with it read the variable and fetch the named module on demand.
*
* @see https://www.mediawiki.org/wiki/Manual:Hooks/BeforePageDisplay
*/
public function onBeforePageDisplay( $out, $skin ): void {
$skinName = $skin->getSkinName();
$service = $this->getBootstrapComponentsService();

$out->addJsConfigVars( 'wgBootstrapComponentsBootstrapModules', [
'scripts' => $service->getBootstrapScriptsModule( $skinName ),
'styles' => $service->getBootstrapStylesModule( $skinName ),
] );

if ( !in_array( self::BOOTSTRAP_FIX_MODULE, $out->getModuleStyles(), true ) ) {
return;
}

if ( !$service->skinProvidesBootstrapStyles( $skinName ) ) {
$out->addModuleStyles( [ 'ext.bootstrap.styles' ] );
}
if ( !$service->skinProvidesBootstrapScripts( $skinName ) ) {
$out->addModules( [ 'ext.bootstrap.scripts' ] );
}
}

/**
* Hook: GalleryGetModes
*
Expand Down Expand Up @@ -194,11 +236,10 @@ public function onOutputPageParserOutput( $outputPage, $parserOutput ): void {
* @return bool
*/
public function onParserAfterParse( $parser, &$text, $stripState ): bool {
// once, this was only loaded, when a component was paced on the page. now, we load it always
// to keep the layout of all the wiki pages consistent.
$parser->getOutput()->addModuleStyles( [ 'ext.bootstrapComponents.bootstrap.fix' ] );
$parser->getOutput()->addModuleStyles( [ 'ext.bootstrap.styles' ] );
$parser->getOutput()->addModules( [ 'ext.bootstrap.scripts' ] );
// Always add the styles fix on parsed pages; it doubles as the marker onBeforePageDisplay
// reads to load Bootstrap for the active skin (which the shared, skin agnostic parser
// cache cannot decide).
$parser->getOutput()->addModuleStyles( [ self::BOOTSTRAP_FIX_MODULE ] );
$skin = $this->getBootstrapComponentsService()->getNameOfActiveSkin();
foreach ( $this->getBootstrapComponentsService()->getActiveComponents() as $activeComponent ) {
if ( !$this->getComponentLibrary()->isRegistered( $activeComponent ) ) {
Expand Down
Loading