Module:Validate gadgets
Appearance
	
	
| This module is used in system messages.  Changes to it can cause immediate changes to the Festipedia user interface. To avoid large-scale disruption, any changes should first be tested in this module's /sandbox or /testcases subpage, or in your own user space. The tested changes can then be added in one single edit to this module. Please discuss any changes on the talk page before implementing them.  | 
| This module depends on the following other modules: | 
{{#invoke:Validate gadgets|validate}}
This module checks the gadget definitions in MediaWiki:Gadgets-definition for errors and other issues.
No output is produced if there are no warnings. But during previews, a message with a green check will be shown.
Note: the validation of values specified in rights and actions is imperfect as it relies on incomplete hardcoded lists of valid values.
local messageBox = require('Module:Message box')
local p = {}
local function arr_contains(array, val)
    for _, value in ipairs(array) do
        if value == val then
            return true
        end
    end
    return false
end
-- Lists of valid options for things that aren't exposed to lua 
-- (unlike namespaces that can be accessed from mw.site.namespaces)
local VALID_SKINS = {'vector', 'vector-2022', 'minerva', 'timeless', 'monobook', 'modern', 'cologneblue'}
local VALID_CONTENT_MODELS = {'wikitext', 'javascript', 'css', 'json', 'MassMessageListContent', 'Scribunto', 'sanitized-css'}
local VALID_RIGHTS = {'edit', 'minoredit', 'viewmywatchlist', 'rollback', 'autoconfirmed', 'extendedconfirmed', 'delete', 'patrol', 'review', 'block'} -- not exhaustive
local VALID_ACTIONS = {'view', 'edit', 'history', 'info', 'move', 'delete', 'undelete', 'protect', 'block' } -- not exhaustive
p.validate = function (frame)
	local text = mw.title.new('MediaWiki:Gadgets-definition'):getContent()
	local lines = mw.text.split(text, '\n', false)
	
	local repo = {}
	local allWarnings = {}	
	for _, line in ipairs(lines) do
		if line:sub(1, 1) == '*' then
			local name, options, pages = p.parse_line(line)
			if not name or #pages == 0 then
				table.insert(allWarnings, '* Invalid definition: '..line)
			else
				repo[name] = { options = options, pages = pages }
			end
		end
	end
	
	for name, conf in pairs(repo) do
		local warnings = p.create_warnings(name, conf.options, conf.pages, repo)
		for _, warning in ipairs(warnings) do
			table.insert(allWarnings, '*'..name..': '..warning)
		end
	end
	if #allWarnings ~= 0 then
		return messageBox.main('ombox', {
			text = '<b>Issues in gadget definitions:</b>\n' .. table.concat(allWarnings, '\n'),
			type = 'delete',
			class = 'gadgets-validation'
		})
	elseif require('Module:If preview/configuration').preview then
		return messageBox.main('ombox', {
			text = '<b>Issues in gadget definitions:</b> <i>No issues found!</i>',
			type = 'notice',
			image = '[[File:Check-green.svg|30px]]',
			class = 'gadgets-validation'
		})
	else 
		return ''
	end
end
p.parse_line = function(def) 
	local pattern = "^%*%s*(.+)%s*(%b[])%s*(.-)$"
	local name, opts, pageList = string.match(def, pattern)
	
	name = mw.text.trim(name)
	-- Process options string into a Lua table
    local options = {}	
	if opts then
	    -- Extracting the options without square brackets and trimming spaces
	    opts = opts:sub(2, -2):gsub("%s+", "")
	    
	    for pair in opts:gmatch("%s*([^|]+)%s*|?") do
		    local key, value = pair:match("%s*([^=]+)%s*=%s*([^=|]+)%s*")
		    if key and value then
		        options[key:match("%s*(.-)%s*$")] = value:match("^%s*(.-)%s*$")
		    else
		        key = pair:match("%s*(.-)%s*$")
		        options[key] = true
		    end
		end
	end
	-- Process page list into an array
	local pages = {}
	if pageList then
	    for page in pageList:gmatch("[^|]+") do
	        table.insert(pages, mw.text.trim(page))
	    end
    end
    return name, options, pages
end
p.create_warnings = function(name, options, pages, repo)
	local warnings = {}
	
	-- RL module name (ext.gadget.<name>) should not exceed 255 bytes
	-- so a limit of 255 - 11 = 244 bytes for gadget name
	if string.len(name) > 244 then
		table.insert(warnings, 'Gadget name must not exceed 244 bytes')
	end
	-- Per ResourceLoader::isValidModuleName
	if name:gsub('[|,!]', '') ~= name then
		table.insert(warnings, 'Gadget name must not contain pipes (|), commas (,) or exclamation marks (!)')
	end
	-- Pattern per MediaWikiGadgetDefinitionsRepo::newFromDefinition
	if not string.match(name, "^[a-zA-Z][-_:%.%w ]*[a-zA-Z0-9]?$") then
		table.insert(warnings, 'Gadget name is used as part of the name of a form field, and must follow the rules defined in https://www.w3.org/TR/html4/types.html#type-cdata')
	end
    if options.type ~= nil and options.type ~= 'general' and options.type ~= 'styles' then
    	table.insert(warnings, 'Allowed values for type are: general, styles')
    end
    if options.targets ~= nil then
    	for _, target in ipairs(mw.text.split(options.targets, ',', false)) do
    		if target ~= 'desktop' and target ~= 'mobile' then
    			table.insert(warnings, 'Target '..target..' is invalid. Allowed values: desktop, mobile')
    		end
    	end
    end
    if options.namespaces ~= nil then
    	for _, id in ipairs(mw.text.split(options.namespaces, ',', false)) do
    		if not string.match(id, '^-?%d+$') then
    			table.insert(warnings, 'Invalid namespace id: '..id..' - must be numeric')
    		elseif mw.site.namespaces[tonumber(id)] == nil then
    			table.insert(warnings, 'Namespace id '..id..' is invalid')
    		end
    	end
    end
    if options.actions ~= nil then
    	for _, action in ipairs(mw.text.split(options.actions, ',', false)) do
    		if not arr_contains(VALID_ACTIONS, action) then
    			table.insert(warnings, 'Action '..action..' is unrecognised')
    		end
    	end
    end
    if options.contentModels ~= nil then
    	for _, model in ipairs(mw.text.split(options.contentModels, ',', false)) do
    		if not arr_contains(VALID_CONTENT_MODELS, model) then
    			table.insert(warnings, 'Content model '..model..' is unrecognised')
    		end
    	end
    end
    if options.skins ~= nil then
    	for _, skin in ipairs(mw.text.split(options.skins, ',', false)) do
    		if not arr_contains(VALID_SKINS, skin) then
    			table.insert(warnings, 'Skin '..skin..' is not available')
    		end
    	end
    end
    if options.rights ~= nil then
    	for _, right in ipairs(mw.text.split(options.rights, ',', false)) do
    		if not arr_contains(VALID_RIGHTS, right) then
    			table.insert(warnings, 'User right '..right..' does not exist')
    		end
    	end
    end
    local scripts = {}
    local styles = {}
    local jsons = {}
    for _, page in ipairs(pages) do
    	page = 'MediaWiki:Gadget-' .. page
    	local title = mw.title.new(page)
    	if title == nil or not title.exists then
    		table.insert(warnings, 'Page [['..page..']] does not exist')
    	end
    	local ext = title.text:match("%.([^%.]+)$")
    	if ext == 'js' then
    		if title.contentModel ~= 'javascript' then
    			table.insert(warnings, 'Page [['..page..']] is not of JavaScript content model')
    		else
    			table.insert(scripts, page)
    		end
    	elseif ext == 'css' then
    		if title.contentModel ~= 'css' then
    			table.insert(warnings, 'Page [['..page..']] is not of CSS content model')
    		else
    			table.insert(styles, page)
    		end
    	elseif ext == 'json' then
    		if title.contentModel ~= 'json' then
    			table.insert(warnings, 'Page [['..page..']] is not of JSON content model')
    		else
    			table.insert(jsons, page)
    		end
    	else
    		table.insert(warnings, 'Page [['..page..']] is not JS/CSS/JSON, will be ignored')
    	end
    end
    if not options.hidden then
	    local description_page = mw.title.new('MediaWiki:Gadget-'..name)
	    if description_page == nil or not description_page.exists then
	    	table.insert(warnings, 'Description [['..description_page.fullText..']] for use in Special:Preferences does not exist')
	    end
	end
    if options.package == nil and #jsons > 0 then
    	table.insert(warnings, 'JSON pages cannot be used in non-package gadgets')
    end
    if options.requiresES6 ~= nil and options.default ~= nil then
    	table.insert(warnings, 'Default gadget cannot use requiresES6 flag')
    end
    if options.type == 'styles' and #scripts > 0 then
    	table.insert(warnings, 'JS pages will be ignored as gadget sets type=styles')
    end
    if options.type == 'styles' and options.peers ~= nil then
    	table.insert(warnings, 'Styles-only gadget cannot have peers')
    end
    if options.type == 'styles' and options.dependencies ~= nil then
    	table.insert(warnings, 'Styles-only gadget cannot have dependencies')
    end
    if options.package ~= nil and #scripts == 0 then
    	table.insert(warnings, 'Package gadget must have at least one JS page')
    end
    if options.ResourceLoader == nil and #scripts > 0 then
    	table.insert(warnings, 'ResourceLoader option must be set')
    end
    
    -- Causes warnings on styles-only gadgets using skins param 
    -- if options.hidden ~= nil and (options.namespaces ~= nil or options.actions ~= nil or options.rights ~= nil or options.contentModels ~= nil or options.skins ~= nil) then
    -- 	table.insert(warnings, 'Conditional load options are not applicable for hidden gadget')
    -- end
	if options.peers ~= nil then
		for _, peer in ipairs(mw.text.split(options.peers, ',', false)) do 
			if repo[peer] == nil then
				table.insert(warnings, 'Peer gadget '..peer..' is not defined')
			elseif p.get_type(repo[peer]) == 'general' then
				table.insert(warnings, 'Peer gadget '..peer..' must be styles-only gadget')
			end
		end
	end
	if options.dependencies ~= nil then
		for _, dep in ipairs(mw.text.split(options.dependencies, ',', false)) do
			if dep:sub(1, 11) == 'ext.gadget.' then
				local dep_gadget = dep:sub(12)
				if repo[dep_gadget] == nil then
					table.insert(warnings, 'Dependency gadget '..dep_gadget..' is not defined')
				end
			end
		end
	end
	return warnings
end
p.get_type = function(def) 
	if def.options.type == 'general' or def.options.type == 'styles' then
		return def.options.type
	end
	if def.options.dependencies ~= nil then
		return 'general'
	end
	for _, page in ipairs(def.pages) do
		if not string.match(page, '%.css$') then
			return 'general'
		end
	end
	return 'styles'
end
return p