Skin:Lift/Development guide

The Lift skin was developed using the SkinMustache class, available in Mediawiki 1.36 and above. This is a development guide to show how it was made so that other skin developers will have an idea as to what they can do when developing skins using the SkinMustache class.

The goals of this development guide are to show how to:

  1. initially set up the skin,
  2. add frameworks such as Bootstrap,
  3. customize the css and js,
  4. control the return of JSON data,
  5. perform basic styling,
  6. take advantage of the API,
  7. clean up templates, skin.json, and skin directory, and
  8. pass along more data to the template through hooks.

Initially set up the skin

edit

The initial folder for a skin using SkinMustache

edit

Although a skin folder can have a variety of structures, the Example skin provides a decent starting point. I personally had difficulties downloading and installing the files from the Github repository, so I recommend downloading from the Mediawiki Skin:Example page.

Install the skin per the directions and in the wiki under Preferences change the skin to Example. For a new wiki installation, the Main Page should now look something like this:

 
Main page using the Example skin



To convert this to our new skin:

  1. Rename the "Example" folder to "Lift"
  2. Now in the "Lift" folder, delete the .phan folder and all files in the root EXCEPT skin.json
  3. Open skin.json and change all instances of "Example" to "Lift" being careful to maintain case sensitivity
  4. In LocalSettings.php change wfLoadSkin( 'Example' ); to wfLoadSkin( 'Lift' );
  5. In the wiki under Preferences change the skin to Lift

Checking our wiki, the Main Page should look the same as it did when the skin was named Example.

Finally, edit skin.json to update the other information for your new skin (i.e., "version", "author", etc.).


SkinJSON to aid in developing and troubleshooting

edit

SkinJSON is a useful tool to allow us to see the data returned by SkinMustache. Download the files from the SkinJSON Github repository into a SkinJSON folder under our Skins directory and enter wfLoadSkin( 'SkinJson' ); in LocalSetting.php.

You will see SkinJSON now available as a skin under your preferences as jsonskin but DO NOT UPDATE YOUR PREFERENCES TO USE jsonskin. We will access the json returned by SkinMustache class indirectly through the following url:

basewikiurl/Main_Page?useskin=lift&useformat=json

This will show all the data that is passed to the Lift skin in a json format.

When viewing the wiki normally, the SkinMustache class is merging this json data with the mustache files inside the templates folder to form the HTML to render the page.

