Module:Clickable button and Module:Clickable button/sandbox: Difference between pages
(Difference between pages)
imported>HouseBlaster m HouseBlaster moved page Module:Clickable button 2 to Module:Clickable button: match with tm:Clickable button |
imported>Waddie96 No edit summary |
||
| Line 1: | Line 1: | ||
-- | --- @module 'Clickable button' | ||
--- Creates clickable Codex button. | |||
--- | |||
--- Outputs wikitext to render the [button component](https://doc.wikimedia.org/codex/latest/components/demos/button.html) | |||
--- from the [Codex design system for Wikimedia](https://doc.wikimedia.org/codex/latest). | |||
--- Options to: | |||
--- - include an icon or create an icon-only button | |||
--- - target a URL or a wikilink | |||
--- - set the weight, size and state | |||
--- - create a dummy or disabled button | |||
--- - add custom CSS classes and inline styles | |||
--- - include ARIA attributes for accessibility. | |||
--- | |||
--- Dummy buttons are disabled by default. Includes helper functions for URL parsing and cleaning, | |||
--- and adding tracking categories. Intended for use in templates and other modules. | |||
--- Supports legacy parameters. To add icons, see CSS file links in `C`. | |||
--- | |||
--- @diagnostic disable: duplicate-doc-field | |||
--- @class args: frame Template arguments. | |||
--- @field label? string Visible text label. | |||
--- @field link? string Target wikilink. | |||
--- @field url? string Target external URL. | |||
--- @field icon? string Name of the icon to display as found in CSS file after the class's icon prefix, i.e. `search` for `cdx-demo-css-icon--search`. | |||
--- @field weight? string Visual weight of the button. | |||
--- @field size? string Size of the button. | |||
--- @field action? string Action type of the button. | |||
--- @field disabled? boolean|string Whether the button is disabled/greyed out. `true` if: `link` = `'no'` or `false`, or `disabled` = `'1'` or `true`. | |||
--- @field nocat? boolean|string If `true`, suppresses tracking categories. Additional category, if defined, will still be added. | |||
--- @field category? string An additional category to add. | |||
--- @field aria-label? string The ARIA label for accessibility DOM. | |||
--- @field class? string Custom CSS classes for the button. Do _not_ nest in "". | |||
--- @field style? string Custom inline CSS styles. Do _not_ nest in "". | |||
--- @field arialabel? string (alias for aria-label) | |||
--- @field aria_label? string (alias for aria-label) | |||
--- @field text string (alias for label) | |||
--- @field ['1']? string Positional argument 1 (alias for link, can be label too if label is not defined). | |||
--- @field ['2']? string Positional argument 2 (alias for label). | |||
--- @field color? string Legacy color parameter. | |||
--- @field private categories? string|boolean Categories to add. | |||
--- @field private ariaDisabled? boolean Internal flag indicating if the button should be functionally disabled to ARIA. | |||
--- @field private oldClassMatched string|boolean Internal flag for outdated classes if used. | |||
--- @field private isUrl boolean Whether the target is a URL. | |||
--- @field private errorText string|nil Internal string used both as error indicator, and error message text. | |||
--- @field private tblClasses table Classes for the button span tag. | |||
--- @field private pageTitleObject mw.title Title object of the current page. | |||
--- @field private linkTitleObject mw.title Title object of the target wikilink. | |||
--- @field private frame frame The current frame. | |||
--- @field private rawArgs table Arguments passed to the module before parsing. | |||
--- @field private parsedArgs table Parsed arguments. | |||
--- @field private iconSpan mw.html Icon span element for the button. | |||
require( 'strict' ) | |||
local | local M = {} | ||
local | -- If your wiki uses non-ASCII/UTF-8 characters in any input text, then replace use of "string.lower" with "mw.ustring.lower". NOTE: "mw.ustring.lower" may be _much_ slower but respects Unicode codepoints rather than just bytes. | ||
local _lower = string.lower | |||
local getArgs = require( 'Module:Arguments' ).getArgs | |||
local checkForUnknowns = require( 'Module:Check for unknown parameters' )._check | |||
local _gsub = mw.ustring.gsub | |||
local _mw_lower = mw.ustring.lower -- Still loaded, as instances where Unicode support is required use it. | |||
local _tonumber = tonumber | |||
local _format = mw.ustring.format | |||
local _type = type | |||
local _table_insert = table.insert | |||
--- Requires @wikimedia/codex (check [[Special:Version]]). | |||
--- @todo Check not in User/Draft namespaces. | |||
local | --- @todo Is checkForUnknowns checking validity of input? | ||
return | --- @todo Check if being subst'd via {{subst:#invoke:}} by checking mw.isSubsting() then output template call not the subst, i.e. unsubst. | ||
--- @todo Check verbose output with mw.dumpObject( type.object ). | |||
--- @todo Check knownArgs. | |||
local C = { --- 'Constants' | |||
lowercaseArgs = { --- Arguments whose inputs are case-insensitive, and are converted to lowercase. | |||
[ 'action' ] = true, | |||
[ 'color' ] = true, | |||
[ 'weight' ] = true, | |||
[ 'size' ] = true, | |||
[ 'icon' ] = true, | |||
}, | |||
knownArgs = { --- Valid argument keys. | |||
'class', | |||
'color', | |||
'weight', | |||
'size', | |||
'icon', | |||
'link', | |||
'action', | |||
'url', | |||
'disabled', | |||
'label', | |||
'aria-label', | |||
'nocat', | |||
'text', | |||
'1', | |||
'2', | |||
'url', | |||
'errorText', | |||
'arialabel', | |||
'aria_label', | |||
checkpositional = 'y', --- Other options for unknown parameters check. | |||
ignoreblank = 'y', | |||
unknown = '[[Category:Pages using Module:Clickable button with unknown parameters|_VALUE_]]', | |||
preview = '<span class="error" style="font-size:inherit;"><strong>Preview warning:</strong>' .. | |||
'Using undocumented parameter(s): "_VALUE_".</span>', | |||
}, | |||
wrapperTemplates = { --- Wrapper templates that only require reading from `parentFrame()`. Positional arguments using template parameters (e.g., `{{{var|}}}`) are ignored, as `currentFrame()` is not used. Improves performance by avoiding argument checks in both frames. | |||
'Template:Clickable button', 'Template:Clickable button/sandbox', | |||
'Template:Cdx-button', 'Template:Cdx-button/sandbox', | |||
}, | |||
trackingCategories = { --- Tracking category pagenames with namespace. | |||
dummyButton = 'Category:Pages using clickable dummy button', | |||
disabledButton = 'Category:Pages using disabled button', | |||
externalLinks = 'Category:Pages using clickable button with external links', | |||
outdatedClasses = 'Category:Pages using clickable button with outdated classes', | |||
errors = 'Category:Errors reported by Module:Clickable button', | |||
unknownParams = 'Category:Pages using Module:Clickable button with unknown parameters', | |||
}, | |||
unknownArgsPreviewText = '<span class="error"><strong>Preview warning:</strong>' .. --- Preview warning text for unknown arguments. | |||
' Using undocumented parameter(s): "_VALUE_".</span>', | |||
noAriaLabelWarningText = '<span class="error" style="font-size:inherit;">' .. --- No ARIA-label preview warning text. | |||
'<strong>Preview warning:</strong> A button without a visible label needs an [' .. | |||
'[WAI-ARIA|ARIA]] label, please define it using "aria-label".</span>', | |||
labelLengthWarningText = '<span class="error" style="font-size:inherit;">' .. --- "Visible label is too long" preview warning text. | |||
'<strong>Preview warning:</strong> A button label should ideally be shorter th' .. | |||
'an 38 characters, see [[en:Template:Clickable button/doc#Button label length|' .. | |||
'documentation]].</span>', | |||
noArgsWarningText = '<span class="error" style="font-size:inherit;">' .. --- No arguments preview warning text. | |||
'<strong>Preview warning:</strong> No parameters were passed to clickable button.</span>', | |||
baseCSS = 'Template:Clickable button/styles.css', --- Base CSS file for button styles. | |||
iconsCSS = 'Template:Clickable button/icons.css', --- CSS file for button icons. | |||
buttonDefaults = { --- Default values for button options | |||
weight = 'normal', | |||
size = 'medium', | |||
action = 'default', | |||
}, | |||
cssClasses = { -- CSS class prefixes for button. | |||
base = 'cdx-button', | |||
disabled = 'cdx-button--fake-button--disabled', | |||
wordWrap = 'cdx-button--word-wrap', | |||
enabled = 'cdx-button--fake-button--enabled', | |||
iconOnly = 'cdx-button--icon-only', | |||
shortLabel = 'cdx-button--short-label', | |||
icon = 'cdx-button__icon', | |||
iconPrefix = 'cdx-demo-css-icon--', | |||
sizePrefix = 'cdx-button--size-', | |||
weightPrefix = 'cdx-button--weight-', | |||
samePage = 'cdx-button--same-page', | |||
actionPrefix = 'cdx-button--action-', | |||
fakeButton = 'cdx-button--fake-button', | |||
}, | |||
labelLimits = { maxLength = 38, minLength = 3 }, --- Label length limits. | |||
excludedNamespaces = { 'User', 'Draft' }, --- Namespace exclusions for tracking categories. | |||
legacyClassSets = { | |||
progressive = { --- Aliases for CSS class: `.progressive`. | |||
[ 'blue' ] = true, | |||
[ 'green' ] = true, | |||
[ 'ui-button-green' ] = true, | |||
[ 'ui-button-blue' ] = true, | |||
[ 'mw-ui-constructive' ] = true, | |||
[ 'mw-ui-progressive' ] = true, | |||
[ 'progressive' ] = true, | |||
}, | |||
destructive = { --- Aliases for CSS class: `.destructive`. | |||
[ 'red' ] = true, | |||
[ 'ui-button-red' ] = true, | |||
[ 'mw-ui-destructive' ] = true, | |||
[ 'destructive' ] = true, | |||
}, | |||
}, | |||
booleanMap = { | |||
-- Explicit true values | |||
[ 'yes' ] = true, | |||
[ 'y' ] = true, | |||
[ 'true' ] = true, | |||
[ 't' ] = true, | |||
[ 'on' ] = true, | |||
[ '1' ] = true, | |||
[ 'enable' ] = true, | |||
[ 'enabled' ] = true, | |||
-- Explicit false values | |||
[ 'no' ] = false, | |||
[ 'n' ] = false, | |||
[ 'false' ] = false, | |||
[ 'f' ] = false, | |||
[ 'off' ] = false, | |||
[ '0' ] = false, | |||
[ 'disable' ] = false, | |||
[ 'disabled' ] = false, | |||
}, | |||
defaultResponse = nil, | |||
} | |||
--- Allows for consistent treatment of boolean-like wikitext input. | |||
--- | |||
--- Uses lookup table for efficiency, unlike [[Module:Yesno]] which uses chained if-elseif statements. | |||
--- - Returns `nil` if input is `nil`. | |||
--- - Checks for boolean type and returns as-is. | |||
--- - For strings, looks up a normalized (lowercased) value in a lookup table (`C.booleanMap`). | |||
--- - If not found, attempts to convert to a number: returns `true` for `1`, `false` for `0`. | |||
--- - If still unrecognized, returns `defaultResponse` (or a constant fallback; default: `nil`). | |||
--- @param value any Value to evaluate as truthy or falsy. | |||
--- @param defaultResponse? any Value to return if input is unrecognized, i.e. neither truthy/falsy. Defaults to nil. | |||
--- @return any valueBoolean Boolean true if truthy, or false if falsy, or nil if nil. defaultResponse or nil if input is unrecognized. | |||
function M.yesno( value, defaultResponse ) | |||
if value == nil then | |||
return nil | |||
end | |||
local valueType = _type( value ) | |||
if valueType == 'boolean' then | |||
return value | |||
elseif valueType == 'string' then | |||
local lookupResult = C.booleanMap[ _lower( value ) ] -- Unicode doesn't matter here. | |||
if lookupResult ~= nil then | |||
return lookupResult | |||
end -- Not found in lookup table. Fallback to numeric check. | |||
end | |||
-- Numeric check works for both numbers and numeric strings. | |||
-- Numeric 1 is truthy, and 0 is falsy. | |||
local number = _tonumber( value ) or nil | |||
if number == 1 then | |||
return true | |||
elseif number == 0 then | |||
return false | |||
end -- Not 1 or 0, fallback to defaultResponse. | |||
if not defaultResponse then | |||
defaultResponse = C.defaultResponse | |||
end | |||
return defaultResponse | |||
end | end | ||
function | --- Parse a wikilink and return its component parts. | ||
-- | --- | ||
-- | --- @class linkData, table | ||
-- | --- @field pageName string? The pagename part, with namespace if present | ||
--- @field sectionHeading string? The section heading after `#` | |||
--- @field displayText string? Display text after pipe `|` | |||
if | --- @field isSectionLink boolean Whether wikilink is a section-only link in current page, i.e. `[[#Section]]`. | ||
--- @param wikilinkText string|nil Wikitext to parse. | |||
--- @return linkData|nil wikilink Components of wikilink, or nil if invalid. | |||
local function parseWikilink( wikilinkText ) | |||
-- @class wikilink: table<string, any> | |||
-- @field pageName string The pagename with namespace, if present | |||
-- @field sectionHeading string The section heading | |||
-- @field displayText string Display text, as given or as generated | |||
-- @field isSectionLinkOnly boolean Whether wikilink is a section-only link in current page, i.e. `[[#Section]]` | |||
-- @param wikilinkText string|nil Wikitext to parse. | |||
-- @return table<string, string>|nil wikilink Components of wikilink, or nil if invalid. | |||
if not wikilinkText or wikilinkText == '' then | |||
return nil | |||
end | |||
-- Remove outer square brackets if present: `[[:Help:Foo#Bar|Flog]]` → `Help:Foo#Bar|Flog` | |||
wikilinkText = _gsub( wikilinkText, '^%[%[', '' ) | |||
wikilinkText = _gsub( wikilinkText, '%]%]$', '' ) | |||
-- Remove initial colon if present | |||
wikilinkText = wikilinkText and string.match( wikilinkText, '^:?(.*)' ) -- Remove initial colon if present. | |||
-- Split on pipe `|` to separate link from display text | |||
local link, displayText = wikilinkText, wikilinkText and string.match( wikilinkText, '^(.-)|(.*)$' ) | |||
wikilinkText = link or wikilinkText | |||
-- Split link on hash/pound sign `#` to separate page from section | |||
local pageName, sectionHeading = wikilinkText, wikilinkText and string.match( wikilinkText, '^(.-)#(.*)$' ) | |||
local isSectionLink = false | |||
if not pageName and sectionHeading then | |||
isSectionLink = true -- It is a section link to current page, i.e. `[[#Bar]]`. | |||
pageName = nil | |||
-- pageName = FORMAT('#%s', sectionHeading) | |||
elseif not pageName and not sectionHeading then | |||
isSectionLink = false | |||
pageName = wikilinkText | |||
elseif pageName and not sectionHeading then | |||
isSectionLink = false | |||
sectionHeading = nil | |||
pageName = wikilinkText | |||
end | end | ||
if not | if not displayText and sectionHeading and pageName then | ||
if | displayText = _format( '%s § %s', pageName, sectionHeading ) | ||
elseif not displayText and sectionHeading and not pageName then | |||
displayText = _format( '§ %s', sectionHeading ) | |||
elseif not displayText and not sectionHeading and pageName then | |||
displayText = pageName | |||
end | |||
return { | |||
pageName = pageName, | |||
sectionHeading = sectionHeading, | |||
displayText = displayText, | |||
isSectionLink = isSectionLink, | |||
} | |||
end | |||
--- Safely creates a [mw.uri object](lua://mw.uri) from a string, returning `nil` if invalid. | |||
--- See [mw.uri in Lua reference manual](https://www.mediawiki.org/wiki/Extension:Scribunto/Lua_reference_manual#mw.uri). | |||
--- | |||
--- @param s string The URL to check. | |||
--- @return mw.uri|nil uri The URI of the given URL. | |||
local function safeUri( s ) | |||
local success, uri = pcall( function ( s ) | |||
return mw.uri.new( s ) | |||
end ) | |||
if success then | |||
return uri | |||
else | |||
return nil | |||
end | |||
end | |||
--- Attempts to extract and normalize a URL from a string. | |||
--- | |||
--- @param extract string String from which the URL must be obtained. | |||
--- @return string|nil url The raw URL. | |||
local function extractUrl( extract ) | |||
local url = extract | |||
url = _gsub( url, '^([Hh]?[Tt]?[Tt]?[Pp]?[Ss]?:/*)(.+)', 'https://%2' ) | |||
local uri = safeUri( url ); | |||
if uri and uri.host then | |||
return url | |||
end | |||
return nil | |||
end | |||
--- Cleans and encodes a URL, generates a display label (domain-only if no label provided), | |||
--- and adds word break opportunities for better display. | |||
--- | |||
--- @param url string The URL | |||
--- @param text? string|nil The display text for the URL if one must not be generated | |||
--- @return string|nil url The URL, returns nil if URL invalid | |||
--- @return string|nil text The display text for the URL | |||
local function _url( url, text ) | |||
-- @TODO cache some of these values. | |||
url = mw.text.trim( url or '' ) | |||
text = mw.text.trim( text or '' ) | |||
if url == '' or not url then | |||
return nil, text | |||
end | |||
-- If the URL contains any unencoded spaces, encode them, | |||
-- because MediaWiki will otherwise interpret a space as the end of the URL. | |||
url = _gsub( url, '%s', function ( s ) | |||
return mw.uri.encode( s, 'PATH' ) | |||
end ) | |||
-- If there is an empty query string or fragment ID, | |||
-- remove it as it will cause mw.uri.new to throw an error | |||
url = _gsub( url, '#$', '' ) | |||
url = _gsub( url, '%?$', '' ) | |||
-- If it's an http(s) URL without the double slash, fix it. | |||
url = _gsub( url, '^[Hh][Tt][Tt][Pp]([Ss]?):(/?)([^/])', 'http%1://%3' ) | |||
local uri = safeUri( url ) | |||
-- Handle URLs without a protocol or who are protocol-relative. | |||
-- e.g., www.example.com/foo or www.example.com:8080/foo, and //www.example.com/foo | |||
if uri | |||
and (not uri.protocol | |||
or (uri.protocol and not uri.host)) | |||
and url:sub( 1, 2 ) ~= '//' then | |||
url = 'http://' .. url | |||
uri = safeUri( url ) | |||
end | |||
if text == '' or not text then | |||
if uri then | |||
-- Generate clean domain-only text (e.g., "en.wikipedia.org") | |||
local host = _lower( uri.host or '' ) | |||
-- Remove www. prefix for cleaner display | |||
host = _gsub( host, '^www%.', '' ) | |||
-- For URLs like "http://en.wikipedia.org/wiki/Article_Name" | |||
-- Only want en.wikipedia.org | |||
text = host | |||
-- Add port if present and not standard | |||
if uri.port and uri.port ~= 80 and uri.port ~= 443 then | |||
text = text .. ':' .. uri.port | |||
end | |||
-- Add word break opportunities for better display. Add `<wbr>` before `_/.-#` sequences. This entry _must_ be the first. `<wbr/>` has a `/` in it, you know. | |||
text = _gsub( text, '(/+)', '<wbr/>%1' ) | |||
text = _gsub( text, '(%.+)', '<wbr/>%1' ) | |||
-- _Disabled_ for now. | |||
---- text = gsub(text,"(%-+)","<wbr/>%1") | |||
text = _gsub( text, '(%#+)', '<wbr/>%1' ) | |||
text = _gsub( text, '(_+)', '<wbr/>%1' ) | |||
else | else | ||
return '' | -- URL is badly-formed, so just display whatever was given. | ||
text = url | |||
end | |||
end | |||
return url, text | |||
end | |||
--- Strips HTML/wikilink markup, ensures protocol, and returns a cleaned URL and display label. | |||
--- Cleans and normalises a URL string. | |||
--- | |||
--- @param url string Raw URL. | |||
--- @param text string Optional display text. | |||
--- @return string|nil cleanUrl Cleaned URL for linking. | |||
--- @return string|nil displayText Display label for the URL. | |||
function M.url( url, text ) | |||
local localUrl = url | |||
localUrl = localUrl or extractUrl( localUrl ) or extractUrl( text ) or '' | |||
-- Strip out HTML tags and wikilink brackets | |||
localUrl = _gsub( localUrl, '<[^>]*>', '' ) or '' | |||
localUrl = _gsub( localUrl, '[%[%]]', '' ) or '' | |||
-- Handle common URL prefixes and ensure proper protocol | |||
localUrl = _gsub( localUrl, '^[Ww][Ww][Ww]%.', 'http://www.' ) or '' | |||
-- Process the URL and generate label | |||
local cleanUrl, displayText = _url( localUrl, text ) | |||
-- Enhanced label generation for URLs - domain-only format | |||
if cleanUrl and not text then | |||
local uri = safeUri( cleanUrl ) | |||
if uri and uri.host then | |||
-- Generate clean domain label (e.g., "en.wikipedia.org") | |||
displayText = _lower( uri.host ) | |||
-- Remove 'www.' prefix for cleaner display | |||
displayText = _gsub( displayText, '^www%.', '' ) | |||
end | end | ||
end | end | ||
return cleanUrl, displayText | |||
end | end | ||
function | --- Generate tracking categories. | ||
--- Checks for unknown parameter use and validates input arguments. | |||
--- | |||
--- @param oldClassMatched string|nil Whether the parser matched any legacy classes in input. | |||
--- @param rawArgs table Raw arguments passed to the module. | |||
--- @return string categories Category wikitext. | |||
local function renderTrackingCategories( category, class, nocat, link, url, disabled, oldClassMatched, rawArgs ) | |||
local categories = '' | |||
class = _type( class ) == 'string' and _lower( class ) or '' | |||
--- Don't add categories if `nocat==true` or `category` is falsy, | |||
--- but still add any custom category passed in. | |||
if category and category ~= '' and M.yesno( category ) ~= false then | |||
-- Extract category name if in wikilink format like [[:Category:Foo Bar|Display]] | |||
local parsed = parseWikilink( category ) | |||
if parsed and parsed.pageName then | |||
categories = _format( '[[%s]]', parsed.pageName ) | |||
end | |||
end | |||
if M.yesno( nocat ) == true then | |||
return categories | |||
elseif M.yesno( category ) == false then -- Legacy `category=no`. | |||
return categories | |||
end | |||
-- | --- Add categories for outdated classes, dummy buttons, disabled buttons, | ||
-- and | --- and external links. | ||
-- | do | ||
--- Dummy button is: | |||
--- - Clickable (i.e. not disabled visually) | |||
--- - No target link and no URL | |||
--- - Gives feedback it'll do something, but does nothing. | |||
--- All matches to if-statements below should all have `ariaDisabled == true`, | |||
--- and therefore `aria-disabled = true`. | |||
if (not link or (M.yesno( link ) == false)) -- Checks for falsy or `link == 'no'` | |||
and not url and not disabled then | |||
categories = _format( '%s [[%s]]', categories, C.trackingCategories.dummyButton ) | |||
end | |||
--- Disabled button is: | |||
---- Greyed out (`data.disabled == true`). | |||
---- Also disabled to accessibility API (`aria-disabled = true`). | |||
if disabled then | |||
categories = _format( '%s [[%s]]', categories, C.trackingCategories.disabledButton ) | |||
end | end | ||
if class and oldClassMatched then | |||
categories = _format( '%s [[%s]]', categories, C.trackingCategories.outdatedClasses ) | |||
if | |||
end | end | ||
if | if url then | ||
categories = _format( '%s [[%s]]', categories, C.trackingCategories.externalLinks ) | |||
end | end | ||
end | end | ||
--- Check for unknown parameters and add appropriate categories | |||
local unknownParamCategories = checkForUnknowns( C.knownArgs, rawArgs ) or '' | |||
categories = categories .. unknownParamCategories | |||
return categories | |||
end | |||
--- | |||
--- Renders the wikitext span tags for the button. | |||
--- | |||
--- @class mw.html: table MediaWiki DOM document content model based on HTML and RDFa. | |||
--- @param data args table Arguments table. | |||
--- @param iconSpan mw.html|nil Icon span element for the button. | |||
--- @param isUrl boolean Whether target is URL | |||
--- @param ariaDisabled boolean Whether button is disabled for ARIA API. | |||
--- @param categories string Categories for the button. | |||
--- @param errorText string|nil Internal string used both as error indicator, and error message text. | |||
--- @param tblClasses table | |||
--- @return string link Wikitext span tags for the button. | |||
local function renderLink( data, iconSpan, isUrl, ariaDisabled, categories, errorText, tblClasses ) | |||
--- @type mw.html: Span tag that creates the button. | |||
local displaySpan = mw.html.create( 'span' ) | |||
--- @type string|nil Custom CSS style attributes for parent span node (not including plainlinks span tag if URL used). | |||
local styleAttributes = _type( data.style ) == 'string' and data.style or nil | |||
--- @future Additional ARIA attributes for button. If implement 'fake' button for use in collapsible/accordion component, don't forget to declare: | |||
--- displaySpan:attr('aria-haspopup', 'true') --- displaySpan:attr('aria-expanded', 'false') | |||
--- Classes, ARIA `role` and `aria-label`, and `style` attributes for button span tag. | |||
for _, aClass in ipairs( tblClasses or {} ) do | |||
displaySpan:addClass( aClass ) | |||
end | end | ||
- | displaySpan:attr( 'role', 'button' ) | ||
if data.aria_label then | |||
displaySpan:attr( 'aria-label', data.aria_label ) | |||
end | |||
if styleAttributes then | |||
displaySpan:attr( 'style', styleAttributes ) | |||
end | |||
if iconSpan ~= '' then | |||
displaySpan:node( iconSpan ) | |||
data. | end | ||
if data.label then | |||
displaySpan:wikitext( data.label ) | |||
end | |||
--- @type string Wikilink that wraps around button wikitext. | |||
local link | |||
if data.disabled or ariaDisabled then | |||
-- `aria-disabled` attribute for no-link/dummy buttons. | |||
) | -- `aria-disabled` attribute for disabled buttons. | ||
displaySpan:attr( 'aria-disabled', 'true' ) | |||
link = _format( '%s %s', tostring( displaySpan ), categories or '' ) | |||
else | else | ||
displaySpan:attr( 'aria-disabled', 'false' ) | |||
if isUrl then | |||
link = _format( '<span class="plainlinks">[%s %s]</span> %s', data.url, tostring( displaySpan ), | |||
categories or '' ) | |||
elseif isUrl == false and data.link then | |||
link = _format( '[[:%s|%s]] %s', data.link, tostring( displaySpan ), categories or '' ) | |||
else -- `isUrl` should be `nil` to get here, or data.link is nil. | |||
elseif | -- Dummy/disabled button. | ||
link = _format( '%s %s', tostring( displaySpan ), categories or '' ) | |||
end | end | ||
end | end | ||
if class | if errorText then | ||
--- Generate error message when viewed in preview mode of an edit. | |||
--[[ --- If previewing an edit displays first argument, otherwise second. | |||
--- @class ifPreview | |||
--- @field main function | |||
--- @type ifPreview | |||
local ifPreview = require('Module:If preview') ]] | |||
if M.yesno( data.nocat ) ~= true then -- Don't add category if `nocat=true` | |||
link = _format( '%s [[%s]]', link, C.trackingCategories.errors ) | |||
end -- Add error message to the link if viewing in preview mode. | |||
mw.addWarning( errorText ) | |||
end | end | ||
-- | return link | ||
end | |||
--- Parses arguments from old template parameters. For backward compatibility. | |||
--- Subfunction of parseParameters() for efficiency. | |||
--- @param color? string `color` argument. | |||
--- @param class? string `class` argument. | |||
--- @param action? 'progressive'|'destructive'|'default'|string `action` argument. | |||
--- @return string class String with class that did not match, likely custom class(es). | |||
--- @return string action Returns action resolved. | |||
--- @return string|nil matched Value of matched class if any of the arguments matched. | |||
local function checkColorAndClass( color, class, action ) | |||
local actionValue = (_type( action ) == 'string' and action) or '' | |||
color = (_type( color ) == 'string' and color) or '' | |||
class = (_type( class ) == 'string' and _lower( class )) or '' | |||
if color == '' and class == '' then | |||
return '', actionValue, nil | |||
end | |||
-- Resolve action, check against set constants. | |||
for actionName, set in pairs( C.legacyClassSets ) do | |||
if set[ color ] and not C.legacyClassSets[ actionName ][ actionValue ] then | |||
return class, actionName, actionValue -- Found `color`. | |||
end | |||
if set[ class ] and not C.legacyClassSets[ actionName ][ actionValue ] then | |||
return '', actionName, actionValue -- Found `class`. | |||
end | end | ||
if | if set[ actionValue ] then | ||
return class, actionName, actionValue -- Found `action`. | |||
end | end | ||
end | end | ||
return | |||
-- No match. | |||
return class, '', nil | |||
end | end | ||
function | --- Constructs the attributes for the wikitext/HTML elements. | ||
-- | --- @param parsedArgs args Parsed arguments. | ||
local | --- @param ariaDisabled boolean Whether button is disabled for ARIA API. | ||
--- @return args data Data, such as attributes, ready to be assembled. | |||
local | --- @return mw.html|nil iconSpan | ||
--- @return boolean isUrl | |||
--- @return boolean ariaDisabled | |||
--- @return string|nil oldClassMatched | |||
--- @return string|nil errorText Internal string used as both an indicator of an error, and error message text. | |||
--- @return table tblClasses | |||
--- @return mw.title pageTitleObject | |||
local function makeLinkData( parsedArgs, ariaDisabled ) | |||
local data = {} | |||
local tblClasses = { C.cssClasses.base, C.cssClasses.fakeButton } | |||
local iconSpan = nil | |||
local isUrl = false | |||
--- @type string|nil | |||
local errorText = nil | |||
local isSamePage = false | |||
local pageTitleObject = mw.title.getCurrentTitle() | |||
--- @todo do i need string check -- type(parsedArgs.icon) == 'string' | |||
data.icon = parsedArgs.icon or nil | |||
data.disabled = parsedArgs.disabled | |||
-- Decide link vs. URL vs. none | |||
-- URL has priority over link if both provided. | |||
if parsedArgs.url then | |||
isUrl = true | |||
local generatedLabel | |||
-- Process URL with enhanced cleaning and label generation | |||
data.url, generatedLabel = M.url( parsedArgs.url, parsedArgs.label ) | |||
-- Use provided label or fall back to derived label. | |||
data.label = parsedArgs.label or generatedLabel | |||
elseif parsedArgs.link then | |||
isUrl = false | |||
data.link = parsedArgs.link | |||
data.label = parsedArgs.label | |||
-- Same-page detection | |||
local linkTitleObject = mw.title.new( data.link ) | |||
if linkTitleObject and pageTitleObject then | |||
isSamePage = (linkTitleObject.fullText == pageTitleObject.fullText) | |||
end | |||
elseif not parsedArgs.url and not parsedArgs.link then | |||
data.label = parsedArgs.label -- Dummy button as has no link or URL. | |||
end | |||
local class, action, oldClassMatched = checkColorAndClass( parsedArgs.color, parsedArgs.class, parsedArgs.action ) | |||
local weight = _type( parsedArgs.weight ) == 'string' and parsedArgs.weight or C.buttonDefaults.weight | |||
local size = _type( parsedArgs.size ) == 'string' and parsedArgs.size or C.buttonDefaults.size | |||
_table_insert( tblClasses, C.cssClasses.actionPrefix .. action ) | |||
_table_insert( tblClasses, C.cssClasses.weightPrefix .. weight ) | |||
_table_insert( tblClasses, C.cssClasses.sizePrefix .. size ) | |||
if (class and class ~= '') then | |||
_table_insert( tblClasses, class ) -- Custom class. | |||
data.class = class | |||
end | |||
if data.disabled then | |||
_table_insert( tblClasses, C.cssClasses.disabled ) | |||
else | |||
_table_insert( tblClasses, C.cssClasses.enabled ) | |||
end | |||
mw.log( 'Debug classes: ' .. table.concat( tblClasses, ' ' ) ) | |||
mw.log( 'Debug action: ' .. (action or 'nil') ) | |||
mw.log( 'Debug label: ' .. (data.label or 'nil') ) | |||
-- Cannot check length earlier as value changes above. | |||
local labelLength = (_type( data.label ) == 'string' and mw.ustring.len( data.label )) or 0 | |||
if data.label and labelLength > C.labelLimits.maxLength then | |||
_table_insert( tblClasses, C.cssClasses.wordWrap ) | |||
end | |||
--- @TODO Check if current page is the target link, if so, make button darker. | |||
--- @TODO Must still actually use this in the CSS file. | |||
if isSamePage then | |||
_table_insert( tblClasses, C.cssClasses.samePage ) | |||
end | |||
if data.icon then -- Store until end of module for icons CSS output logic. | |||
iconSpan = mw.html.create( 'span' ) | |||
iconSpan:addClass( C.cssClasses.icon ) | |||
iconSpan:addClass( _format( '%s%s', C.cssClasses.iconPrefix, data.icon ) ) | |||
iconSpan:attr( 'aria-hidden', 'true' ) | |||
if not data.label then | |||
-- Icon-only button, add extra class for styling. | |||
_table_insert( tblClasses, C.cssClasses.iconOnly ) | |||
end | end | ||
end | |||
-- Label length checks. | |||
if data.label then | |||
if labelLength > C.labelLimits.maxLength then | |||
errorText = C.labelLengthWarningText | |||
elseif labelLength < C.labelLimits.minLength then | |||
_table_insert( tblClasses, C.cssClasses.shortLabel ) | |||
end | end | ||
end | end | ||
-- | local hasNoLabel = not data.label and not parsedArgs.aria_label | ||
if | -- Error if no aria-label and no visible label, for any non-disabled button | ||
-- (whether it has a link/URL or is a dummy button) | |||
if hasNoLabel and not parsedArgs.disabled then | |||
if | errorText = errorText and _format( '%s %s', errorText, C.noAriaLabelWarningText ) or | ||
link = | C.noAriaLabelWarningText | ||
end | |||
data.aria_label = parsedArgs.aria_label | |||
return data, iconSpan, isUrl, ariaDisabled, oldClassMatched, errorText, tblClasses, pageTitleObject | |||
end | |||
--- Parses the module's arguments for backward compatibility. | |||
--- Validates module arguments and returns parsed arguments. | |||
--- With deprecated parameters from old templates and modules. | |||
--- @param rawArgs args table Module arguments. | |||
--- @return args parsedArgs Parsed arguments. | |||
--- @return boolean ariaDisabled Whether button is disabled for ARIA API. | |||
local function parseParameters( rawArgs ) | |||
--- It's weird that we may make a link a label, but if we truly | |||
--- only got positional argument `1`, then that would mean it's | |||
--- intentional to make both the link and label the same. | |||
--- `label` value priority: `label` > `text` > `2` > `1` | |||
rawArgs.label = rawArgs.label or rawArgs.text or rawArgs[ 2 ] or rawArgs[ 1 ] or nil | |||
rawArgs.disabled = M.yesno( rawArgs.disabled ) or (M.yesno( rawArgs.link ) == false) or false | |||
rawArgs.link = rawArgs.link or rawArgs[ 1 ] or nil | |||
rawArgs[ 1 ] = nil -- Remove positional rawArgs after assigning | |||
rawArgs[ 2 ] = nil | |||
if rawArgs.disabled then | |||
-- If disabled, must not generate link. Usually doesn't but in case. | |||
rawArgs.link = nil | |||
rawArgs.url = nil | |||
end | |||
local parsedLink = rawArgs.link and parseWikilink( rawArgs.link ) | |||
--- @TODO double check next five lines of code | |||
-- If had no label, give autogenerated label. | |||
rawArgs.label = rawArgs.label or (parsedLink and parsedLink.displayText) | |||
-- Try assign newly cleaned link. Fallback if needed. | |||
rawArgs.link = (parsedLink and parsedLink.pageName) or rawArgs.link | |||
if rawArgs.link == '' then | |||
rawArgs.link = nil -- Invalid wikilink, remove it. | |||
end | |||
if rawArgs.link and parsedLink then | |||
-- Fallback to displayText if there was any in wikilink, or `Foo § Bar` or just pagename. | |||
rawArgs.label = rawArgs.label or parsedLink.displayText or nil | |||
rawArgs.link = parsedLink.pageName or rawArgs.link or nil | |||
if rawArgs.link == '' then -- If no link leftover, remove it. | |||
rawArgs.link = nil | |||
end | |||
if parsedLink.pageName and parsedLink.sectionHeading then | |||
rawArgs.link = _format( '%s#%s', parsedLink.pageName, parsedLink.sectionHeading ) | |||
elseif parsedLink.isSectionLink and parsedLink.sectionHeading then | |||
rawArgs.link = _format( '#%s', parsedLink.sectionHeading ) | |||
elseif parsedLink.pageName then | |||
rawArgs.link = parsedLink.pageName | |||
else | else | ||
link = | rawArgs.link = nil | ||
end | end | ||
end | |||
--- `aria-disabled = true` if no link whatsoever, always. Make dummy button. But for accessibility, | |||
--- ARIA must know it won't do anything. | |||
local ariaDisabled = false | |||
if rawArgs.disabled or (not rawArgs.link and not rawArgs.url) then | |||
ariaDisabled = true | |||
end | |||
--- @TODO _OPTION_ to forcefully disable dummy buttons by setting: | |||
---- rawArgs.disabled = true | |||
if rawArgs.label then | |||
--- @TODO refactor: decide if we want to allow [[ or ]] in label, and if so, how to handle it. | |||
--[=[ -- Plain search if [[ or ]] present, to wrap <nowiki> tags. | |||
if string.find(rawArgs.label, '[[', 1, true) or string.find(rawArgs.label, ']]', 1, true) then | |||
rawArgs.label = GSUB(rawArgs.label, '%[%[', '') | |||
rawArgs.label = GSUB(rawArgs.label, '%]%]', '') | |||
if rawArgs.label == '' then -- If no label leftover, remove it. | |||
rawArgs.label = nil | |||
end | |||
end ]=] | |||
rawArgs.label = mw.text.nowiki( rawArgs.label ) | |||
else | else | ||
rawArgs.label = nil | |||
end | |||
rawArgs.nocat = M.yesno( rawArgs.nocat ) | |||
-- Normalize ARIA label keys | |||
rawArgs.aria_label = rawArgs.aria_label or rawArgs[ 'aria-label' ] or rawArgs.arialabel | |||
rawArgs[ 'aria-label' ] = nil | |||
rawArgs.arialabel = nil | |||
return rawArgs, ariaDisabled | |||
end | |||
--- Interface for other Lua modules. | |||
--- Function can be called by other Lua modules to generate wikitext. | |||
--- Note: Does not render CSS files or pre-process arguments like `M.main()`. | |||
--- | |||
--- @param rawArgs args Module's arguments. | |||
--- @return string data Wikitext that renders button, without CSS files. | |||
function M._main( rawArgs ) | |||
local parsedArgs, ariaDisabled = parseParameters( rawArgs ) | |||
local data, iconSpan, isUrl, oldClassMatched, errorText, tblClasses, pageTitleObject | |||
data, iconSpan, isUrl, ariaDisabled, oldClassMatched, errorText, tblClasses, pageTitleObject = makeLinkData( | |||
parsedArgs, ariaDisabled ) | |||
local isExcludedNamespace = false | |||
for _, namespace in ipairs( C.excludedNamespaces ) do -- Don't add tracking categories in excluded namespaces. | |||
if pageTitleObject.nsText == namespace then | |||
isExcludedNamespace = true | |||
parsedArgs.nocat = true -- Redundant, but whatever. | |||
break | |||
end | end | ||
end | end | ||
local categories --- @type string | |||
if not isExcludedNamespace then | |||
categories = renderTrackingCategories( parsedArgs.category, data.class, parsedArgs.nocat, | |||
data.link, data.url, data.disabled, oldClassMatched, rawArgs ) | |||
end | |||
return renderLink( data, iconSpan, isUrl, ariaDisabled, categories, errorText, tblClasses ) | |||
end | end | ||
function | --- Module entry point. Interface for templates and modules. | ||
--- | |||
return '' | --- Pre-processes arguments, inserts CSS files, and renders the button. | ||
--- @usage Called via the `{{#invoke: Clickable button | main }}` parser function. | |||
--- @param frame args Frame object passed by the MediaWiki parser. | |||
--- @return string|nil wikitextOutput Wikitext for insertion on a wiki page. | |||
function M.main( frame ) | |||
local rawArgs = getArgs( frame, { | |||
wrappers = C.wrapperTemplates, | |||
valueFunc = function ( key, value ) -- Custom formatting function for arguments. | |||
value = mw.text.trim( value ) -- Remove whitespace. | |||
if not value or value == '' then -- Remove blank arguments. | |||
return nil | |||
end | |||
if C.lowercaseArgs[ key ] then -- Convert to lowercase. | |||
return _mw_lower( value ) | |||
else | |||
return value | |||
end | |||
end, | |||
} ) | |||
-- Return empty string, and preview warning if no arguments supplied. | |||
do | |||
local hasInput = false | |||
for _, v in pairs( rawArgs ) do | |||
if v and v ~= '' then | |||
hasInput = true | |||
break | |||
end | |||
end | |||
if not hasInput then | |||
mw.addWarning( C.noArgsWarningText ) | |||
return '' | |||
end | |||
end | end | ||
local | |||
local output = M._main( rawArgs ) | |||
local outputCSS = frame:extensionTag( 'templatestyles', '', { -- Insert CSS files into the output. | |||
then | src = C.baseCSS, -- Duplicates are de-duplicated by Parsoid. | ||
} ) | |||
if rawArgs.icon then | |||
outputCSS = _format( '%s%s', outputCSS, frame:extensionTag( 'templatestyles', '', { | |||
src = C.iconsCSS, | |||
} ) ) | |||
end | end | ||
return _format( '%s%s', outputCSS, output ) | |||
end | end | ||
return | return M | ||