Module:Validate gadgets

From Festipedia, hosted by the FR Heritage Group

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