Of interest here are the various items under "data-portlets", for example the "data-personal":

    "data-portlets": {
        ...,
        "data-personal": {
            "id": "p-personal",
            "class": "mw-portlet mw-portlet-personal",
            "html-tooltip": "",
            "html-items": "<li id=\"pt-userpage\"><a href=\"\/beta\/wiki\/index.php?title=User:Loren_Maxwell\" class=\"new\" dir=\"auto\" title=\"Your user page (page does not exist) [.]\" accesskey=\".\">Loren Maxwell<\/a><\/li><li id=\"pt-mytalk\"><a href=\"\/beta\/wiki\/index.php?title=User_talk:Loren_Maxwell\" class=\"new\" title=\"Your talk page (page does not exist) [n]\" accesskey=\"n\">Talk<\/a><\/li><li id=\"pt-preferences\"><a href=\"\/beta\/wiki\/index.php?title=Special:Preferences\" title=\"Your preferences\">Preferences<\/a><\/li><li id=\"pt-watchlist\"><a href=\"\/beta\/wiki\/index.php?title=Special:Watchlist\" title=\"A list of pages you are monitoring for changes [l]\" accesskey=\"l\">Watchlist<\/a><\/li><li id=\"pt-mycontris\"><a href=\"\/beta\/wiki\/index.php?title=Special:Contributions\/Loren_Maxwell\" title=\"A list of your contributions [y]\" accesskey=\"y\">Contributions<\/a><\/li><li id=\"pt-logout\"><a href=\"\/beta\/wiki\/index.php?title=Special:UserLogout&amp;returnto=Main+Page&amp;returntoquery=useskin%3Dlift%26useformat%3Djson\" data-mw=\"interface\" title=\"Log out\">Log out<\/a><\/li>",
            "html-after-portal": "",
            "label": "Personal tools"
        },
        ...,


Data-portlets are used to form our menus, with each portlet being an individual menu. For the data-personal, the core Mediawiki software determines what menu options will be available to the user on each page, the SkinMustache class converts those options into json data (shown as html-items here), and the mustache files in the templates directory form it into html to display on the wiki.


Add frameworks such as Bootstrap

edit

To incorporate other frameworks, in this case Bootstrap, Animate, Hover, and Font Awesome, download the folders and files of those frameworks into the resources folder:

 
Lift skin with Animate, Bootstrap, Font Awesome, and Hover added as resources

To incorporate the Roboto front from Google, create a font_roboto.css file and place it in the resources folder:

/* cyrillic-ext */
@font-face {
  font-family: 'Roboto';
  font-style: normal;
  font-weight: 400;
  src: url(https://fonts.gstatic.com/s/roboto/v29/KFOmCnqEu92Fr1Mu72xKOzY.woff2) format('woff2');
  unicode-range: U+0460-052F, U+1C80-1C88, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;
}
/* cyrillic */
@font-face {
  font-family: 'Roboto';
  font-style: normal;
  font-weight: 400;
  src: url(https://fonts.gstatic.com/s/roboto/v29/KFOmCnqEu92Fr1Mu5mxKOzY.woff2) format('woff2');
  unicode-range: U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
}
/* greek-ext */
@font-face {
  font-family: 'Roboto';
  font-style: normal;
  font-weight: 400;
  src: url(https://fonts.gstatic.com/s/roboto/v29/KFOmCnqEu92Fr1Mu7mxKOzY.woff2) format('woff2');
  unicode-range: U+1F00-1FFF;
}
/* greek */
@font-face {
  font-family: 'Roboto';
  font-style: normal;
  font-weight: 400;
  src: url(https://fonts.gstatic.com/s/roboto/v29/KFOmCnqEu92Fr1Mu4WxKOzY.woff2) format('woff2');
  unicode-range: U+0370-03FF;
}
/* vietnamese */
@font-face {
  font-family: 'Roboto';
  font-style: normal;
  font-weight: 400;
  src: url(https://fonts.gstatic.com/s/roboto/v29/KFOmCnqEu92Fr1Mu7WxKOzY.woff2) format('woff2');
  unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+1EA0-1EF9, U+20AB;
}
/* latin-ext */
@font-face {
  font-family: 'Roboto';
  font-style: normal;
  font-weight: 400;
  src: url(https://fonts.gstatic.com/s/roboto/v29/KFOmCnqEu92Fr1Mu7GxKOzY.woff2) format('woff2');
  unicode-range: U+0100-024F, U+0259, U+1E00-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
/* latin */
@font-face {
  font-family: 'Roboto';
  font-style: normal;
  font-weight: 400;
  src: url(https://fonts.gstatic.com/s/roboto/v29/KFOmCnqEu92Fr1Mu4mxK.woff2) format('woff2');
  unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}


The resources folder:

 
Lift skin with the Roboto font added as resources



Next, the location of these resources need to be identified to the skin using ResourcesModules in skin.json (note that I have only kept two of the original four style sheets, screen-common.less and screen-desktop.less, and I am no longer using conditional loading; later we'll also be deleting those two style sheets as we move toward using Bootstrap for the skin):

	"ResourceModules": {
		"skins.lift": {
			"class": "ResourceLoaderSkinModule",
			"features": {
				"normalize": true,
				"elements": true,
				"content": true,
				"content-parser-output": true,
				"interface": true,
				"logo": true
			},
			"styles": [
                "resources/screen-common.less",
                "resources/screen-desktop.less",
			    "resources/bootstrap/css/bootstrap.min.css",
			    "resources/hover/css/hover-min.css",
			    "resources/animate/animate.min.css",
			    "resources/font_roboto.css"
			]
		},
		"skins.thrust.js": {
			"scripts": [
			    "resources/bootstrap/js/bootstrap.bundle.min.js",
			    "resources/fontawesome/js/all.min.js",
				"resources/main.js"
			]
		}
	},


At this point all of our resources are located in the "resources" directory, so we can simplify our skin.json file by updating the localBasePath under ResourceFileModulePaths to point to the "resources" folder (and remember to remove the leading "resources/" fragment from the styles and scripts themselves):

	"ResourceFileModulePaths": {
		"localBasePath": "resources"
	},
	"ResourceModules": {
		"skins.lift": {
			"class": "ResourceLoaderSkinModule",
			"features": {
				"normalize": true,
				"elements": true,
				"content": true,
				"content-parser-output": true,
				"interface": true,
				"logo": true
			},
			"styles": [
                "screen-common.less",
                "screen-desktop.less",
			    "bootstrap/css/bootstrap.min.css",
			    "hover/css/hover-min.css",
			    "animate/animate.min.css",
			    "font_roboto.css"
			]
		},
		"skins.thrust.js": {
			"scripts": [
			    "bootstrap/js/bootstrap.bundle.min.js",
			    "fontawesome/js/all.min.js",
				"main.js"
			]
		}
	},

Customize the css and js

edit

We can also use the ResourceModules to load our own css and js files.

For now, create two files, lift.css and lift.js in the resources root directory:

 
Lift skin with lift.css and lift.js added as resources


Now edit skin.json to identify the two custom files under ResourceModules:

	"ResourceFileModulePaths": {
		"localBasePath": "resources"
	},
	"ResourceModules": {
		"skins.lift": {
			"class": "ResourceLoaderSkinModule",
			"features": {
				"normalize": true,
				"elements": true,
				"content": true,
				"content-parser-output": true,
				"interface": true,
				"logo": true
			},
			"styles": [
                "screen-common.less",
                "screen-desktop.less",
			    "bootstrap/css/bootstrap.min.css",
			    "hover/css/hover-min.css",
			    "animate/animate.min.css",
			    "font_roboto.css",
			    "lift.css"
			]
		},
		"skins.thrust.js": {
			"scripts": [
			    "bootstrap/js/bootstrap.bundle.min.js",
			    "fontawesome/js/all.min.js",
			    "lift.js",
				"main.js"
			]
		}
	},


Finally, edit the lift.css file to specify Roboto should be used as the font for the entire body element:

body {
    font-family: 'Roboto', sans-serif;
}


At this point, we have added all the resources we'll use and reserve the ability to edit lift.css and lift.js more later. However, looking at our wiki's Main page it will show that the font is now Roboto because it was loaded under font_roboto.css and the font for the body element was specified in lift.css (compare this to the screenshot above for the Example skin without any modifications to see the difference in fonts):

 
Main page using the Lift skin showing the Roboto font


In the next few sections, we'll use move all of the "Navigation menu" portion of this page into two different navigation bars, one at the top of the page and one just below the page title.


Perform basic styling

edit
edit

Now that all the resources are loaded, let's create a fixed-top header with the site logo and search bar for the site.

First, let's use the SkinJSON skin to look at the relevant JSON returned from the SkinMustache class by navigating to the url:

yourwikibaseurl/wiki/Main_Page?useskin=lift&useformat=json

For my test wiki this shows:

    ...,
    "data-logos": {
        "1x": "\/beta\/images\/Testwiki.png",
        "1.5x": "\/beta\/images\/Testwiki.png",
        "2x": "\/beta\/images\/Testwiki.png",
        "svg": "\/beta\/images\/Testwiki.png",
        "icon": "\/beta\/images\/Testwiki.png",
        "wordmark": null,
        "tagline": null
    },
    ...,
    "data-search-box": {
        "form-action": "\/beta\/wiki\/index.php",
        "html-button-search-fallback": "<input type=\"submit\" name=\"fulltext\" value=\"Search\" title=\"Search the pages for this text\" id=\"mw-searchButton\" class=\"searchButton mw-fallbackSearchButton\"\/>",
        "html-button-search": "<input type=\"submit\" name=\"go\" value=\"Go\" title=\"Go to a page with this exact name if it exists\" id=\"searchButton\" class=\"searchButton\"\/>",
        "html-input": "<input type=\"search\" name=\"search\" placeholder=\"Search Test wiki\" autocapitalize=\"sentences\" title=\"Search Test wiki [f]\" accesskey=\"f\" id=\"searchInput\"\/>",
        "msg-search": "Search",
        "page-title": "Special:Search"
    },
    ...,


Next, let's look in the templates folder at the skin.mustache file. The SkinMustache class uses to template construct the HTML from the above JSON. Note that lines 5 through 42 is the specific portion we are moving into a main menu across the top of the page and the menu below the page title.

<div id="mw-wrapper">
	<div class="mw-body" id="content" role="main">
        <...>
    </div>
	<div id="mw-navigation">
		<h2>{{msg-navigation-heading}}</h2>
		<div id="p-logo" class="mw-portlet" role="banner">
			<a href="{{link-mainpage}}">
			{{#data-logos}}
				{{#icon}}<img src="{{.}}" width="40" height="40">{{/icon}}
				{{#wordmark}}<img src="{{src}}" width="{{width}}" height="{{height}}">{{/wordmark}}
				{{^wordmark}}<h1>{{msg-sitetitle}}</h1>{{/wordmark}}
			{{/data-logos}}
			</a>
		</div>
		{{#data-search-box}}
		<form action="{{form-action}}" role="search" class="mw-portlet" id="p-search">
			<input type="hidden" name="title" value="{{page-title}}">
			<h3>
				<label for="searchInput">{{msg-search}}</label>
			</h3>
			{{{html-input}}}
			{{{html-button-search}}}
		</form>
		{{/data-search-box}}
		{{#data-portlets}}
		<div id="user-tools">
			{{#data-personal}}{{>Portlet}}{{/data-personal}}
		</div>
		<div id="page-tools">
			{{#data-namespaces}}{{>Portlet}}{{/data-namespaces}}
			{{#data-variants}}{{>Portlet}}{{/data-variants}}
			{{#data-views}}{{>Portlet}}{{/data-views}}
			{{#data-actions}}{{>Portlet}}{{/data-actions}}
		</div>
		{{/data-portlets}}
		<div id="site-navigation">
			{{#data-portlets-sidebar.data-portlets-first}}{{>Portlet}}{{/data-portlets-sidebar.data-portlets-first}}
			{{#data-portlets-sidebar.array-portlets-rest}}{{>Portlet}}{{/data-portlets-sidebar.array-portlets-rest}}
			{{#data-portlets.data-languages}}{{>Portlet}}{{/data-portlets.data-languages}}
		</div>
	</div>
	<div id="footer" class="mw-footer" role="contentinfo" {{{html-user-language-attributes}}}>
        <...>
	</div>
</div>

If you're not familiar with Mustache (and Handlebars), then it would be worth reviewing a good tutorial first. It would also be worth quickly comparing the JSON and the template with the actual html generated for the main page to see how the page is constructed.

At the very top of the skin.mustache template, add {{>header}} to include header.mustache as a partial template. Also, start to introduce Boostrap classes into the templates by adding the classes "container-xxl", "pt5", "mt-5" to the mw-wrapper div. This will help keep the size of the main content consistent while also adding some space at the top for our header:

{{>header}}

<div id="mw-wrapper" class="container-xxl pt5 mt-5">
	<div class="mw-body" id="content" role="main">
        <...>
    </div>
	<div id="mw-navigation">
		<h2>{{msg-navigation-heading}}</h2>
		<div id="p-logo" class="mw-portlet" role="banner">
			<a href="{{link-mainpage}}">
			{{#data-logos}}
				{{#icon}}<img src="{{.}}" width="40" height="40">{{/icon}}
				{{#wordmark}}<img src="{{src}}" width="{{width}}" height="{{height}}">{{/wordmark}}
				{{^wordmark}}<h1>{{msg-sitetitle}}</h1>{{/wordmark}}
			{{/data-logos}}
			</a>
		</div>
		{{#data-search-box}}
		<form action="{{form-action}}" role="search" class="mw-portlet" id="p-search">
			<input type="hidden" name="title" value="{{page-title}}">
			<h3>
				<label for="searchInput">{{msg-search}}</label>
			</h3>
			{{{html-input}}}
			{{{html-button-search}}}
		</form>
		{{/data-search-box}}
        <...>
	</div>
	<div id="footer" class="mw-footer" role="contentinfo" {{{html-user-language-attributes}}}>
        <...>
	</div>
</div>

Now create the file header.mustache in the templates directory and enter this code.

<header class="container-fluid fixed-top bg-white border-bottom d-print-none">
	<nav class="navbar navbar-expand navbar-light py-0">
		<div class="container-fluid">
		    
		    {{! Logo}}
            <div class="navbar-brand py-0">
            	<div id="p-logo" role="banner">
            		<a href="{{link-mainpage}}">
            		{{#data-logos}}
            		{{#icon}}<img src="{{.}}" height="45">{{/icon}}
            		{{/data-logos}}
            		</a>
            	</div>
            </div>
            
            {{! Search}}
            {{#data-search-box}}
            <form action="{{form-action}}" role="search" class="mw-portlet border-start w-100 mx-3" id="p-search">
                <div class="form-floating">
                    <input type="search" name="search" autocapitalize="sentences" class="form-control border-0" id="searchInput" placeholder="{{msg-search}}">
                    <label for="searchInput">{{msg-search}}</label>
                    <div class="d-none">{{{html-button-search}}}</div>
                </div>
            </form>
            {{/data-search-box}}
            
		</div>
	</nav>
</header>

Your templates folder should now look like this:

 
Lift skin with header.mustache added as a template

Now refresh the wiki and the main page should look like this:

 
Main page using the Lift skin showing the header with the logo and the search bar styled by Bootstrap

We were able to previously change the site to the Roboto font, and now, using Boostrap classes, we are able to easily make the new header in a fixed position at the top of the page. It has the site logo and a fully functional search bar using Bootstrap's floating labels. The search bar as a button, but in the template we added the class "d-none", so while it is operational it does not display. Additionally, we've taken the first steps to move fully toward a Bootstrap skin by adding the "container-xxl" class to the mw-wrapper div, although it didn't change the appearance.

Take a moment to navigate around the site to see how various pages look with the header at the top and the Roboto font.

Next we'll tackle the menu items.

Control the return of JSON data

edit

Currently the menus are returned in data-portlets and data-portlets-sidebar:

{
    ...,
    "data-portlets": {
        "data-user-menu": {
            ...
        },
        "data-notifications": {
            ...
        },
        "data-namespaces": {
            ...
        },
        "data-views": {
            ...
        },
        "data-actions": {
            ...
        },
        "data-variants": {
            ...
        },
        "data-personal": {
            ...
        }
    },
    "data-portlets-sidebar": {
        "data-portlets-first": {
            ...
        },
        "array-portlets-rest": [
            {
                ...
            }
        ]
    },
    ...
    }
}


A closer look at one of the menu items, data-personal, shows that the JSON returned from SkinMustache already forms the html for us under the member html-items:

{
    ...,
    "data-portlets": {
        ...,
        "data-personal": {
            ...,
            "html-items": "<li id=\"pt-userpage\"><a href=\"\/beta\/wiki\/index.php?title=User:Loren_Maxwell\" class=\"new\" dir=\"auto\" title=\"Your user page (page does not exist) [.]\" accesskey=\".\">Loren Maxwell<\/a><\/li><li id=\"pt-mytalk\"><a href=\"\/beta\/wiki\/index.php?title=User_talk:Loren_Maxwell\" class=\"new\" title=\"Your talk page (page does not exist) [n]\" accesskey=\"n\">Talk<\/a><\/li><li id=\"pt-preferences\"><a href=\"\/beta\/wiki\/index.php?title=Special:Preferences\" title=\"Your preferences\">Preferences<\/a><\/li><li id=\"pt-watchlist\"><a href=\"\/beta\/wiki\/index.php?title=Special:Watchlist\" title=\"A list of pages you are monitoring for changes [l]\" accesskey=\"l\">Watchlist<\/a><\/li><li id=\"pt-mycontris\"><a href=\"\/beta\/wiki\/index.php?title=Special:Contributions\/Loren_Maxwell\" title=\"A list of your contributions [y]\" accesskey=\"y\">Contributions<\/a><\/li><li id=\"pt-logout\"><a href=\"\/beta\/wiki\/index.php?title=Special:UserLogout&amp;returnto=Main+Page&amp;returntoquery=useskin%3Dlift%26useformat%3Djson\" data-mw=\"interface\" title=\"Log out\">Log out<\/a><\/li>",
            ...
        },
        ...
    },
    "data-portlets-sidebar": {
        ...
    },
    "data-footer": {
        ...
   }
}


While this preformmated html from SkinMustache may help many skin developers, at the same time it prevents us from templating the JSON to take advantage of Bootstrap classes to style our own menus.

To fix this, we must make our own skin class, in this case SkinLift, that will extend SkinMustache so that we have control over the JSON that is returned for the menus.

First, create a new folder called includes, and inside that folder create a file called SkinLift.php:

 
Lift skin with SkinLift.php added as an include


For now, enter this into the SkinLift.php file:

<?php

class SkinLift extends SkinMustache {
}

Update the json.skin file to 1) autoload the SkinLift class from "includes/SkinLift.php" and 2) change the class under "ValidSkinNames"."lift" from "SkinMustache" to "SkinLift":

{
	...,
	"ValidSkinNames": {
		"lift": {
			"class": "SkinLift",
			"args": [ {
				"name": "Lift",
				"templateDirectory": "skins/Lift/templates",
				"styles": [
					"skins.lift"
				],
				"messages": [
					"sitetitle",
					"search",
					"tagline",
					"navigation-heading"
				],
				"scripts": [
					"skins.lift.js"
				]
			} ]
		}
	},
	"AutoloadClasses": {
		"SkinLift": "includes/SkinLift.php"
	},
	...
}


Refresh the wiki to ensure everything works. The page should be the same although the class returning the JSON is now SkinLift.

To get our new class to return an array of html attributes as array-links rather than fully formed html, we need to overwrite SkinMustache's getPortletData function to the following:

<?php

class SkinLift extends SkinMustache {

	
	/**
	 * Extends getPortletData function
	 */
	protected function getPortletData( $label, array $urls = [] )
	{
		$data = parent::getPortletData( $label, $urls );

		// Sanitize and standardize links
		foreach ( $urls as $key => $item ) {
		    
		    $item['text'] ??= (!is_int($key) ? wfMessage( $key )->text() : '');
		    
		    if ($item['class'] == 'selected') $item['class'] = 'active';
		    
		    if ($item['links']) {
		        $item = $item['links'][0];
		        unset($item['links']);
		    }
		
			$data['array-links'][] = [is_int($key) ? $item['text'] : $key => $item];
		}

		return $data;
	}

}

Save SkinLift.php and now look at the JSON returned from SkinLift using SkinJSON:

{
    ...,
    "data-portlets": {
        "data-user-menu": {
            "id": "p-personal",
            "class": "mw-portlet mw-portlet-personal",
            "html-tooltip": "",
            "html-items": "<li id=\"pt-userpage\"><a href=\"\/beta\/wiki\/index.php?title=User:Loren_Maxwell\" class=\"new\" dir=\"auto\" title=\"Your user page (page does not exist) [.]\" accesskey=\".\">Loren Maxwell<\/a><\/li><li id=\"pt-mytalk\"><a href=\"\/beta\/wiki\/index.php?title=User_talk:Loren_Maxwell\" class=\"new\" title=\"Your talk page (page does not exist) [n]\" accesskey=\"n\">Talk<\/a><\/li><li id=\"pt-preferences\"><a href=\"\/beta\/wiki\/index.php?title=Special:Preferences\" title=\"Your preferences\">Preferences<\/a><\/li><li id=\"pt-watchlist\"><a href=\"\/beta\/wiki\/index.php?title=Special:Watchlist\" title=\"A list of pages you are monitoring for changes [l]\" accesskey=\"l\">Watchlist<\/a><\/li><li id=\"pt-mycontris\"><a href=\"\/beta\/wiki\/index.php?title=Special:Contributions\/Loren_Maxwell\" title=\"A list of your contributions [y]\" accesskey=\"y\">Contributions<\/a><\/li><li id=\"pt-logout\"><a href=\"\/beta\/wiki\/index.php?title=Special:UserLogout&amp;returnto=Main+Page&amp;returntoquery=useskin%3Dlift%26useformat%3Djson\" data-mw=\"interface\" title=\"Log out\">Log out<\/a><\/li>",
            "html-after-portal": "",
            "label": "Personal tools",
            "array-links": [
                {
                    "userpage": {
                        "single-id": "pt-userpage",
                        "href": "\/beta\/wiki\/index.php?title=User:Loren_Maxwell",
                        "class": "new",
                        "text": "Loren Maxwell",
                        "dir": "auto",
                        "exists": false
                    }
                },
                {
                    "mytalk": {
                        "single-id": "pt-mytalk",
                        "href": "\/beta\/wiki\/index.php?title=User_talk:Loren_Maxwell",
                        "class": "new",
                        "text": "Talk",
                        "exists": false
                    }
                },
                {
                    "preferences": {
                        "single-id": "pt-preferences",
                        "href": "\/beta\/wiki\/index.php?title=Special:Preferences",
                        "text": "Preferences"
                    }
                },
                {
                    "watchlist": {
                        "single-id": "pt-watchlist",
                        "href": "\/beta\/wiki\/index.php?title=Special:Watchlist",
                        "text": "Watchlist"
                    }
                },
                {
                    "mycontris": {
                        "single-id": "pt-mycontris",
                        "href": "\/beta\/wiki\/index.php?title=Special:Contributions\/Loren_Maxwell",
                        "text": "Contributions"
                    }
                },
                {
                    "logout": {
                        "single-id": "pt-logout",
                        "href": "\/beta\/wiki\/index.php?title=Special:UserLogout&returnto=Main+Page&returntoquery=useskin%3Dlift%26useformat%3Djson",
                        "text": "Log out",
                        "data-mw": "interface"
                    }
                }
            ]
        },
        ...,
    },
    ...
}

The new function in SkinLift has added a new member to each data-portlet called array-links, which holds the html attributes to make links rather than the preformed html. With this new JSON available we can return to header.mustache to add dropdown menus to the header as well, such as for the data-personal data-porlet, along with some added css to make our header responsive when the time comes:

<header class="container-fluid fixed-top bg-white border-bottom d-print-none">
	<nav class="navbar navbar-expand navbar-light py-0">
		<div class="container-fluid flex-wrap flex-sm-nowrap">
            <div class="navbar-collapse w-100 justify-content-between justify-content-sm-start">
                
                {{! Logo}}
                <div class="navbar-brand py-0">
                	<div id="p-logo" role="banner">
                		<a href="{{link-mainpage}}">
                		{{#data-logos}}
                		{{#icon}}<img src="{{.}}" height="45">{{/icon}}
                		{{/data-logos}}
                		</a>
                	</div>
                </div>
                
                {{! Search}}
                {{#data-search-box}}
                <form action="{{form-action}}" role="search" class="mw-portlet border-start w-100 mx-3" id="p-search">
                    <div class="form-floating">
                        <input type="search" name="search" autocapitalize="sentences" class="form-control border-0" id="searchInput" placeholder="{{msg-search}}">
                        <label for="searchInput">{{msg-search}}</label>
                        <div class="d-none">{{{html-button-search}}}</div>
                    </div>
                </form>
                {{/data-search-box}}
                
    		</div>
            <div class="navbar-collapse justify-content-between">
    
                {{! Personal menu}}
                {{#data-portlets.data-personal}}
                    <div class="dropdown">
                    <a class="btn{{#class}} {{.}}{{/class}}" href="#" role="button" id="{{id}}" title="{{label}}" data-bs-toggle="dropdown" aria-expanded="false">
                    <i class="fas fa-user fa-fw"></i>
                    </a>
                      <ul class="dropdown-menu dropdown-menu-sm-end" aria-labelledby="{{id}}">
                        {{#each array-links}}
                            {{#each .}}<li><a id="{{single-id}}" class="dropdown-item{{#class}} {{.}}{{/class}}" href="{{href}}">{{text}}</a></li>{{/each}}
                        {{/each}}
                      </ul>
                    </div>
                {{/data-portlets.data-personal}}
    
            </div>
        </div>
	</nav>
</header>
 
Main page using the Lift skin showing the header with the logo, the search bar, and the user-personal data-portlet (far right) styled by Bootstrap with the Users icon from Font Awesome

Also, without too much effort using the template's if and unless functions. we can separate our login/logout button as well:

	<nav class="navbar navbar-expand navbar-light py-0">
		<div class="container-fluid flex-wrap flex-sm-nowrap">
            <div class="navbar-collapse w-100 justify-content-between justify-content-sm-start">

                {{! Logo}}
                <div class="navbar-brand py-0">
                	<div id="p-logo" role="banner">
                		<a href="{{link-mainpage}}">
                		{{#data-logos}}
                		{{#icon}}<img src="{{.}}" height="45">{{/icon}}
                		{{/data-logos}}
                		</a>
                	</div>
                </div>

                {{! Search}}
                {{#data-search-box}}
                <form action="{{form-action}}" role="search" class="mw-portlet border-start w-100 mx-3" id="p-search">
                    <div class="form-floating">
                        <input type="search" name="search" autocapitalize="sentences" class="form-control border-0" id="searchInput" placeholder="{{msg-search}}">
                        <label for="searchInput">{{msg-search}}</label>
                        <div class="d-none">{{{html-button-search}}}</div>
                    </div>
                </form>
                {{/data-search-box}}
                
    		</div>
            <div class="navbar-collapse justify-content-end">
    
                {{! Personal menu}}
                {{#data-portlets.data-personal}}
                    <div class="dropdown">
                    <a class="btn{{#class}} {{.}}{{/class}}" href="#" role="button" id="{{id}}" title="{{label}}" data-bs-toggle="dropdown" aria-expanded="false">
                    <i class="fas fa-user fa-fw"></i>
                    </a>
                      <ul class="dropdown-menu dropdown-menu-sm-end" aria-labelledby="{{id}}">
                        {{#each array-links}}
                            {{#unless login}}
                                {{#unless logout}}
                                        {{#each .}}<li><a id="{{single-id}}" class="dropdown-item{{#class}} {{.}}{{/class}}" href="{{href}}">{{text}}</a></li>{{/each}}
                                {{/unless}}
                            {{/unless}}
                        {{/each}}
                      </ul>
                    </div>
                {{/data-portlets.data-personal}}
                
                {{! Separate login/logout button}}
                {{#data-portlets.data-personal.array-links}}
                    {{#if login}}
                        {{#each .}}
                            <a class="btn{{#class}} {{.}}{{/class}}" title="{{text}}" href="{{href}}" role="button"><i class="fas fa-sign-in-alt fa-fw"></i></a>
                        {{/each}}
                    {{/if}}
                {{/data-portlets.data-personal.array-links}}
                {{#data-portlets.data-personal.array-links}}
                    {{#if logout}}
                        {{#each .}}
                            <a class="btn{{#class}} {{.}}{{/class}}" title="{{text}}" href="{{href}}" role="button"><i class="fas fa-sign-out-alt fa-fw"></i></a>
                        {{/each}}
                    {{/if}}
                {{/data-portlets.data-personal.array-links}}
                
            </div>
        </div>
    </nav>
</header>

And the resulting page:

 
Main page using the Lift skin showing the header with the logo, the search bar, and the user-personal data-portlet (far right) with the logout button separated, all styled by Bootstrap with icons from Font Awesome

Additionally, we can add a menu below the Main Page title for our data-namespaces, data-views, data-actions, and data-variants data-portlets through the mustache partial pagemenu:

{{>header}}

<div id="mw-wrapper" class="container-xxl pt5 mt-5">
	<div class="mw-body" id="content" role="main">
	    ...
		<div class="mw-body-content">
			<div id="contentSub">
				{{{html-subtitle}}}
				{{{html-undelete-link}}}
			</div>
			
            {{>pagemenu}}
            
			{{{html-body-content}}}
    	    ...
		</div>
	    ...
	</div>
    ...
</div>

The pagemenu.mustache file:

<nav class="navbar navbar-expand navbar-light bg-white border-bottom d-print-none">
	<div class="container-fluid flex-wrap flex-sm-nowrap">
        <div class="navbar-collapse">
            
            {{! Namespaces menu}}
            {{#data-portlets.data-namespaces}}
                <ul class="nav nav-pills">
                    {{#each array-links}}
                        {{#each .}}<li class="nav-item"><a id="{{single-id}}" class="nav-link{{#class}} {{.}}{{/class}}" href="{{href}}">{{text}}</a></li>{{/each}}
                    {{/each}}
                </ul>
            {{/data-portlets.data-namespaces}}
            
        </div>
        <div class="navbar-collapse justify-content-end">

            {{! Variants menu}}
            {{#data-portlets.data-variants}}
                <div class="dropdown">
                <a class="btn{{#class}} {{.}}{{/class}}" href="#" role="button" id="{{id}}" title="{{label}}" data-bs-toggle="dropdown" aria-expanded="false">
                <i class="fas fa-code-branch fa-fw"></i>
                </a>
                  <ul class="dropdown-menu dropdown-menu-end" aria-labelledby="{{id}}">
                    {{#each array-links}}
                        {{#each .}}<li><a id="{{single-id}}" class="dropdown-item{{#class}} {{.}}{{/class}}" href="{{href}}">{{text}}</a></li>{{/each}}
                    {{/each}}
                  </ul>
                </div>
            {{/data-portlets.data-variants}}

            {{! Views menu}}
            {{#data-portlets.data-views}}
                <div class="dropdown">
                <a class="btn{{#class}} {{.}}{{/class}}" href="#" role="button" id="{{id}}" title="{{label}}" data-bs-toggle="dropdown" aria-expanded="false">
                <i class="fas fa-book-open fa-fw"></i>
                </a>
                  <ul class="dropdown-menu dropdown-menu-sm-end" aria-labelledby="{{id}}">
                    {{#each array-links}}
                        {{#each .}}<li><a id="{{single-id}}" class="dropdown-item{{#class}} {{.}}{{/class}}" href="{{href}}">{{text}}</a></li>{{/each}}
                    {{/each}}
                  </ul>
                </div>
            {{/data-portlets.data-views}}

            {{! Actions menu}}
            {{#data-portlets.data-actions}}
                <div class="dropdown">
                <a class="btn{{#class}} {{.}}{{/class}}" href="#" role="button" id="{{id}}" title="{{label}}" data-bs-toggle="dropdown" aria-expanded="false">
                <i class="fas fa-th fa-fw"></i>
                </a>
                  <ul class="dropdown-menu dropdown-menu-sm-end" aria-labelledby="{{id}}">
                    {{#each array-links}}
                        {{#each .}}<li><a id="{{single-id}}" class="dropdown-item{{#class}} {{.}}{{/class}}" href="{{href}}">{{text}}</a></li>{{/each}}
                    {{/each}}
                  </ul>
                </div>
            {{/data-portlets.data-actions}}
            
        </div>
    </div>
</nav>

And the resulting wiki page:

 
Main page using the Lift skin showing the header with the logo, the search bar, and the user-personal data-portlet (far right) with the logout button separated, plus a page menu below the Main Page heading showing the data-namespaces, data-views, and data-actions data portlets, all styled by Bootstrap with icons from Font Awesome

Finally, let's remove the original components that are now included in the header and the page menu from the skin.mustache template by removing lines 8 through 38 from the mw-navigation div:

{{>header}}

<div id="mw-wrapper" class="container-xxl pt5 mt-5">
	<div class="mw-body" id="content" role="main">
	    <...>
	</div>
	<div id="mw-navigation">
		<h2>{{msg-navigation-heading}}</h2>
		<div id="p-logo" class="mw-portlet" role="banner">
			<a href="{{link-mainpage}}">
			{{#data-logos}}
				{{#icon}}<img src="{{.}}" width="40" height="40">{{/icon}}
				{{#wordmark}}<img src="{{src}}" width="{{width}}" height="{{height}}">{{/wordmark}}
				{{^wordmark}}<h1>{{msg-sitetitle}}</h1>{{/wordmark}}
			{{/data-logos}}
			</a>
		</div>
		{{#data-search-box}}
		<form action="{{form-action}}" role="search" class="mw-portlet" id="p-search">
			<input type="hidden" name="title" value="{{page-title}}">
			<h3>
				<label for="searchInput">{{msg-search}}</label>
			</h3>
			{{{html-input}}}
			{{{html-button-search}}}
		</form>
		{{/data-search-box}}
		{{#data-portlets}}
		<div id="user-tools">
			{{#data-personal}}{{>Portlet}}{{/data-personal}}
		</div>
		<div id="page-tools">
			{{#data-namespaces}}{{>Portlet}}{{/data-namespaces}}
			{{#data-variants}}{{>Portlet}}{{/data-variants}}
			{{#data-views}}{{>Portlet}}{{/data-views}}
			{{#data-actions}}{{>Portlet}}{{/data-actions}}
		</div>
		{{/data-portlets}}
		<div id="site-navigation">
			{{#data-portlets-sidebar.data-portlets-first}}{{>Portlet}}{{/data-portlets-sidebar.data-portlets-first}}
			{{#data-portlets-sidebar.array-portlets-rest}}{{>Portlet}}{{/data-portlets-sidebar.array-portlets-rest}}
			{{#data-portlets.data-languages}}{{>Portlet}}{{/data-portlets.data-languages}}
		</div>
	</div>
	<div id="footer" class="mw-footer" role="contentinfo" {{{html-user-language-attributes}}}>
	    <...>
	</div>
</div>

This should leave us with:

{{>header}}

<div id="mw-wrapper" class="container-xxl pt5 mt-5">
	<div class="mw-body" id="content" role="main">
	    <...>
	</div>
	<div id="mw-navigation">
		<div id="site-navigation">
			{{#data-portlets-sidebar.data-portlets-first}}{{>Portlet}}{{/data-portlets-sidebar.data-portlets-first}}
			{{#data-portlets-sidebar.array-portlets-rest}}{{>Portlet}}{{/data-portlets-sidebar.array-portlets-rest}}
			{{#data-portlets.data-languages}}{{>Portlet}}{{/data-portlets.data-languages}}
		</div>
	</div>
	<div id="footer" class="mw-footer" role="contentinfo" {{{html-user-language-attributes}}}>
	    <...>
	</div>
</div>

And the resulting main page would look like this:

 
Main page using the Lift skin as above with duplicate menus removed


While the navigation area is cleaner and arguably more intuitive since moving those options to our new header and the page menu, our goal is to eliminate that navigation area altogether.

The remaining data-portlets are returned to the main page inside the "data-portlets-sidebar" member, which we can see using SkinJSON:

{
    ...,
    "data-portlets": {
        "data-user-menu": {
            ...,
        },
        "data-notifications": {
            ...,
        },
        "data-namespaces": {
            ...,
        },
        "data-views": {
            ...,
        },
        "data-actions": {
            ...,
        },
        "data-variants": {
            ...,
        },
        "data-personal": {
            ...,
        }
    },
    "data-portlets-sidebar": {
        "data-portlets-first": {
            "id": "p-navigation",
            ...,
            "label": "Navigation",
            ...,
        },
        "array-portlets-rest": [
            {
                "id": "p-tb",
                ...,
                "label": "Tools",
                ...,
            }
        ]
    },
    ...,
}

The sidebar is a legacy component in many past Mediawiki skins, but the choice as to whether to have one and what goes in it should be abstracted out so that future skin developers can determine if a sidebar meets their needs. For us, we'll move those menus into the "data-portlets" member.

The first part of "data-portlets-sidebar" is simply called "data-portlets-first". This contains either the menu derived from the Mediawiki:Sidebar page or, if that is empty, the toolbox. The "array-portlets-rest" member houses either the Toolbox (if Mediawiki:Sidebar contains a navigation menu) or the toolbox.

The first task is to extract the toolbox portlet, regardless of whether it is located in the "data-portlets-first" or "array-portlets-rest", into a "data-portlets" member.

We will do this by extending the getTemplateData method of SkinMustache with our child class SkinLift:

<?php

class SkinThrust extends SkinMustache {

	/**
	 * Extends getPortletData function
	 */
	protected function getPortletData( $label, array $urls = [] )
	{
	    ...
	}

    /**
     * Extends getTemplateData function
     */
    public function getTemplateData() {
        
        // Get from parent
        $data = parent::getTemplateData();
        
        // Promote toolbox to it's own data-toolbox portlet and promote the rest to a data-navigation portlet
        if ($data['data-portlets-sidebar']['data-portlets-first']['id'] == 'p-tb') {
            
            $data['data-portlets']['data-toolbox'] = $data['data-portlets-sidebar']['data-portlets-first'];
            
        } else {

            $data['data-portlets']['data-toolbox'] = array_pop($data['data-portlets-sidebar']['array-portlets-rest']);
            
            $data['data-portlets']['data-navigation']['label'] = 'Site navigation';
            $data['data-portlets']['data-navigation']['array-sections'][] = $data['data-portlets-sidebar']['data-portlets-first'];
            
            foreach ($data['data-portlets-sidebar']['array-portlets-rest'] as &$portlet) {
                $data['data-portlets']['data-navigation']['array-sections'][] = $portlet;
            } unset($portlet);
            
            foreach ($data['data-portlets']['data-navigation']['array-sections'] as &$section) {
                foreach ($section['array-links'] as $key => &$link) {
                    $section['array-links'][$key] = $link[key($link)];
                } unset($link);
            } unset($section);
            
        }
        unset($data['data-portlets-sidebar']);
        // Remove toolbox from navigation
        unset($data['data-portlets']['data-navigation']['array-links']);
        
        return $data;
    }
    
}

Now using SkinJSON we can see the following is returned:

{
    ...,
    "data-portlets": {
        "data-user-menu": {
            ...,
        },
        "data-notifications": {
            ...,
        },
        "data-namespaces": {
            ...,
        },
        "data-views": {
            ...,
        },
        "data-actions": {
            ...,
        },
        "data-variants": {
            ...,
        },
        "data-personal": {
            ...,
        },
        "data-toolbox": {
            ...,
        },
        "data-navigation": {
            "label": "Site navigation",
            "array-sections": [
                {
                    "id": "p-navigation",
                    ...,
                    "label": "Navigation",
                    "array-links": [
                        ...,
                    ]
                }
            ]
        },
    ...,
}

Notice data-toolbox and data-navigation are our new data-portlets and also notice that data-portlets-sidebar is now gone from the JSON. Also notice that because the navigation menu might contain several sections although in this case it only contains one.

Next we can use this new JSON to modify the header template to include the toolbox and a navigation menu:

<header class="container-fluid fixed-top bg-white border-bottom d-print-none">
	<nav class="navbar navbar-expand navbar-light py-0">
		<div class="container-fluid flex-wrap flex-sm-nowrap">
            <div class="navbar-collapse w-100 justify-content-between justify-content-sm-start">
                
                {{! Logo}}
                <...>

                {{! Navigation}}
                {{#data-portlets.data-navigation}}
                    <div class="dropdown">
                    <a class="btn{{#class}} {{.}}{{/class}}" href="#" role="button" id="{{id}}" title="{{label}}" data-bs-toggle="dropdown" aria-expanded="false">
                        <i class="fas fa-sitemap fa-fw"></i>
                    </a>
                      <ul class="dropdown-menu" aria-labelledby="{{id}}">
                          {{#array-sections}}
                                <li><span class="dropdown-header bg-light fs-6 fw-bold">{{label}}</span></li>
                                {{#array-links}}<li><a id="{{id}}" class="dropdown-item{{#class}} {{.}}{{/class}}" href="{{href}}">{{text}}</a></li>{{/array-links}}
                          {{/array-sections}}
                      </ul>
                    </div>
                {{/data-portlets.data-navigation}}
                
                {{! Search}}
                <...>
                
    		</div>
            <div class="navbar-collapse justify-content-end">
    
                {{! Toolbox}}
                {{#data-portlets.data-toolbox}}
                    <div class="dropdown">
                    <a class="btn{{#class}} {{.}}{{/class}}" href="#" role="button" id="{{id}}" title="{{label}}" data-bs-toggle="dropdown" aria-expanded="false">
                    <i class="fas fa-tools fa-fw"></i>
                    </a>
                      <ul class="dropdown-menu dropdown-menu-end" aria-labelledby="{{id}}">
                        {{#each array-links}}
                            {{#unless login}}
                                {{#unless logout}}
                                    {{#each .}}<li><a id="{{single-id}}" class="dropdown-item{{#class}} {{.}}{{/class}}" href="{{href}}">{{text}}</a></li>{{/each}}
                                {{/unless}}
                            {{/unless}}
                        {{/each}}
                      </ul>
                    </div>
                {{/data-portlets.data-toolbox}}
                
                {{! Personal menu}}
                <...>
                
                {{! Separate login/logout button}}
                <...>
                
            </div>
        </div>
    </nav>
</header>

Notice the more complicated structure of the data-navigation template to allow for the different sections. We'll see those in action momentarily.

And now, our new skin:

 
Main page using the Lift skin with Navigation menu and Toolbox menu

Note that nothing appears in the old navigation area because we have gotten rid of the "data-portlets-sidebar" member which was used in the template to render them. Because it is now gone, there is nothing to show. We'll clean up the template in a moment, but first let's test adding new options to Mediawiki:Sidebar to see what happens:

* Navigation
** mainpage|mainpage-description
** recentchanges-url|recentchanges
** randompage-url|randompage
** helppage|help-mediawiki
* More navigation
** Special:AllPages|All pages
** Special:Categories|All categories
** Special:ListFiles|List of files
* SEARCH
* TOOLBOX
* LANGUAGES

And now our navigation menu shows:

 
Main page using the Lift skin with Navigation menu expanded through Mediawiki:Sidebar page

The getTemplateData method is also a place to do any other manipulation of the Mediwiki-generated menus as well, such as deleting the data-user-menu, which is a duplicate of data-personal used for backward compatibility, and moving the Edit options from the Views menu to the Actions menu:

    /**
     * Extends the getTemplateData function
     */
    public function getTemplateData() {
        global $wgShowTalkPages;
        
        // Get from parent
        $data = parent::getTemplateData();
        
        // Delete data-user-menu (duplicated with data-personal)
        unset($data['data-portlets']['data-user-menu']);

        // Promote toolbox to it's own data-toolbox portlet and promote the rest to a data-navigation portlet
        ...

        // Move edit to actions portlet
        foreach ($data['data-portlets']['data-views']['array-links'] as $key => $link) {
            if (in_array(key($data['data-portlets']['data-views']['array-links'][$key]), ['ve-edit', 'edit'])) {
                array_unshift($data['data-portlets']['data-actions']['array-links'], $link);
                unset($data['data-portlets']['data-views']['array-links'][$key]);
            }
        } unset($key); unset($link);
        // Undo the key order effects of the above unset on the key
        $data['data-portlets']['data-views']['array-links'] = array_values($data['data-portlets']['data-views']['array-links']);
        
        return $data;
    }
 
Main page using the Lift skin with Edit options (Edit source and Edit) moved from Views menu to Actions menu

Using the same approach, let's look at categories.

Start by adding the following categories to the Main Page:

[[Category:Test category]]
[[Category:Another test category]]
[[Category:The main page]]
[[Category:All main pages]]


Now we can see how categories are returned as JSON by viewing SkinJSON:

{
    ...,
    "html-categories": "<div id=\"catlinks\" class=\"catlinks\" data-mw=\"interface\"><div id=\"mw-normal-catlinks\" class=\"mw-normal-catlinks\"><a href=\"\/beta\/wiki\/index.php?title=Special:Categories\" title=\"Special:Categories\">Categories<\/a>: <ul><li><a href=\"\/beta\/wiki\/index.php?title=Category:Test_category&amp;action=edit&amp;redlink=1\" class=\"new\" title=\"Category:Test category (page does not exist)\">Test category<\/a><\/li><li><a href=\"\/beta\/wiki\/index.php?title=Category:Another_test_category&amp;action=edit&amp;redlink=1\" class=\"new\" title=\"Category:Another test category (page does not exist)\">Another test category<\/a><\/li><li><a href=\"\/beta\/wiki\/index.php?title=Category:The_main_page&amp;action=edit&amp;redlink=1\" class=\"new\" title=\"Category:The main page (page does not exist)\">The main page<\/a><\/li><li><a href=\"\/beta\/wiki\/index.php?title=Category:All_main_pages&amp;action=edit&amp;redlink=1\" class=\"new\" title=\"Category:All main pages (page does not exist)\">All main pages<\/a><\/li><\/ul><\/div><\/div>",
    ...,
}

This is what it looks like in the current skin:

 
Main page using the Lift skin with native category styling


Here we see again that SkinMustache returns the preformed html and forces us into a specific styling, so we want to overwrite the getCategories method in SkinLift to return html attributes instead (notice also the addition of "use Mediawiki/MediaWikiServices;" at the top):

<?php

use MediaWiki\MediaWikiServices;

class SkinLift extends SkinMustache {

	/**
	 * Extends getPortletData function
	 */
	protected function getPortletData( $label, array $urls = [] )
	{
	    ...
	}

    /**
     * Extends the getTemplateData function
     */
    public function getTemplateData()
    {
	    ...
    }

	/**
	 * Extends getCategories function
	 */
	public function getCategories() {
		$userOptionsLookup = MediaWikiServices::getInstance()->getUserOptionsLookup();
		$showHiddenCats = $userOptionsLookup->getBoolOption( $this->getUser(), 'showhiddencats' );

        $cats = $this->getOutput()->getCategories();
        $catLinks = [];
        foreach ($cats as $cat) {
            $catTitle = Title::makeTitleSafe( NS_CATEGORY, $cat );
            $catLinks['array-links'][] = [
                'text' => $catTitle->getText(),
                'href' => $catTitle->isKnown() ? $catTitle->getLinkURL() : $catTitle->getEditURL(),
                'exists' => $catTitle->isKnown()
            ];

        };

		return $catLinks;
	}
	
}

Now the JSON returns:

{
    ...,
    "html-categories": {
        "array-links": [
            {
                "text": "Test category",
                "href": "\/beta\/wiki\/index.php?title=Category:Test_category&action=edit",
                "exists": false
            },
            {
                "text": "Another test category",
                "href": "\/beta\/wiki\/index.php?title=Category:Another_test_category&action=edit",
                "exists": false
            },
            {
                "text": "The main page",
                "href": "\/beta\/wiki\/index.php?title=Category:The_main_page&action=edit",
                "exists": false
            },
            {
                "text": "All main pages",
                "href": "\/beta\/wiki\/index.php?title=Category:All_main_pages&action=edit",
                "exists": false
            }
        ]
    },
    ...,
}

Finally, in our skin.mustache tempalte, we can replace the reference to {{{html-categories}}} with the following code:

            {{#html-categories}}
            <div class="card border-primary mt-5">
              <div class="card-header"><a href="/w/Special:Categories" class="alert-link">Categories</a></div>
              <div class="card-body">
                <ul class="list-group list-group-horizontal d-flex flex-wrap small">
                {{#array-links}}
                    <li class="list-group-item bg-none py-1"><a href="{{href}}" class="{{#unless exists}} new {{/unless}}">{{text}}</a></li>
                {{/array-links}}
                </ul>
              </div>
            </div>
            {{/html-categories}}

The resulting skin looks like this:

 
Main page using the Lift skin with Bootstrap styling

Take advantaging of the API

edit

Now that we've moved some of the menus around, let's look at how the skin can take advantage of the API, specifically by adding a Watch/unwatch switch to the page.

In our pagemenu template, let's separate out the watch option from the Actions menu:

<nav class="navbar navbar-expand navbar-light bg-white border-bottom d-print-none">
	<div class="container-fluid flex-wrap flex-sm-nowrap">
        <div class="navbar-collapse">
            
            {{! Namespaces menu}}
            <...>

        </div>
        <div class="navbar-collapse justify-content-end">

            {{! Variants menu}}
            <...>

            {{! Views menu}}
            <...>

            {{! Actions menu}}
            {{#data-portlets.data-actions}}
                <div class="dropdown">
                <a class="btn{{#class}} {{.}}{{/class}}" href="#" role="button" id="{{id}}" title="{{label}}" data-bs-toggle="dropdown" aria-expanded="false">
                <i class="fas fa-th fa-fw"></i>
                </a>
                  <ul class="dropdown-menu dropdown-menu-sm-end" aria-labelledby="{{id}}">
                    {{#each array-links}}
                        {{#unless watch}}
                            {{#unless unwatch}}
                                {{#each .}}<li><a id="{{single-id}}" class="dropdown-item{{#class}} {{.}}{{/class}}" href="{{href}}">{{text}}</a></li>{{/each}}
                            {{/unless}}
                        {{/unless}}
                    {{/each}}
                  </ul>
                </div>
            {{/data-portlets.data-actions}}
            
            {{! Watch/unwatch switch}}
            {{#data-portlets.data-actions.array-links}}
                {{#if watch}}
                    {{#each .}}
                        <ul class="nav nav-pills ps-5">
                            <li class="nav-item">
                                <div class="form-check form-switch" style="padding:.5rem 1rem;margin-bottom: 1px;">
                                  <input class="form-check-input" type="checkbox" role="switch" id="switchWatch">
                                  <label class="form-check-label" for="switchWatch">Watch</label>
                                </div>
                            </li>
                        </ul>
                    {{/each}}
                {{/if}}
            {{/data-portlets.data-actions.array-links}}
            {{#data-portlets.data-actions.array-links}}
                {{#if unwatch}}
                    {{#each .}}
                        <ul class="nav nav-pills ps-5">
                            <li class="nav-item">
                                <div class="form-check form-switch" style="padding:.5rem 1rem;margin-bottom: 1px;">
                                  <input class="form-check-input" type="checkbox" role="switch" id="switchWatch" checked>
                                  <label class="form-check-label" for="switchWatch">Watch</label>
                                </div>
                            </li>
                        </ul>
                    {{/each}}
                {{/if}}
            {{/data-portlets.data-actions.array-links}}

        </div>
    </div>
</nav>

And the resulting page looks like:

 
Main page using the Lift skin with Watch switch

At this point, the Watch/unwatch option has been moved, but aside from actuating the switch, nothing actually happens, so we need to add the following code to our lift.js file:

$('#switchWatch').on('change', function() {

    /* See: https://www.mediawiki.org/wiki/API:Watch#MediaWiki_JS */
    /*
    	watch.js
    
    	MediaWiki API Demos
    	Demo of `Watch` module: Add a page to your watchlist
    
    	MIT License
    */
    
    var params = {
    		action: 'watch',
    		unwatch: !$(this).is(':checked'),
    		titles: mw.config.values.wgPageName
    	},
    	api = new mw.Api()

    api.postWithToken( 'watch', params )
    
    $(this).blur()

})

Now when a particular page is visited it will automatically show whether to page is being watched by the visitor and the visitor can turn the watch on or off by simply using the Bootstrap switch control. This could also have been done using a checkbox or any similar control.

You can refer to the link in the code to learn more about this particular use of the API.


Clean up templates, skin.json, and skin directory

edit

At this point we've made several changes to our original files and should take a few minutes to clean up the skin directory.

First, completely remove the mw-navigation div from the skin.mustache template and modify the footer so that the file looks like this:

{{>header}}

<div id="mw-wrapper" class="container-xxl pt5 mt-5">
	<div class="mw-body" id="content" role="main">
		<div id="siteNotice">{{{html-site-notice}}}</div>
		{{{html-user-message}}}
		<div class="mw-indicators mw-body-content">
		{{#array-indicators}}
		<div id="{{id}}" class="{{class}}">{{{html}}}</div>
		{{/array-indicators}}
		</div>
		<h1 class="firstHeading" {{{html-user-language-attributes}}}>{{{html-title}}}</h1>
		<div id="siteSub">{{msg-tagline}}</div>
		<div class="mw-body-content">
			<div id="contentSub">
				{{{html-subtitle}}}
				{{{html-undelete-link}}}
			</div>
            {{>pagemenu}}
			{{{html-body-content}}}
            {{#html-categories}}
            <div class="card border-primary mt-5">
              <div class="card-header"><a href="/w/Special:Categories" class="alert-link">Categories</a></div>
              <div class="card-body">
                <ul class="list-group list-group-horizontal d-flex flex-wrap small">
                {{#array-links}}
                    <li class="list-group-item bg-none py-1"><a href="{{href}}" class="{{#unless exists}} new {{/unless}}">{{text}}</a></li>
                {{/array-links}}
                </ul>
              </div>
            </div>
            {{/html-categories}}

		</div>
		{{{html-after-content}}}
	</div>
    <footer id="footer" class="container-fluid border-top my-5 bg-white" {{{html-user-language-attributes}}}>
    	{{#data-footer}}
    	{{!}}{{#data-info}}{{>FooterList}}{{/data-info}}
    	{{!}}<div id="footer-list" class="d-flex flex-wrap justify-content-between d-print-none">
    	{{!}}{{#data-places}}{{>FooterList}}{{/data-places}}
    	{{!}}{{#data-icons}}{{>FooterList}}{{/data-icons}}
    	{{!}}</div>
    	{{/data-footer}}
    </footer>
</div>

Modify the FooterList.mustache file:

<ul id="{{id}}" class="list-group list-group-horizontal flex-wrap small">
{{#array-items}}
<li id="{{id}}" class="list-group-item border-0 text-muted">{{{html}}}</li>
{{/array-items}}
</ul>

Clean up skin.json by removing the extra js and less files, including the ResourceModuleSkinStyles which is not used in this skin:

{
	"name": "Lift",
	"version": "1.0.0",
	"author": "Loren Maxwell",
	"url": "https://www.mediawiki.org/wiki/Skin:Lift",
	"descriptionmsg": "lift-desc",
	"namemsg": "skinname-lift",
	"license-name": "CC0-1.0",
	"type": "skin",
	"requires": {
		"MediaWiki": ">= 1.36.0"
	},
	"ValidSkinNames": {
		"lift": {
			"class": "SkinLift",
			"args": [ {
				"name": "Lift",
				"templateDirectory": "skins/Lift/templates",
				"messages": [
				],
				"styles": [
					"skins.lift.css"
				],
				"scripts": [
					"skins.lift.js"
				]
			} ]
		}
	},
	"AutoloadClasses": {
		"SkinLift": "includes/SkinLift.php"
	},
	"MessagesDirs": {
		"Lift": [
			"i18n"
		]
	},
	"ResourceFileModulePaths": {
		"localBasePath": "resources"
	},
	"ResourceModules": {
		"skins.lift.css": {
			"class": "ResourceLoaderSkinModule",
			"features": {
				"normalize": true,
				"elements": true,
				"content": true,
				"content-parser-output": true,
				"interface": true,
				"logo": true
			},
			"styles": [
			    "bootstrap/css/bootstrap.min.css",
			    "hover/css/hover-min.css",
			    "animate/animate.min.css",
			    "font_roboto.css",
			    "lift.css"
			]
		},
		"skins.lift.js": {
			"scripts": [
			    "bootstrap/js/bootstrap.bundle.min.js",
			    "fontawesome/js/all.min.js",
			    "lift.js"
			]
		}
	},
	"manifest_version": 1
}

Delete the extra resources from the resources folder:

  • extensions folder
  • main.js file
  • print.css file
  • screen-common.less
  • screen-desktop.less
  • screen-mobile.less
  • variables.less

Your resources folder should now look like this:

 
Lift skin cleaned up resources folder

And now refresh. Notice there will be other minor changes to the styling since the :

 
Main page using the Lift skin with Bootstrap styling

A note about Features:

{
	...,
	"ResourceModules": {
		"skins.lift.css": {
			"class": "ResourceLoaderSkinModule",
			"features": {
				"normalize": true,
				"elements": true,
				"content": true,
				"content-parser-output": true,
				"interface": true,
				"logo": true
			},
    	...,
	},
	...
}

Features are options passed to the ResourceLoaderSkinModule to take advantage (or not) of some preset styling offered by Mediawiki. Turning on and off various features and refreshing the skin is probably the best way to see the impact of each one. Essentially, the fewer features that are set to true, the more styling responsibility the skin developer must take.

We'll leave the features set as they were originally from the example skin, but feel free to turn them off if you want more control.

More details can be found at the ResourceLoaderSkinModule page.

Pass along more data to the skin through hooks

edit

Finally, let's look at how we can pass additional information to the skin for it to be rendered. In this example we'll use the hooks for the skin itself, but extensions can pass information to the skin through hooks as well.

The first part is to modify our SkinLift to add:

  1. A private member to hold the additional data ($additionalTempalteData)
  2. A method to accept the additional data (setTemplateVariable)
  3. A method to consolidate that data with similar array keys (array_merge_recursive_distinct)
  4. A way to add the additional data to the data already passed ($data += $this->additionalTemplateData; under the current getTemplateData method):
<?php

use MediaWiki\MediaWikiServices;

class SkinLift extends SkinMustache {

	private $additionalTemplateData = [];

	/**
	* 
	* See User contributed notes:https://www.php.net/manual/en/function.array-merge-recursive.php
	* 
    * @author Daniel <daniel (at) danielsmedegaardbuus (dot) dk>
    * @author Gabriel Sobrinho <gabriel (dot) sobrinho (at) gmail (dot) com>
    */
    private function array_merge_recursive_distinct( array &$array1, array &$array2 )
    {
      $merged = $array1;
    
      foreach ( $array2 as $key => &$value )
      {
        if ( is_array ( $value ) && isset ( $merged [$key] ) && is_array ( $merged [$key] ) )
        {
          $merged [$key] = $this->array_merge_recursive_distinct ( $merged [$key], $value );
        }
        else
        {
          $merged [$key] = $value;
        }
      }
    
      return $merged;
    }

	public function setTemplateVariable( $value ) {
		$this->additionalTemplateData = $this->array_merge_recursive_distinct($this->additionalTemplateData, $value);
	}
	
	/**
	 * Extends getPortletData function
	 */
	protected function getPortletData( $label, array $urls = [] ) {
	    ...
	}


	
    /**
     * Extends the getTemplateData function
     */
    public function getTemplateData() {

        ...

        $data += $this->additionalTemplateData;
        return $data;
    }

	/**
	 * Extends getCategories function
	 */
	public function getCategories() {
	    ...
	}
	
}

Now let's create a feature of this skin that will track the number of members who are currently online, how long their current session has been, and show the last page they visited. NOTE: A feature like this would normally be better put into an extension rather than a skin, but it's added here to avoid making a separate extension to demonstrate how to accomplish this).

First, let's create the required table in the database (called "wiki_users_online") to store the information we want to display.


Here's the SQL:

CREATE TABLE `wiki_users_online` (
 `user_id` int NOT NULL DEFAULT '0',
 `session_id` varbinary(32) NOT NULL,
 `ip_address` varbinary(100) NOT NULL,
 `lastPageTitle` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL,
 `lastLinkURL` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL,
 `start_session` datetime DEFAULT NULL,
 `end_session` datetime DEFAULT NULL,
 `prev_end_session` datetime DEFAULT NULL,
 PRIMARY KEY (`user_id`,`ip_address`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci

And the resulting table:

 
Schema for new wiki_users_online table


Now let's create a file, LiftHooks.php, in the includes folder to place our hooks into:

 
Lift skin with LiftHooks.php added as an include

Enter the following code into LiftHooks.php (notice line 270, under the onSkinTemplateNavigation_Universal method, the setTemplateVariable method is called for the skin, which adds our data using the SkinLift class above):

<?php

use MediaWiki\MediaWikiServices;

/**
 * @file
 */
class LiftHooks {

    /**
     * Get "time ago"
     * 
     * Modified from: https://www.php.net/manual/en/dateinterval.format.php
    */
    public function formatDateDiff($start, $end=null) {
        if(!($start instanceof DateTime)) {
            $start = new DateTime($start);
        }
       
        if($end === null) {
            $end = new DateTime();
        }
       
        if(!($end instanceof DateTime)) {
            $end = new DateTime($start);
        }

        $interval = $end->diff($start);
        $suffix = ( $interval->invert ? ' ago' : '' );
      
        $doPlural = function($nb,$str){return $nb>1?$str.'s':$str;}; // adds plurals
       
        $format = [];
        if($interval->y !== 0) {
            $format[] = "%y ".$doPlural($interval->y, "year");
        }
        if($interval->m !== 0) {
            $format[] = "%m ".$doPlural($interval->m, "month");
        }
        if($interval->d !== 0) {
            $format[] = "%d ".$doPlural($interval->d, "day");
        }
        if($interval->h !== 0) {
            $format[] = "%h ".$doPlural($interval->h, "hour");
        }
        if($interval->i !== 0) {
            $format[] = "%i ".$doPlural($interval->i, "minute");
        }
        if($interval->s !== 0) {
            $format[] = "%s ".$doPlural($interval->s, "second");
        }
        if($interval->y === 0 && $interval->m === 0 && $interval->d === 0 && $interval->h === 0 && $interval->i === 0 && $interval->s === 0 && $interval->f !== 0) {
            return 'less than 1 second ago';
        }

        // We use the two biggest parts
        if(count($format) > 1) {
            $format = array_shift($format).", ".array_shift($format);
        } else {
            $format = array_pop($format);
        }
       
        // Prepend 'since ' or whatever you like
        return $interval->format($format) . $suffix;
    }

	/**
	 * Delete old users online
	*/ 
    private static function deleteOldVisitors() {
        global $wgUsersOnlineTimeout;

		$timeout = 3600;
		if ( is_numeric( $wgUsersOnlineTimeout ) ) {
			$timeout = $wgUsersOnlineTimeout;
		}
		$nowdatetime = date("Y-m-d H:i:s");
        $now = strtotime($nowdatetime);
        $old = $now - $timeout;
        $olddatetime = date("Y-m-d H:i:s", $old);

        $lb = MediaWikiServices::getInstance()->getDBLoadBalancer();
        $db = $lb->getConnectionRef( DB_PRIMARY  );

		$db->delete( 'users_online', [ 'user_id' => 0, 'end_session < "' . $olddatetime . '"' ], __METHOD__ );
        
    }

	/**
	 * Count anonymous users online
	*/ 
	private static function countAnonsOnline() {

        $lb = MediaWikiServices::getInstance()->getDBLoadBalancer();
        $db = $lb->getConnectionRef( DB_REPLICA  );

		$row = $db->selectRow(
			'users_online',
			'COUNT(*) AS cnt',
			'user_id = 0 AND end_session >= DATE_SUB(UTC_TIMESTAMP(), INTERVAL 5 MINUTE)',
			__METHOD__,
			'GROUP BY ip_address'
		);
		$anons = (int)$row->cnt;

		return $anons;
	}
	
	/**
	 * Count users online
	*/ 
	private static function countUsersOnline() {

        $lb = MediaWikiServices::getInstance()->getDBLoadBalancer();
        $db = $lb->getConnectionRef( DB_REPLICA  );

		$row = $db->selectRow(
			'users_online',
			'COUNT(*) AS cnt',
			'user_id != 0 AND end_session >= DATE_SUB(UTC_TIMESTAMP(), INTERVAL 5 MINUTE)',
			__METHOD__,
			'GROUP BY ip_address'
		);
		$users = (int)$row->cnt;
		
		return $users;
	}

	/**
	 * Get users online
	*/ 
	private static function getUsersOnline() {

        $lb = MediaWikiServices::getInstance()->getDBLoadBalancer();
        $db = $lb->getConnectionRef( DB_REPLICA  );

		$users = [
		    'array-currently' => [],
		    'array-recently' => []
		    ];

		$currently = $db->select(
        	[ 'users_online', 'user' ],
        	[ 'wiki_users_online.user_id', 'wiki_users_online.lastLinkURL', 'wiki_users_online.lastPageTitle', 'wiki_users_online.start_session', 'wiki_users_online.end_session', 'wiki_user.user_name' ],
        	[
        		'wiki_users_online.user_id != 0 AND wiki_users_online.end_session >= DATE_SUB(UTC_TIMESTAMP(), INTERVAL 5 MINUTE)'
        	],
        	__METHOD__,
        	['ORDER BY' => 'wiki_users_online.end_session DESC'],
        	[
        		'user' => [ 'INNER JOIN', [ 'wiki_user.user_id=wiki_users_online.user_id' ] ]
        	]
		);
		
		foreach ($currently as $r) {
		    $user = $r;
		    $user->ago = LiftHooks::formatDateDiff($user->end_session);
		    $user->online_since = LiftHooks::formatDateDiff($user->start_session);
		    $user->user_page = MediaWikiServices::getInstance()->getUserFactory()->newFromId( (int)$user->user_id )->getUserPage()->getLocalURL();
		    $users['array-currently'][] = (array) $user;
		}

		$currently = $db->select(
        	[ 'users_online', 'user' ],
        	[ 'wiki_users_online.user_id', 'wiki_users_online.lastLinkURL', 'wiki_users_online.lastPageTitle', 'wiki_users_online.start_session', 'wiki_users_online.end_session', 'wiki_user.user_name' ],
        	[
        		'wiki_users_online.user_id != 0 AND wiki_users_online.end_session <= DATE_SUB(UTC_TIMESTAMP(), INTERVAL 5 MINUTE) AND wiki_users_online.end_session >= DATE_SUB(UTC_TIMESTAMP(), INTERVAL 24 HOUR)',
        	],
        	__METHOD__,
        	['ORDER BY' => 'wiki_users_online.end_session DESC'],
        	[
        		'user' => [ 'INNER JOIN', [ 'wiki_user.user_id=wiki_users_online.user_id' ] ]
        	]
		);
		
		foreach ($recently as $r) {
		    $user = $r;
		    $user->ago = LiftHooks::formatDateDiff($user->end_session);
		    $user->offline_since = LiftHooks::formatDateDiff($user->end_session);
		    $user->user_page = MediaWikiServices::getInstance()->getUserFactory()->newFromId( (int)$user->user_id )->getUserPage()->getLocalURL();
		    $users['array-recently'][] = (array) $user;
		}

		return $users;
	}

	/**
	 * Update users online data
	*/ 
	public static function onBeforeInitialize( \Title &$title, $unused, \OutputPage $output, \User $user, \WebRequest $request, \MediaWiki $mediaWiki ) {
        
		// Delete old visitors
		LiftHooks::deleteOldVisitors();

        // Get previous and current sessions
        $lb = MediaWikiServices::getInstance()->getDBLoadBalancer();
        $db = $lb->getConnectionRef( DB_PRIMARY );

        $current_session = $request->getSessionId()->getId();
        
	    $last_session = null;
		if ($user->getId() != 0) {

            // Get user's last session_id
    		$row = $db->selectRow(
    			'users_online',
    			['session_id', 'start_session', 'end_session', 'prev_end_session'],
    			'user_id = '. $user->getId(),
    			__METHOD__,
    			''
    		);
    		
    		$last_session = $row->session_id;
		}
		
		$same_session = $last_session == $current_session;
		
		// row to insert to table
		$row = [
			'user_id' => $user->getId(),
			'session_id' => $request->getSessionId()->getId(),
			'ip_address' => $user->getName(),
			'lastLinkURL' => $title->getLinkURL(),
			'lastPageTitle' => $title->getText(),
			'start_session' => $same_session ? $row->start_session : date("Y-m-d H:i:s"),
			'end_session' => date("Y-m-d H:i:s"),
			'prev_end_session' => $same_session ? $row->prev_end_session : $row->end_session
		];
		$method = __METHOD__;
		$db->onTransactionIdle( function() use ( $db, $method, $row ) {
			$db->upsert(
				'users_online',
				$row,
				[ 'user_id', 'ip_address' ],
				[
				    'session_id' => $row['session_id'],
				    'lastLinkURL' => $row['lastLinkURL'],
				    'lastPageTitle' => $row['lastPageTitle'],
				    'start_session' => $row['start_session'],
				    'end_session' => $row['end_session'],
				    'prev_end_session' => $row['prev_end_session']
				],
				$method
			);
		});

	}

	/**
	 * Pass users online data to skin
	 */
	public static function onSkinTemplateNavigation_Universal( $skin, &$links ) {
	    
		if (method_exists($skin, 'setTemplateVariable')) {

            // Online
    		$portlet['data-extension-portlets']['data-online'] = [];
    		
		    $portlet['data-extension-portlets']['data-online'] = [
		        'id' => 'p-online',
        		'text'  => 'Online',
        		'href'  => '/w/Main page',
        		'title' => 'Online',
        		'id'    => 'n-online',
        		'online' => LiftHooks::countUsersOnline(),
        		'guests' => LiftHooks::countAnonsOnline(),
        		'members' => LiftHooks::getUsersOnline()
	        ];

            $skin->setTemplateVariable($portlet);
		}
        
	}
    
}


And now modify skin.json to load the hooks and set our initial UsersOnlineTimeout variable (notice the SkinTemplateNavigation::Universal hook maps to the onSkinTemplateNavigation_Universal in the LiftHooks class, which calls the setTemplateVariable method to add the data):

{
    ...,
	"AutoloadClasses": {
		"SkinLift": "includes/SkinLift.php",
		"LiftHooks": "includes/LiftHooks.php"
	},
	"config": {
		"UsersOnlineTimeout": 3600
	},
	"Hooks": {
		"BeforeInitialize": "LiftHooks::onBeforeInitialize",
        "SkinTemplateNavigation::Universal": "LiftHooks::onSkinTemplateNavigation_Universal"
    },
    ...,
}

Refresh the wiki and now look at the database table:

 
Users online table after refreshing wiki

And let's check the resulting JSON using SkinJSON (note the times are different because I have refreshed):

{
        ...,
    "data-portlets": {
        ...,
    },
    "data-footer": {
        ...,
    },
    "data-extension-portlets": {
        "data-online": {
            "id": "n-online",
            "text": "Online",
            "href": "\/w\/Main page",
            "title": "Online",
            "online": 1,
            "guests": 0,
            "members": {
                "array-currently": [
                    {
                        "user_id": "1",
                        "lastLinkURL": "\/beta\/wiki\/index.php?title=Main_Page",
                        "lastPageTitle": "Main Page",
                        "start_session": "2021-10-18 19:07:07",
                        "end_session": "2021-10-18 19:14:50",
                        "user_name": "Loren Maxwell",
                        "ago": "7 seconds ago",
                        "online_since": "7 minutes, 50 seconds ago",
                        "user_page": "\/beta\/wiki\/index.php?title=User:Loren_Maxwell"
                    }
                ],
                "array-recently": []
            }
        }
    }
}

Now to show this data, enter the following into our header template :

<!-- header starts -->
<header class="container-fluid fixed-top bg-white border-bottom d-print-none">
	<nav class="navbar navbar-expand navbar-light py-0">
		<div class="container-fluid flex-wrap flex-sm-nowrap">
            <div class="navbar-collapse w-100 justify-content-between justify-content-sm-start">
                
                {{! Logo}}
                <...>
                
                {{! Navigation}}
                <...>
                
                {{! Search}}
                <...>
                
    		</div>
            <div class="navbar-collapse justify-content-end">
    
                {{! Toolbox}}
                <...>
                
                {{! Personal menu}}
                <...>

                {{! Users online}}
                {{#data-extension-portlets.data-online}}
                <a class="btn position-relative" title="Members online" data-bs-toggle="offcanvas" href="#membersOnline" role="button" aria-controls="membersOnline">
                  <i class="fas fa-users fa-fw" aria-hidden="true"></i>
                  <span class="position-absolute top-0 translate-middle badge rounded-pill bg-primary">{{data-extension-portlets.data-online.online}}<span class="visually-hidden">members online</span>
                  </span>
                </a>
                {{/data-extension-portlets.data-online}}
                
                {{! Separate login/logout button}}
                <...>

            </div>
        </div>
    </nav>
</header>

{{! Users online offcanvas panel}}
<div class="offcanvas offcanvas-end" data-bs-scroll="true" tabindex="-1" id="membersOnline" aria-labelledby="membersOnlineLabel">
   <div class="offcanvas-header">
      <h3 class="offcanvas-title" id="membersOnlineLabel">Members online</h3>
      <button type="button" class="btn-close text-reset" data-bs-dismiss="offcanvas" aria-label="Close"></button>
   </div>
   <div class="offcanvas-body">
      {{#data-extension-portlets.data-online}}
      <div class="card border-primary">
         <div class="card-header bg-primary text-white">Currently online (past five minutes)</div>
         <ul class="list-group list-group-flush ms-0">
            <li class="list-group-item d-flex justify-content-between align-items-center">
               <span><i class="fas fa-users fa-fw"></i> Members</span>
               <span>{{online}}</span>
            </li>
            <li class="list-group-item d-flex justify-content-between align-items-center">
               <span><i class="fal fa-users fa-fw"></i> Guests</span>
               <span>{{guests}}</span>
            </li>
         </ul>
         <div class="card-header bg-primary text-white">Members currently online</div>
         <ul  class="list-group list-group-flush m-0">
            {{#members.array-currently}}
            <li class="list-group-item">
               <div><a href="{{user_page}}">{{user_name}}</a></div>
               <div class="text-muted fst-italic fs-xs" aria-hidden="true">Started a session {{online_since}}</div>
               <div class="text-muted fst-italic fs-xs">Last viewed <a href="{{lastLinkURL}}">{{lastPageTitle}}</a> {{ago}}</div>
            </li >
            {{/members.array-currently}}
         </ul >
         <div class="card-header bg-primary text-white">Members recently online (last 24 hours)</div>
         <div class="list-group">
            {{#members.array-recently}}
            <li class="list-group-item">
               <div><a href="{{user_page}}">{{user_name}}</a></div>
               <div class="text-muted fst-italic fs-xs" aria-hidden="true">Ended a session {{offline_since}}</div>
               <div class="text-muted fst-italic fs-xs">Last viewed <a href="{{lastLinkURL}}">{{lastPageTitle}}</a> {{ago}}</div>
            </li >
            {{/members.array-recently}}
         </div>
      </div>
      {{/data-extension-portlets.data-online}}
   </div>
</div>

Finally, let's refresh the wiki to see how it looks.

First, notice the new "Users online" icon:

 
Main page using the Lift skin showing Users online button

Now click the Users online button to see the offcanvas panel with more detail:

 
Main page using the Lift skin showing Users online details in a Bootstrap offcanvas panel