Module:TemplateHelpers: Difference between revisions
// via Wikitext Extension for VSCode Tag: Reverted |
// via Wikitext Extension for VSCode |
||
| (47 intermediate revisions by the same user not shown) | |||
| Line 1: | Line 1: | ||
-- | --[[ | ||
* Name: TemplateHelpers | |||
* Author: Mark W. Datysgeld | |||
* Description: Common helper functions for template modules promoting code reuse and consistency across string processing, field handling, normalization, and block rendering | |||
* Notes: String processing functions for manipulating strings and template arguments; field processing functions for handling template fields and values; normalization wrappers for standardizing data formats; block generation helpers for rendering template blocks; category and semantic utilities (DEPRECATED wrappers for SemanticCategoryHelpers); configuration standardization for creating standard config structures; includes caching mechanism and multi-value string processing | |||
]] | |||
local p = {} | local p = {} | ||
-- Dependencies | |||
local linkParser = require('Module:LinkParser') | |||
local CountryData = require('Module:CountryData') | |||
local dateNormalization = require('Module:NormalizationDate') | |||
local CanonicalForms = require('Module:CanonicalForms') | |||
local NormalizationText = require('Module:NormalizationText') | |||
local ListGeneration = require('Module:ListGeneration') | |||
-------------------------------------------------------------------------------- | -------------------------------------------------------------------------------- | ||
| Line 74: | Line 77: | ||
p.FIELD_FORMAT_WITH_TOOLTIP = FIELD_FORMAT_WITH_TOOLTIP | p.FIELD_FORMAT_WITH_TOOLTIP = FIELD_FORMAT_WITH_TOOLTIP | ||
-- | -- Registry for declarative list post-processors | ||
local | local listPostProcessors = { | ||
local | language = function(itemContent) | ||
local | local NormalizationLanguage = require('Module:NormalizationLanguage') | ||
local | local langName = NormalizationLanguage.normalize(itemContent) | ||
local nativeName = NormalizationLanguage.getNativeForm(langName) | |||
local | if nativeName and langName ~= "English" then | ||
return string.format('%s<br/><span style="display:inline-block; width:0.1em; visibility:hidden;">*</span><span style="font-size:75%%;">%s</span>', langName, nativeName) | |||
else | |||
return langName | |||
end | |||
end, | |||
website = function(itemContent) | |||
local linkUrl = itemContent | |||
if not linkUrl:match("^%a+://") then | |||
linkUrl = "https://" .. linkUrl | |||
end | |||
return string.format("[%s %s]", linkUrl, require('Module:LinkParser').strip(itemContent)) | |||
end, | |||
autoWikiLink = function(itemContent) | |||
-- Trim whitespace and check for existing wiki links | |||
local trimmedContent = p.trim(itemContent) | |||
if linkParser.processWikiLink(trimmedContent, "check") then | |||
-- Extract page name and display text | |||
local pageName, displayText = trimmedContent:match("^%[%[([^|]+)|?(.*)%]%]$") | |||
-- Normalize by trimming whitespace | |||
pageName = p.trim(pageName or "") | |||
displayText = p.trim(displayText or "") | |||
-- Reconstruct link, omitting display text if it's same as page name | |||
if displayText == "" or displayText == pageName then | |||
return string.format("[[%s]]", pageName) | |||
else | |||
return string.format("[[%s|%s]]", pageName, displayText) | |||
end | |||
else | |||
-- Not a wiki link, so just wrap it | |||
return string.format("[[%s]]", trimmedContent) | |||
end | |||
end | |||
} | |||
-------------------------------------------------------------------------------- | -------------------------------------------------------------------------------- | ||
| Line 94: | Line 132: | ||
-- Process all keys | -- Process all keys | ||
for key, value in pairs(args) do | for key, value in pairs(args) do | ||
-- | -- Determine if this key is numeric (number type or numeric-string) | ||
local isNumericKey = type(key) == "number" or (type(key) == "string" and tonumber(key) ~= nil) | |||
-- Skip all numeric parameters entirely | |||
if not isNumericKey then | |||
-- | -- Preserve original key and lowercase variant | ||
normalized[key] = value | |||
if type(key) == "string" then | if type(key) == "string" then | ||
normalized[key:lower()] = value | normalized[key:lower()] = value | ||
end | end | ||
end | end | ||
end | end | ||
| Line 118: | Line 155: | ||
function p.joinValues(values, delimiter) | function p.joinValues(values, delimiter) | ||
return NormalizationText.joinValues(values, delimiter) | return NormalizationText.joinValues(values, delimiter) | ||
end | |||
-- Removes duplicate values from an array while preserving order | |||
-- @param t table The array to deduplicate | |||
-- @return table A new array with duplicates removed | |||
function p.removeDuplicates(t) | |||
-- Type checking | |||
if type(t) ~= 'table' then | |||
return {} | |||
end | |||
-- Helper function to check if a value is NaN | |||
local function isNan(v) | |||
return type(v) == 'number' and tostring(v) == '-nan' | |||
end | |||
-- Pre-allocate result table (maximum possible size is #t) | |||
local ret, exists = {}, {} | |||
-- Process each value, preserving order | |||
for i, v in ipairs(t) do | |||
if isNan(v) then | |||
-- NaNs can't be table keys, and they are also unique | |||
ret[#ret + 1] = v | |||
else | |||
if not exists[v] then | |||
ret[#ret + 1] = v | |||
exists[v] = true | |||
end | |||
end | |||
end | |||
return ret | |||
end | end | ||
| Line 132: | Line 202: | ||
local wikiLinkCache = {} | local wikiLinkCache = {} | ||
-- @deprecated See LinkParser.processWikiLink | |||
-- @deprecated | |||
function p.processWikiLink(value, mode) | function p.processWikiLink(value, mode) | ||
return require('Module:LinkParser').processWikiLink(value, mode) | |||
end | end | ||
-- Module-level pattern categories for sanitizing user input | -- Module-level pattern categories for sanitizing user input | ||
| Line 153: | Line 215: | ||
pattern = "%[%[([^|%]]+)%]%]", | pattern = "%[%[([^|%]]+)%]%]", | ||
replacement = function(match) | replacement = function(match) | ||
return | return linkParser.processWikiLink("[[" .. match .. "]]", "strip") | ||
end | end | ||
}, | }, | ||
| Line 159: | Line 221: | ||
pattern = "%[%[([^|%]]+)|([^%]]+)%]%]", | pattern = "%[%[([^|%]]+)|([^%]]+)%]%]", | ||
replacement = function(match1, match2) | replacement = function(match1, match2) | ||
return | return linkParser.processWikiLink("[[" .. match1 .. "|" .. match2 .. "]]", "strip") | ||
end | end | ||
} | } | ||
| Line 190: | Line 252: | ||
-------------------------------------------------------------------------------- | -------------------------------------------------------------------------------- | ||
function p.getFieldValue(args, field) | function p.getFieldValue(args, field) | ||
if field.keys then | -- Cache lookup for performance | ||
local cacheKey = p.generateCacheKey("getFieldValue", field.key or table.concat(field.keys or {}, ","), args) | |||
local cached = p.withCache(cacheKey, function() | |||
-- Case-insensitive lookup logic | |||
return key, args[ | if field.keys then | ||
for _, key in ipairs(field.keys) do | |||
if args[key] and args[key] ~= "" then | |||
return { key = key, value = args[key] } | |||
end | |||
local lowerKey = key:lower() | |||
if args[lowerKey] and args[lowerKey] ~= "" and lowerKey ~= key then | |||
return { key = lowerKey, value = args[lowerKey] } | |||
end | |||
end | end | ||
return { key = nil, value = nil } | |||
end | |||
local lowerKey = key:lower() | |||
if args[lowerKey] and args[lowerKey] ~= "" and lowerKey ~= key then | if field.key then | ||
return lowerKey, args[lowerKey] | if args[field.key] and args[field.key] ~= "" then | ||
return { key = field.key, value = args[field.key] } | |||
end | |||
local lowerKey = field.key:lower() | |||
if args[lowerKey] and args[lowerKey] ~= "" and lowerKey ~= field.key then | |||
return { key = lowerKey, value = args[lowerKey] } | |||
end | end | ||
return { key = field.key, value = nil } | |||
end | end | ||
return nil, nil | |||
end | return { key = nil, value = nil } | ||
end) | |||
return cached.key, cached.value | |||
end | end | ||
| Line 247: | Line 310: | ||
-- Normalization Wrappers | -- Normalization Wrappers | ||
-------------------------------------------------------------------------------- | -------------------------------------------------------------------------------- | ||
-- Wrapper around CountryData for consistent country formatting | -- Wrapper around CountryData for consistent country formatting | ||
| Line 297: | Line 320: | ||
-- Use the caching wrapper | -- Use the caching wrapper | ||
return p.withCache(cacheKey, function() | return p.withCache(cacheKey, function() | ||
return CountryData.formatCountries(value) | |||
end) | end) | ||
end | end | ||
| Line 359: | Line 358: | ||
local showSingleDate = options.showSingleDate ~= false -- true by default | local showSingleDate = options.showSingleDate ~= false -- true by default | ||
local consolidateIdenticalDates = options.consolidateIdenticalDates ~= false -- true by default | local consolidateIdenticalDates = options.consolidateIdenticalDates ~= false -- true by default | ||
-- Global fallback: if only end date is present, treat end as start | |||
if (not startDate or startDate == "") and endDate and endDate ~= "" then | |||
startDate, endDate = endDate, nil | |||
end | |||
-- Handle empty input | -- Handle empty input | ||
| Line 418: | Line 422: | ||
-------------------------------------------------------------------------------- | -------------------------------------------------------------------------------- | ||
-- @deprecated See TemplateStructure.renderTitleBlock and AchievementSystem.renderTitleBlockWithAchievement | |||
-- @deprecated | |||
function p.renderTitleBlock(args, titleClass, titleText, options) | function p.renderTitleBlock(args, titleClass, titleText, options) | ||
options = options or {} | options = options or {} | ||
| Line 426: | Line 428: | ||
-- If achievement support is needed, use AchievementSystem | -- If achievement support is needed, use AchievementSystem | ||
if options.achievementSupport then | if options.achievementSupport then | ||
return require('Module:AchievementSystem').renderTitleBlockWithAchievement( | |||
args, titleClass, titleText, | |||
options.achievementClass or "", | |||
options.achievementId or "", | |||
options.achievementName or "" | |||
) | ) | ||
else | else | ||
-- Otherwise use the basic title block from TemplateStructure | -- Otherwise use the basic title block from TemplateStructure | ||
return require('Module:TemplateStructure').renderTitleBlock(args, titleClass, titleText) | |||
end | end | ||
end | end | ||
-- Renders a standard fields block based on field definitions and processors | -- Renders a standard fields block based on field definitions and processors | ||
| Line 488: | Line 447: | ||
processors = processors or {} | processors = processors or {} | ||
propertyMappings = propertyMappings or {} | propertyMappings = propertyMappings or {} | ||
-- filter out hidden fields | |||
local filteredFields = {} | |||
for _, f in ipairs(fields) do | |||
if not f.hidden then | |||
table.insert(filteredFields, f) | |||
end | |||
end | |||
-- Pre-allocate output table - estimate based on number of fields | -- Pre-allocate output table - estimate based on number of fields | ||
| Line 494: | Line 461: | ||
local outIndex = 1 | local outIndex = 1 | ||
for _, field in ipairs( | for _, field in ipairs(filteredFields) do | ||
local key, value = p.getFieldValue(args, field) | local key, value = p.getFieldValue(args, field) | ||
if value then | if value then | ||
local continue = false | |||
local | |||
-- | -- Handle declarative lists first | ||
if field.list then | |||
-- Use a safe, semicolon-only pattern to avoid breaking up valid names. | |||
local semicolonOnlyPattern = {{pattern = ";%s*", replacement = ";"}} | |||
local items = NormalizationText.splitMultiValueString(value, semicolonOnlyPattern) | |||
if #items > 0 then | |||
local options = {} | |||
if type(field.list) == 'string' then | |||
options.mode = field.list | |||
elseif type(field.list) == 'table' then | |||
for k, v in pairs(field.list) do | |||
options[k] = v | |||
end | |||
end | |||
-- Chain post-processors declaratively | |||
local processorsToApply = {} | |||
if field.autoWikiLink then | |||
table.insert(processorsToApply, "autoWikiLink") | |||
end | |||
if options.postProcess then | |||
table.insert(processorsToApply, options.postProcess) | |||
end | |||
for _, processorName in ipairs(processorsToApply) do | |||
local processorFunc = listPostProcessors[processorName] | |||
if processorFunc then | |||
local originalHook = options.itemHook | |||
options.itemHook = function(item) | |||
if type(originalHook) == 'function' then | |||
item = originalHook(item) | |||
end | |||
local itemContent = type(item) == 'table' and item.content or item | |||
local processedContent = processorFunc(itemContent) | |||
if type(item) == 'table' then | |||
item.content = processedContent | |||
return item | |||
else | |||
return processedContent | |||
end | |||
end | |||
end | |||
end | |||
local listOutput = ListGeneration.createList(items, options) | |||
out[outIndex] = string.format(FIELD_FORMAT, field.label, listOutput) | |||
outIndex = outIndex + 1 | |||
continue = true | |||
end | end | ||
end | end | ||
if not continue then | |||
-- Create sanitization options | |||
local sanitizeOptions = { | |||
preserveWikiLinks = field.autoWikiLink or field.preserveWikiLinks | |||
} | |||
-- Get property name for this field if available (case-insensitive) | |||
local propertyName = nil | |||
for propName, fieldName in pairs(propertyMappings) do | |||
if key and (fieldName == key or tostring(fieldName):lower() == tostring(key):lower()) then | |||
-- | propertyName = propName | ||
local | break | ||
end | |||
end | |||
-- Get tooltip text if property exists | |||
local tooltipText = "" | |||
if propertyName then | |||
tooltipText = require('Module:SemanticCategoryHelpers').getPropertyDescription(propertyName) or "" | |||
end | |||
-- | -- Prepare tooltip attributes if tooltip text exists | ||
local tooltipClass = "" | |||
local tooltipAttr = "" | |||
if tooltipText and tooltipText ~= "" then | |||
-- Escape quotes in tooltip text to prevent HTML attribute issues | |||
local escapedTooltip = tooltipText:gsub('"', '"') | |||
tooltipClass = " has-tooltip" | |||
tooltipAttr = string.format('data-tooltip="%s"', escapedTooltip) | |||
end | |||
-- Handle the case where a processor returns complete HTML | -- Apply processor if available for this field | ||
if key and processors[key] and type(processors[key]) == "function" then | |||
local processedValue = processors[key](value, args) | |||
-- Preserve wiki links if needed | |||
processedValue = linkParser.preserveWikiLinks( | |||
value, | |||
processedValue, | |||
sanitizeOptions.preserveWikiLinks | |||
) | |||
-- Handle the case where a processor returns complete HTML | |||
if type(processedValue) == "table" and processedValue.isCompleteHtml then | |||
-- Add the complete HTML as is | |||
out[outIndex] = processedValue.html | |||
outIndex = outIndex + 1 | |||
elseif processedValue ~= nil and processedValue ~= false then | |||
-- Apply wiki link handling | |||
processedValue = linkParser.applyWikiLinkHandling(processedValue, field) | |||
-- Standard field rendering with tooltip | |||
out[outIndex] = string.format(FIELD_FORMAT_WITH_TOOLTIP, | |||
tooltipClass, tooltipAttr, field.label, processedValue) | |||
outIndex = outIndex + 1 | |||
end | |||
else | |||
-- Standard field rendering without processor | |||
-- Apply sanitization with preserveWikiLinks option if needed | |||
local finalValue | |||
if sanitizeOptions.preserveWikiLinks then | |||
finalValue = value | |||
else | |||
finalValue = p.sanitizeUserInput(value, nil, nil, sanitizeOptions) | |||
end | |||
-- Apply wiki link handling | -- Apply wiki link handling | ||
finalValue = linkParser.applyWikiLinkHandling(finalValue, field) | |||
-- | -- Use format with tooltip | ||
out[outIndex] = string.format(FIELD_FORMAT_WITH_TOOLTIP, | out[outIndex] = string.format(FIELD_FORMAT_WITH_TOOLTIP, | ||
tooltipClass, tooltipAttr, field.label, | tooltipClass, tooltipAttr, field.label, finalValue) | ||
outIndex = outIndex + 1 | outIndex = outIndex + 1 | ||
end | end | ||
end | end | ||
end | end | ||
| Line 576: | Line 600: | ||
end | end | ||
-- @deprecated See TemplateStructure.renderDividerBlock | |||
-- @deprecated | |||
function p.renderDividerBlock(label) | function p.renderDividerBlock(label) | ||
local | return require('Module:TemplateStructure').renderDividerBlock(label) | ||
return | end | ||
-- Extracts semantic value from a field, handling wiki links appropriately | |||
-- @param fieldValue The value to extract semantic data from | |||
-- @param fieldName The name of the field (for error reporting) | |||
-- @param errorContext Optional error context for error handling | |||
-- @return The extracted semantic value or nil if the input is empty | |||
function p.extractSemanticValue(fieldValue, fieldName, errorContext) | |||
if not fieldValue or fieldValue == "" then | |||
return nil | |||
end | |||
-- If the value already has wiki links, extract the name using LinkParser | |||
local LinkParser = require('Module:LinkParser') | |||
if LinkParser.processWikiLink(fieldValue, "check") then | |||
-- Use the standardized error handling helper | |||
return p.withErrorHandling( | |||
errorContext, | |||
"extractFromWikiLink_" .. fieldName, | |||
LinkParser.extractFromWikiLink, | |||
fieldValue, -- fallback to original value on error | |||
fieldValue | |||
) | |||
else | |||
-- Otherwise, use the plain text value | |||
return fieldValue | |||
end | |||
end | end | ||
| Line 686: | Line 735: | ||
-- Configuration Standardization | -- Configuration Standardization | ||
-------------------------------------------------------------------------------- | -------------------------------------------------------------------------------- | ||
--[[ | --[[ | ||