Module:Clickable button/sandbox

--- @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 M = {}

-- 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.
--- @todo Is checkForUnknowns checking validity of input?
--- @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

--- 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 `|`
--- @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

	if not displayText and sectionHeading and pageName then
		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
			-- 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

	return cleanUrl, displayText
end

--- 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 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

		if class and oldClassMatched then
			categories = _format( '%s [[%s]]', categories, C.trackingCategories.outdatedClasses )
		end
		if url then
			categories = _format( '%s [[%s]]', categories, C.trackingCategories.externalLinks )
		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

	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 )
	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
		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.
			-- Dummy/disabled button.
			link = _format( '%s %s', tostring( displaySpan ), categories or '' )
		end
	end

	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

	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
		if set[ actionValue ] then
			return class, actionName, actionValue -- Found `action`.
		end
	end

	-- No match.
	return class, '', nil
end

--- Constructs the attributes for the wikitext/HTML elements.
--- @param parsedArgs args Parsed arguments.
--- @param ariaDisabled boolean Whether button is disabled for ARIA API.
--- @return args data Data, such as attributes, ready to be assembled.
--- @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

	-- 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

	local hasNoLabel = not data.label and not parsedArgs.aria_label

	-- 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
		errorText = errorText and _format( '%s %s', errorText, C.noAriaLabelWarningText ) or
			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
			rawArgs.link = nil
		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
		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

	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

--- Module entry point. Interface for templates and modules.
---
--- 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

	local output = M._main( rawArgs )

	local outputCSS = frame:extensionTag( 'templatestyles', '', { -- Insert CSS files into the output.
		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

	return _format( '%s%s', outputCSS, output )
end

return M

Content Disclaimer

Informasi ini disarikan dari Wikipedia dan disajikan kembali untuk tujuan edukasi. Konten tersedia di bawah lisensi CC BY-SA 3.0. Kami tidak bertanggung jawab atas ketidakakuratan data yang bersumber dari kontribusi publik tersebut.

  1. The information displayed on this website is sourced in part or in whole from Wikipedia and has been adapted for the purpose of restating it. We strive to provide accurate and relevant information, however:
  2. There is no guarantee of absolute accuracy. Wikipedia is an open, collaborative project that can be edited by anyone, so information is subject to change.
  3. It is not intended to constitute professional advice. The content displayed is for informational and educational purposes only. For important decisions (e.g., medical, legal, or financial), please consult a professional.
  4. Content copyright. Wikipedia is licensed under the Creative Commons Attribution-ShareAlike License (CC BY-SA). This means that content may be reused with appropriate attribution and shared under a similar license.
  5. Responsible use. Any risk arising from the use of information from this website is entirely the responsibility of the user.