Jump to content

Module:TemplateHelpers: Difference between revisions

// via Wikitext Extension for VSCode
Tag: Manual revert
// via Wikitext Extension for VSCode
 
(134 intermediate revisions by the same user not shown)
Line 1: Line 1:
-- Module:TemplateHelpers
--[[
-- Common helper functions for template modules to promote code reuse and consistency. This module provides utilities for string processing, field handling, normalization, and standardized block rendering to be used across multiple template types.
* 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 = {}
Line 6: Line 10:
-- Dependencies
-- Dependencies
local linkParser = require('Module:LinkParser')
local linkParser = require('Module:LinkParser')
local MultiCountryDisplay = require('Module:MultiCountryDisplay')
local CountryData = require('Module:CountryData')
local dateNormalization = require('Module:DateNormalization')
local dateNormalization = require('Module:NormalizationDate')
local CanonicalForms = require('Module:CanonicalForms')
local CanonicalForms = require('Module:CanonicalForms')
local NormalizationText = require('Module:NormalizationText')
local ListGeneration = require('Module:ListGeneration')
--------------------------------------------------------------------------------
-- Caching Mechanism
--------------------------------------------------------------------------------
-- Module-level unified cache
local functionCache = {}
-- Helper for generating cache keys from multiple arguments
-- @param prefix String prefix for the cache key (usually the function name)
-- @param ... Any number of arguments to include in the cache key
-- @return A string cache key
function p.generateCacheKey(prefix, ...)
    local args = {...}
    local parts = {prefix}
   
    for i, arg in ipairs(args) do
        if type(arg) == "table" then
            -- For tables, we can't reliably generate a cache key
            -- So we just use a placeholder with the table's memory address
            parts[i+1] = "table:" .. tostring(arg)
        elseif type(arg) == "nil" then
            parts[i+1] = "nil"
        else
            parts[i+1] = tostring(arg)
        end
    end
   
    return table.concat(parts, ":")
end
-- Generic caching wrapper
-- @param cacheKey The cache key to use
-- @param operation A function that returns the value to cache
-- @return The cached result or the result of executing the operation
function p.withCache(cacheKey, operation)
    -- Check if result is already cached
    if functionCache[cacheKey] ~= nil then
        return functionCache[cacheKey]
    end
   
    -- Execute operation and cache result
    local result = operation()
    functionCache[cacheKey] = result
    return result
end
--------------------------------------------------------------------------------
-- Constants
--------------------------------------------------------------------------------
-- This constant defines how the "label" string are rendered in HTML; we further manipulate them with the "template-label-style" CSS class
-- Added class to the entire row for alternating row colors and direct cell styling for better control
local FIELD_FORMAT = "|- class=\"template-data-row\"\n| class=\"template-label-cell\" | <span class=\"template-label-style\">%s</span>\n| class=\"template-value-cell\" | %s"
-- Format with tooltip support - includes tooltip data directly on the cell
local FIELD_FORMAT_WITH_TOOLTIP = "|- class=\"template-data-row\"\n| class=\"template-label-cell%s\" %s | <span class=\"template-label-style\">%s</span>\n| class=\"template-value-cell\" | %s"
-- Expose the field formats for use by other modules
p.FIELD_FORMAT = FIELD_FORMAT
p.FIELD_FORMAT_WITH_TOOLTIP = FIELD_FORMAT_WITH_TOOLTIP
-- Registry for declarative list post-processors
local listPostProcessors = {
    language = function(itemContent)
        local NormalizationLanguage = require('Module:NormalizationLanguage')
        local langName = NormalizationLanguage.normalize(itemContent)
        local nativeName = NormalizationLanguage.getNativeForm(langName)
        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
}


--------------------------------------------------------------------------------
--------------------------------------------------------------------------------
-- String Processing Functions
-- String Processing Functions
--------------------------------------------------------------------------------
--------------------------------------------------------------------------------
-- Normalizes template arguments to be case-insensitive
-- Returns a new table with lowercase keys while preserving original keys as well
-- Also handles empty numeric parameters that can occur with {{Template|}} syntax
function p.normalizeArgumentCase(args)
    local normalized = {}
   
    -- Process all keys
    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
                normalized[key:lower()] = value
            end
        end
    end
   
    return normalized
end


-- Trims leading and trailing whitespace from a string
-- Trims leading and trailing whitespace from a string
function p.trim(s)
function p.trim(s)
     return (s:gsub("^%s+", ""):gsub("%s+$", ""))
     return NormalizationText.trim(s)
end
end


-- Splits a semicolon-delimited string into a table of trimmed non-empty values
-- Joins a table of values with the specified delimiter
function p.splitSemicolonValues(value)
function p.joinValues(values, delimiter)
     if not value or value == "" then return {} end
    return NormalizationText.joinValues(values, delimiter)
     local items = {}
end
     for item in string.gmatch(value, "[^;]+") do
 
         local trimmed = item:match("^%s*(.-)%s*$")
-- Removes duplicate values from an array while preserving order
         if trimmed and trimmed ~= "" then
-- @param t table The array to deduplicate
             table.insert(items, trimmed)
-- @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
     end
     end
     return items
   
     return ret
end
end


-- Joins a table of values with the specified delimiter
-- Get current page ID with caching
function p.joinValues(values, delimiter)
-- @return number|nil The current page ID or nil if not available
     delimiter = delimiter or "; "
function p.getCurrentPageId()
    if not values or #values == 0 then return "" end
    -- Use mw.title API to get the current page title object
     return table.concat(values, delimiter)
    local title = mw.title.getCurrentTitle()
    -- Return the ID property or nil if not available
    return title and title.id or nil
end
 
-- Module-level cache for wiki link processing
local wikiLinkCache = {}
 
-- @deprecated See LinkParser.processWikiLink
function p.processWikiLink(value, mode)
     return require('Module:LinkParser').processWikiLink(value, mode)
end
 
 
-- Module-level pattern categories for sanitizing user input
-- These are exposed for potential extension by other modules
p.SANITIZE_PATTERNS = {
    WIKI_LINKS = {
        {
            pattern = "%[%[([^|%]]+)%]%]",
            replacement = function(match)
                return linkParser.processWikiLink("[[" .. match .. "]]", "strip")
            end
        },
        {
            pattern = "%[%[([^|%]]+)|([^%]]+)%]%]",
            replacement = function(match1, match2)
                return linkParser.processWikiLink("[[" .. match1 .. "|" .. match2 .. "]]", "strip")
            end  
        }
    },
    SINGLE_BRACES = {
        { pattern = "{([^{}]+)}", replacement = "%1" }  -- {text} -> text
    },
    HTML_BASIC = {
        { pattern = "</?[bi]>", replacement = "" },    -- Remove <b>, </b>, <i>, </i>
        { pattern = "</?span[^>]*>", replacement = "" } -- Remove <span...>, </span>
    },
    LOGO = {
        { pattern = "^[Ff][Ii][Ll][Ee]%s*:", replacement = "" } -- Remove "File:" prefix
    },
    IMAGE_FILES = {
        { pattern = "%[%[([^|%]]+)%]%]", replacement = "%1" },    -- [[Image.jpg]] -> Image.jpg
        { pattern = "%[%[([^|%]]+)|.+%]%]", replacement = "%1" },  -- [[Image.jpg|...]] -> Image.jpg
        { pattern = "^[Ff][Ii][Ll][Ee]%s*:", replacement = "" },  -- Remove "File:" prefix
        { pattern = "^[Ii][Mm][Aa][Gg][Ee]%s*:", replacement = "" } -- Remove "Image:" prefix too
    }
}
 
-- Sanitizes user input by removing or transforming unwanted patterns
function p.sanitizeUserInput(value, patternCategories, customPatterns, options)
     return NormalizationText.sanitizeUserInput(value, patternCategories, customPatterns, options)
end
end


Line 43: Line 252:
--------------------------------------------------------------------------------
--------------------------------------------------------------------------------


-- Retrieves a field value from args using either multiple possible keys or a single key
function p.getFieldValue(args, field)
function p.getFieldValue(args, field)
     if field.keys then
     -- Cache lookup for performance
        for _, key in ipairs(field.keys) do
    local cacheKey = p.generateCacheKey("getFieldValue", field.key or table.concat(field.keys or {}, ","), args)
             if args[key] and args[key] ~= "" then
    local cached = p.withCache(cacheKey, function()
                 return key, args[key]
        -- Case-insensitive lookup logic
        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
            return { key = nil, value = nil }
        end
 
        if field.key then
             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 }
     return field.key, (args[field.key] and args[field.key] ~= "") and args[field.key] or nil
     end)
     return cached.key, cached.value
end
end


-- Processes multiple values with a given processor function
-- Processes multiple values with a given processor function
-- Uses splitMultiValueString for more flexible delimiter handling
-- Pre-allocates result table for better performance
function p.processMultipleValues(values, processor)
function p.processMultipleValues(values, processor)
     if not values or values == "" then return {} end
     if not values or values == "" then return {} end
     local items = p.splitSemicolonValues(values)
     local items = p.splitMultiValueString(values)
   
    -- Pre-allocate results table based on input size
     local results = {}
     local results = {}
    local resultIndex = 1
   
     for _, item in ipairs(items) do
     for _, item in ipairs(items) do
         local processed = processor(item)
         local processed = processor(item)
         if processed and processed ~= "" then
         if processed and processed ~= "" then
             table.insert(results, processed)
             results[resultIndex] = processed
            resultIndex = resultIndex + 1
         end
         end
     end
     end
Line 74: Line 311:
--------------------------------------------------------------------------------
--------------------------------------------------------------------------------


-- Formats website URLs as either a single link or an HTML unordered list of links
-- Wrapper around CountryData for consistent country formatting
function p.normalizeWebsites(value)
    if not value or value == "" then return "" end
    local websites = p.splitSemicolonValues(value)
    if #websites > 1 then
        local listItems = {}
        for _, site in ipairs(websites) do
            local formattedLink = string.format("[%s %s]", site, linkParser.strip(site))
            table.insert(listItems, string.format("<li>%s</li>", formattedLink))
        end
        return string.format("<ul class=\"template-list template-list-website\" style=\"margin:0; padding-left:1em;\">%s</ul>", table.concat(listItems, ""))
    elseif #websites == 1 then
        return string.format("[%s %s]", websites[1], linkParser.strip(websites[1]))
    end
    return ""
end
 
-- Wrapper around MultiCountryDisplay for consistent country formatting
function p.normalizeCountries(value)
function p.normalizeCountries(value)
     if not value or value == "" then return "" end
     if not value or value == "" then return "" end
     return MultiCountryDisplay.formatCountries(value)
      
    -- Create a cache key
    local cacheKey = p.generateCacheKey("normalizeCountries", value)
   
    -- Use the caching wrapper
    return p.withCache(cacheKey, function()
        return CountryData.formatCountries(value)
    end)
end
end


Line 100: Line 327:
function p.normalizeDates(value)
function p.normalizeDates(value)
     if not value or value == "" then return "" end
     if not value or value == "" then return "" end
     return tostring(dateNormalization.formatDate(value))
      
    -- Create a cache key
    local cacheKey = p.generateCacheKey("normalizeDates", value)
   
    -- Use the caching wrapper
    return p.withCache(cacheKey, function()
        return tostring(dateNormalization.formatDate(value))
    end)
end
 
-- Formats a date range with configurable options
-- @param startDate The start date string
-- @param endDate The end date string (optional)
-- @param options Table of options for customizing the output:
--  - dateLabel: Label to use for the date field (default: ConfigRepository.fieldLabels.date)
--  - rangeDelimiter: String to use between dates (default: " – " [en dash])
--  - outputMode: Output format - "complete" (default), "text", or "html"
--  - showSingleDate: Whether to show the start date when end date is missing (default: true)
--  - consolidateIdenticalDates: Whether to show only one date when start=end (default: true)
-- @return Based on outputMode:
--  - "text": Returns the formatted date text as a string
--  - "html": Returns the complete HTML for the date field as a string
--  - "complete": Returns a table with text, html, and isCompleteHtml properties
function p.formatDateRange(startDate, endDate, options)
    -- Default options
    options = options or {}
    local dateLabel = options.dateLabel or (require('Module:ConfigRepository').fieldLabels.date)
    local rangeDelimiter = options.rangeDelimiter or " – " -- en dash
    local outputMode = options.outputMode or "complete" -- "complete", "text", or "html"
    local showSingleDate = options.showSingleDate ~= 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
    if not startDate or startDate == "" then
        if outputMode == "text" then return "" end
        if outputMode == "html" then return "" end
        return { text = "", html = "", isCompleteHtml = true }
    end
   
    -- Create a cache key
    -- For options, we only include the values that affect the output
    local optionsKey = string.format(
        "%s:%s:%s:%s",
        dateLabel,
        rangeDelimiter,
        outputMode,
        consolidateIdenticalDates and "consolidate" or "noconsolidate"
    )
   
    local cacheKey = p.generateCacheKey("formatDateRange", startDate, endDate or "nil", optionsKey)
   
    -- Use the caching wrapper
    return p.withCache(cacheKey, function()
        -- Normalize dates
        local startFormatted = p.normalizeDates(startDate)
        local endFormatted = endDate and endDate ~= "" and p.normalizeDates(endDate) or nil
       
        -- Format date text based on options
        local dateText
        if endFormatted and endFormatted ~= startFormatted then
            -- Different start and end dates
            dateText = startFormatted .. rangeDelimiter .. endFormatted
        elseif endFormatted and endFormatted == startFormatted and not consolidateIdenticalDates then
            -- Same start and end dates, but option to show both
            dateText = startFormatted .. rangeDelimiter .. endFormatted
        else
            -- Single date or consolidated identical dates
            dateText = startFormatted
        end
       
        -- Format HTML using the field format and label
        local dateHtml = string.format(FIELD_FORMAT, dateLabel, dateText)
       
        -- Return based on requested output mode
        if outputMode == "text" then return dateText end
        if outputMode == "html" then return dateHtml end
       
        -- Default: return both formats
        return {
            text = dateText,
            html = dateHtml,
            isCompleteHtml = true -- For compatibility with existing code
        }
    end)
end
end


Line 107: Line 422:
--------------------------------------------------------------------------------
--------------------------------------------------------------------------------


-- Generates a standard title block with configurable class and text
-- @deprecated See TemplateStructure.renderTitleBlock and AchievementSystem.renderTitleBlockWithAchievement
function p.renderTitleBlock(args, titleClass, titleText)
function p.renderTitleBlock(args, titleClass, titleText, options)
     titleClass = titleClass or "template-title"
     options = options or {}
     return string.format('|-\n! colspan="2" class="%s" | %s', titleClass, titleText)
   
    -- If achievement support is needed, use AchievementSystem
     if options.achievementSupport then
        return require('Module:AchievementSystem').renderTitleBlockWithAchievement(
            args, titleClass, titleText,
            options.achievementClass or "",
            options.achievementId or "",
            options.achievementName or ""
        )
    else
        -- Otherwise use the basic title block from TemplateStructure
        return require('Module:TemplateStructure').renderTitleBlock(args, titleClass, titleText)
    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
function p.renderFieldsBlock(args, fields, processors)
-- Enhanced to support complete HTML blocks, custom field rendering, and tooltips
-- Pre-allocates output table for better performance
function p.renderFieldsBlock(args, fields, processors, propertyMappings)
     processors = processors or {}
     processors = processors 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
    -- Not all fields may be present in args, but this gives us a reasonable upper bound
     local out = {}
     local out = {}
    local outIndex = 1
      
      
     for _, field in ipairs(fields) do
     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
             -- Apply processor if available for this field
             local continue = false
            if key and processors[key] and type(processors[key]) == "function" then
           
                value = processors[key](value, args)
            -- 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
              
 
            -- Skip insertion if processor returned nil or false
             if not continue then
            if value ~= nil and value ~= false then
                -- Create sanitization options
                 table.insert(out, string.format("|-\n| '''%s''':\n| %s", field.label, value))
                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
                        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
               
                -- 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
                    finalValue = linkParser.applyWikiLinkHandling(finalValue, field)
                   
                    -- Use format with tooltip
                    out[outIndex] = string.format(FIELD_FORMAT_WITH_TOOLTIP,
                        tooltipClass, tooltipAttr, field.label, finalValue)
                    outIndex = outIndex + 1
                end
             end
             end
         end
         end
Line 136: Line 600:
end
end


-- Renders a standard divider block with optional label
-- @deprecated See TemplateStructure.renderDividerBlock
function p.renderDividerBlock(label)
function p.renderDividerBlock(label)
     if label and label ~= "" then
    return require('Module:TemplateStructure').renderDividerBlock(label)
         return string.format('|-\n| colspan="2" class="template-divider" |\n|-\n| colspan="2" class="icannwiki-centered" | <span class="icannwiki-bold">%s</span>', label)
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
     else
         return '|-\n| colspan="2" class="template-divider" |'
         -- Otherwise, use the plain text value
        return fieldValue
    end
end
 
-- Standardized error handling helper
-- Executes a function with error protection if an error context is provided
-- @param errorContext The error context for error handling (optional)
-- @param functionName The name of the function being protected (for error reporting)
-- @param operation The function to execute
-- @param fallback The fallback value to return if an error occurs
-- @param ... Additional arguments to pass to the operation function
-- @return The result of the operation or the fallback value if an error occurs
function p.withErrorHandling(errorContext, functionName, operation, fallback, ...)
    -- Capture varargs in a local table to avoid using ... multiple times
    local args = {...}
   
    -- If no error context is provided, execute the operation directly
    if not errorContext or type(errorContext) ~= "table" then
        return operation(unpack(args))
    end
   
    -- Use ErrorHandling module for protected execution
    local ErrorHandling = require('Module:ErrorHandling')
   
    -- Create a wrapper function that passes the arguments to the operation
    local wrapper = function()
        return operation(unpack(args))
    end
   
    -- Execute with error protection
    return ErrorHandling.protect(
        errorContext,
        functionName,
        wrapper,
        fallback
    )
end
 
-- Renders a standard logo block with sanitized image path
-- @param args The template arguments
-- @param options Table of options for customizing the output:
--  - cssClass: CSS class for the logo container (default: "template-logo")
--  - imageParams: Additional image parameters like size, alignment (default: "")
--  - errorContext: Optional error context for error handling
-- @return The rendered logo block HTML or empty string if no logo
function p.renderLogoBlock(args, options)
    -- Default options
    options = options or {}
    local cssClass = options.cssClass or "template-logo"
    local imageParams = options.imageParams or ""
   
    -- Define the logo rendering operation
    local function renderLogoOperation(args, cssClass, imageParams)
        -- Get logo parameter
        local logo = args["logo"]
       
        -- If no logo or empty, return empty string
        if not logo or logo == "" then
            return ""
        end
       
        -- Sanitize logo path - extract filename and remove prefixes
        logo = p.sanitizeUserInput(logo, "IMAGE_FILES")
       
        -- Format image parameters if provided
        local imgParams = imageParams ~= "" and "|" .. imageParams or ""
       
        -- Render the logo image
        return string.format(
            '|-\n| colspan="2" class="%s" | [[Image:%s%s]]',
            cssClass, logo, imgParams
        )
     end
     end
   
    -- Use the standardized error handling helper
    return p.withErrorHandling(
        options.errorContext,
        "renderLogoBlock",
        renderLogoOperation,
        "", -- Empty string fallback
        args, cssClass, imageParams
    )
end
end


--------------------------------------------------------------------------------
--------------------------------------------------------------------------------
-- Category Utilities
-- Multi-Value String Processing
--------------------------------------------------------------------------------
--------------------------------------------------------------------------------


-- Builds a category string from a table of category names
-- Default delimiters for splitMultiValueString
function p.buildCategories(categories)
-- Defined once as an upvalue to avoid recreating on each function call
     if not categories or #categories == 0 then return "" end
local defaultDelimiters = {
     local formatted = {}
     {pattern = "%s+and%s+", replacement = ";"},
    for _, cat in ipairs(categories) do
     {pattern = ";%s*", replacement = ";"}
        -- Check if the category already has the [[ ]] wrapper
}
        if not string.match(cat, "^%[%[Category:") then
 
            table.insert(formatted, string.format("[[Category:%s]]", cat))
-- Semicolon-only pattern for backward compatibility with splitSemicolonValues
        else
-- Exposed as a module-level constant for use by other modules
            table.insert(formatted, cat)
p.SEMICOLON_PATTERN = {{pattern = ";%s*", replacement = ";"}}
        end
 
    end
-- Generic function to split multi-value strings with various delimiters
     return table.concat(formatted, "\n")
function p.splitMultiValueString(value, delimiters)
     return NormalizationText.splitMultiValueString(value, delimiters)
end
end


-- Adds categories based on a canonical mapping
--------------------------------------------------------------------------------
function p.addMappingCategories(value, mapping)
-- Configuration Standardization
     if not value or value == "" or not mapping then return {} end
--------------------------------------------------------------------------------
     local categories = {}
 
    local canonical = select(1, CanonicalForms.normalize(value, mapping))
--[[
    Renders a table of fields with labels and values using TemplateStructure.
   
    Parameters:
      fields      - Array of field objects, each with:
                    - label: The field label to display
                    - value: The field value to display
                    - class: Optional CSS class for the row
      options      - Optional configuration:
                    - tableClass: CSS class for the table (default: "template-field-table")
                    - tableAttrs: Additional table attributes
                    - fieldFormat: Format string for field rows (default: uses FIELD_FORMAT)
                    - errorContext: Optional error context for error handling
   
    Returns:
      Wikitext markup for the field table
]]
function p.renderFieldTable(fields, options)
    -- Early return for empty fields
     if not fields or #fields == 0 then
        return ""
    end
      
    options = options or {}
      
      
     if canonical then
     -- Define the field table rendering operation
         for _, group in ipairs(mapping) do
    local function renderFieldTableOperation(fields, options)
            if group.canonical == canonical and group.category then
        local TemplateStructure = require('Module:TemplateStructure')
                 table.insert(categories, group.category)
       
                break
        -- Create a config for the render function with optimized defaults
        local config = {
            tableClass = options.tableClass or "template-field-table",
            tableAttrs = options.tableAttrs or 'cellpadding="2"',
            blocks = {}
        }
       
        -- Pre-allocate blocks array based on field count
        local blocks = {}
       
        -- Use the module's FIELD_FORMAT or a custom format if provided
         local fieldFormat = options.fieldFormat or FIELD_FORMAT
       
        -- Create a block function for each field with direct index assignment
        for i = 1, #fields do
            local field = fields[i]
            blocks[i] = function()
                -- Combine the field's class with the template-data-row class
                local fieldClass = field.class and field.class or ""
               
                -- Create the row with proper classes
                local row = '|- class="template-data-row' .. (fieldClass ~= "" and ' ' .. fieldClass or '') .. '"'
               
                -- Format the cells using the field format but replace the row start
                local cellsFormat = fieldFormat:gsub("^[^|]+", "")
               
                 -- Return the complete row
                return row .. string.format(cellsFormat, field.label, field.value)
             end
             end
         end
         end
       
        -- Assign blocks to config
        config.blocks = blocks
       
        -- Use TemplateStructure's render function
        return TemplateStructure.render({}, config, options.errorContext)
     end
     end
      
      
     return categories
    -- Use the standardized error handling helper
     return p.withErrorHandling(
        options.errorContext,
        "renderFieldTable",
        renderFieldTableOperation,
        "", -- Empty string fallback
        fields, options
    )
end
end


return p
return p

Latest revision as of 03:13, 25 August 2025

Documentation for this module may be created at Module:TemplateHelpers/doc

--[[
* 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 = {}

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

--------------------------------------------------------------------------------
-- Caching Mechanism
--------------------------------------------------------------------------------

-- Module-level unified cache
local functionCache = {}

-- Helper for generating cache keys from multiple arguments
-- @param prefix String prefix for the cache key (usually the function name)
-- @param ... Any number of arguments to include in the cache key
-- @return A string cache key
function p.generateCacheKey(prefix, ...)
    local args = {...}
    local parts = {prefix}
    
    for i, arg in ipairs(args) do
        if type(arg) == "table" then
            -- For tables, we can't reliably generate a cache key
            -- So we just use a placeholder with the table's memory address
            parts[i+1] = "table:" .. tostring(arg)
        elseif type(arg) == "nil" then
            parts[i+1] = "nil"
        else
            parts[i+1] = tostring(arg)
        end
    end
    
    return table.concat(parts, ":")
end

-- Generic caching wrapper
-- @param cacheKey The cache key to use
-- @param operation A function that returns the value to cache
-- @return The cached result or the result of executing the operation
function p.withCache(cacheKey, operation)
    -- Check if result is already cached
    if functionCache[cacheKey] ~= nil then
        return functionCache[cacheKey]
    end
    
    -- Execute operation and cache result
    local result = operation()
    functionCache[cacheKey] = result
    return result
end

--------------------------------------------------------------------------------
-- Constants
--------------------------------------------------------------------------------

-- This constant defines how the "label" string are rendered in HTML; we further manipulate them with the "template-label-style" CSS class
-- Added class to the entire row for alternating row colors and direct cell styling for better control
local FIELD_FORMAT = "|- class=\"template-data-row\"\n| class=\"template-label-cell\" | <span class=\"template-label-style\">%s</span>\n| class=\"template-value-cell\" | %s"

-- Format with tooltip support - includes tooltip data directly on the cell
local FIELD_FORMAT_WITH_TOOLTIP = "|- class=\"template-data-row\"\n| class=\"template-label-cell%s\" %s | <span class=\"template-label-style\">%s</span>\n| class=\"template-value-cell\" | %s"

-- Expose the field formats for use by other modules
p.FIELD_FORMAT = FIELD_FORMAT
p.FIELD_FORMAT_WITH_TOOLTIP = FIELD_FORMAT_WITH_TOOLTIP

-- Registry for declarative list post-processors
local listPostProcessors = {
    language = function(itemContent)
        local NormalizationLanguage = require('Module:NormalizationLanguage')
        local langName = NormalizationLanguage.normalize(itemContent)
        local nativeName = NormalizationLanguage.getNativeForm(langName)
        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
}

--------------------------------------------------------------------------------
-- String Processing Functions
--------------------------------------------------------------------------------

-- Normalizes template arguments to be case-insensitive
-- Returns a new table with lowercase keys while preserving original keys as well
-- Also handles empty numeric parameters that can occur with {{Template|}} syntax
function p.normalizeArgumentCase(args)
    local normalized = {}
    
    -- Process all keys
    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
                normalized[key:lower()] = value
            end
        end
    end
    
    return normalized
end

-- Trims leading and trailing whitespace from a string
function p.trim(s)
    return NormalizationText.trim(s)
end

-- Joins a table of values with the specified delimiter
function p.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

-- Get current page ID with caching
-- @return number|nil The current page ID or nil if not available
function p.getCurrentPageId()
    -- Use mw.title API to get the current page title object
    local title = mw.title.getCurrentTitle()
    -- Return the ID property or nil if not available
    return title and title.id or nil
end

-- Module-level cache for wiki link processing
local wikiLinkCache = {}

-- @deprecated See LinkParser.processWikiLink
function p.processWikiLink(value, mode)
    return require('Module:LinkParser').processWikiLink(value, mode)
end


-- Module-level pattern categories for sanitizing user input
-- These are exposed for potential extension by other modules
p.SANITIZE_PATTERNS = {
    WIKI_LINKS = {
        { 
            pattern = "%[%[([^|%]]+)%]%]", 
            replacement = function(match) 
                return linkParser.processWikiLink("[[" .. match .. "]]", "strip") 
            end 
        },
        { 
            pattern = "%[%[([^|%]]+)|([^%]]+)%]%]", 
            replacement = function(match1, match2) 
                return linkParser.processWikiLink("[[" .. match1 .. "|" .. match2 .. "]]", "strip") 
            end 
        }
    },
    SINGLE_BRACES = {
        { pattern = "{([^{}]+)}", replacement = "%1" }  -- {text} -> text
    },
    HTML_BASIC = {
        { pattern = "</?[bi]>", replacement = "" },     -- Remove <b>, </b>, <i>, </i>
        { pattern = "</?span[^>]*>", replacement = "" } -- Remove <span...>, </span>
    },
    LOGO = {
        { pattern = "^[Ff][Ii][Ll][Ee]%s*:", replacement = "" } -- Remove "File:" prefix
    },
    IMAGE_FILES = {
        { pattern = "%[%[([^|%]]+)%]%]", replacement = "%1" },     -- [[Image.jpg]] -> Image.jpg
        { pattern = "%[%[([^|%]]+)|.+%]%]", replacement = "%1" },  -- [[Image.jpg|...]] -> Image.jpg
        { pattern = "^[Ff][Ii][Ll][Ee]%s*:", replacement = "" },   -- Remove "File:" prefix
        { pattern = "^[Ii][Mm][Aa][Gg][Ee]%s*:", replacement = "" } -- Remove "Image:" prefix too
    }
}

-- Sanitizes user input by removing or transforming unwanted patterns
function p.sanitizeUserInput(value, patternCategories, customPatterns, options)
    return NormalizationText.sanitizeUserInput(value, patternCategories, customPatterns, options)
end

--------------------------------------------------------------------------------
-- Field Processing Functions
--------------------------------------------------------------------------------

function p.getFieldValue(args, field)
    -- 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
        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
            return { key = nil, value = nil }
        end

        if field.key then
            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
            return { key = field.key, value = nil }
        end

        return { key = nil, value = nil }
    end)
    return cached.key, cached.value
end

-- Processes multiple values with a given processor function
-- Uses splitMultiValueString for more flexible delimiter handling
-- Pre-allocates result table for better performance
function p.processMultipleValues(values, processor)
    if not values or values == "" then return {} end
    local items = p.splitMultiValueString(values)
    
    -- Pre-allocate results table based on input size
    local results = {}
    local resultIndex = 1
    
    for _, item in ipairs(items) do
        local processed = processor(item)
        if processed and processed ~= "" then
            results[resultIndex] = processed
            resultIndex = resultIndex + 1
        end
    end
    return results
end

--------------------------------------------------------------------------------
-- Normalization Wrappers
--------------------------------------------------------------------------------

-- Wrapper around CountryData for consistent country formatting
function p.normalizeCountries(value)
    if not value or value == "" then return "" end
    
    -- Create a cache key
    local cacheKey = p.generateCacheKey("normalizeCountries", value)
    
    -- Use the caching wrapper
    return p.withCache(cacheKey, function()
        return CountryData.formatCountries(value)
    end)
end

-- Wrapper around DateNormalization for consistent date formatting
function p.normalizeDates(value)
    if not value or value == "" then return "" end
    
    -- Create a cache key
    local cacheKey = p.generateCacheKey("normalizeDates", value)
    
    -- Use the caching wrapper
    return p.withCache(cacheKey, function()
        return tostring(dateNormalization.formatDate(value))
    end)
end

-- Formats a date range with configurable options
-- @param startDate The start date string
-- @param endDate The end date string (optional)
-- @param options Table of options for customizing the output:
--   - dateLabel: Label to use for the date field (default: ConfigRepository.fieldLabels.date)
--   - rangeDelimiter: String to use between dates (default: " – " [en dash])
--   - outputMode: Output format - "complete" (default), "text", or "html"
--   - showSingleDate: Whether to show the start date when end date is missing (default: true)
--   - consolidateIdenticalDates: Whether to show only one date when start=end (default: true)
-- @return Based on outputMode:
--   - "text": Returns the formatted date text as a string
--   - "html": Returns the complete HTML for the date field as a string
--   - "complete": Returns a table with text, html, and isCompleteHtml properties
function p.formatDateRange(startDate, endDate, options)
    -- Default options
    options = options or {}
    local dateLabel = options.dateLabel or (require('Module:ConfigRepository').fieldLabels.date)
    local rangeDelimiter = options.rangeDelimiter or " – " -- en dash
    local outputMode = options.outputMode or "complete" -- "complete", "text", or "html"
    local showSingleDate = options.showSingleDate ~= 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
    if not startDate or startDate == "" then
        if outputMode == "text" then return "" end
        if outputMode == "html" then return "" end
        return { text = "", html = "", isCompleteHtml = true }
    end
    
    -- Create a cache key
    -- For options, we only include the values that affect the output
    local optionsKey = string.format(
        "%s:%s:%s:%s",
        dateLabel,
        rangeDelimiter,
        outputMode,
        consolidateIdenticalDates and "consolidate" or "noconsolidate"
    )
    
    local cacheKey = p.generateCacheKey("formatDateRange", startDate, endDate or "nil", optionsKey)
    
    -- Use the caching wrapper
    return p.withCache(cacheKey, function()
        -- Normalize dates
        local startFormatted = p.normalizeDates(startDate)
        local endFormatted = endDate and endDate ~= "" and p.normalizeDates(endDate) or nil
        
        -- Format date text based on options
        local dateText
        if endFormatted and endFormatted ~= startFormatted then
            -- Different start and end dates
            dateText = startFormatted .. rangeDelimiter .. endFormatted
        elseif endFormatted and endFormatted == startFormatted and not consolidateIdenticalDates then
            -- Same start and end dates, but option to show both
            dateText = startFormatted .. rangeDelimiter .. endFormatted
        else
            -- Single date or consolidated identical dates
            dateText = startFormatted
        end
        
        -- Format HTML using the field format and label
        local dateHtml = string.format(FIELD_FORMAT, dateLabel, dateText)
        
        -- Return based on requested output mode
        if outputMode == "text" then return dateText end
        if outputMode == "html" then return dateHtml end
        
        -- Default: return both formats
        return {
            text = dateText,
            html = dateHtml,
            isCompleteHtml = true -- For compatibility with existing code
        }
    end)
end

--------------------------------------------------------------------------------
-- Block Generation Helpers
--------------------------------------------------------------------------------

-- @deprecated See TemplateStructure.renderTitleBlock and AchievementSystem.renderTitleBlockWithAchievement
function p.renderTitleBlock(args, titleClass, titleText, options)
    options = options or {}
    
    -- If achievement support is needed, use AchievementSystem
    if options.achievementSupport then
        return require('Module:AchievementSystem').renderTitleBlockWithAchievement(
            args, titleClass, titleText, 
            options.achievementClass or "", 
            options.achievementId or "", 
            options.achievementName or ""
        )
    else
        -- Otherwise use the basic title block from TemplateStructure
        return require('Module:TemplateStructure').renderTitleBlock(args, titleClass, titleText)
    end
end


-- Renders a standard fields block based on field definitions and processors
-- Enhanced to support complete HTML blocks, custom field rendering, and tooltips
-- Pre-allocates output table for better performance
function p.renderFieldsBlock(args, fields, processors, propertyMappings)
    processors = processors 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
    -- Not all fields may be present in args, but this gives us a reasonable upper bound
    local out = {}
    local outIndex = 1
    
    for _, field in ipairs(filteredFields) do
        local key, value = p.getFieldValue(args, field)
        if value then
            local continue = false
            
            -- 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

            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
                        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
                
                -- 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
                    finalValue = linkParser.applyWikiLinkHandling(finalValue, field)
                    
                    -- Use format with tooltip
                    out[outIndex] = string.format(FIELD_FORMAT_WITH_TOOLTIP, 
                        tooltipClass, tooltipAttr, field.label, finalValue)
                    outIndex = outIndex + 1
                end
            end
        end
    end
    
    return table.concat(out, "\n")
end

-- @deprecated See TemplateStructure.renderDividerBlock
function p.renderDividerBlock(label)
    return require('Module:TemplateStructure').renderDividerBlock(label)
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

-- Standardized error handling helper
-- Executes a function with error protection if an error context is provided
-- @param errorContext The error context for error handling (optional)
-- @param functionName The name of the function being protected (for error reporting)
-- @param operation The function to execute
-- @param fallback The fallback value to return if an error occurs
-- @param ... Additional arguments to pass to the operation function
-- @return The result of the operation or the fallback value if an error occurs
function p.withErrorHandling(errorContext, functionName, operation, fallback, ...)
    -- Capture varargs in a local table to avoid using ... multiple times
    local args = {...}
    
    -- If no error context is provided, execute the operation directly
    if not errorContext or type(errorContext) ~= "table" then
        return operation(unpack(args))
    end
    
    -- Use ErrorHandling module for protected execution
    local ErrorHandling = require('Module:ErrorHandling')
    
    -- Create a wrapper function that passes the arguments to the operation
    local wrapper = function()
        return operation(unpack(args))
    end
    
    -- Execute with error protection
    return ErrorHandling.protect(
        errorContext,
        functionName,
        wrapper,
        fallback
    )
end

-- Renders a standard logo block with sanitized image path
-- @param args The template arguments
-- @param options Table of options for customizing the output:
--   - cssClass: CSS class for the logo container (default: "template-logo")
--   - imageParams: Additional image parameters like size, alignment (default: "")
--   - errorContext: Optional error context for error handling
-- @return The rendered logo block HTML or empty string if no logo
function p.renderLogoBlock(args, options)
    -- Default options
    options = options or {}
    local cssClass = options.cssClass or "template-logo"
    local imageParams = options.imageParams or ""
    
    -- Define the logo rendering operation
    local function renderLogoOperation(args, cssClass, imageParams)
        -- Get logo parameter
        local logo = args["logo"]
        
        -- If no logo or empty, return empty string
        if not logo or logo == "" then
            return ""
        end
        
        -- Sanitize logo path - extract filename and remove prefixes
        logo = p.sanitizeUserInput(logo, "IMAGE_FILES")
        
        -- Format image parameters if provided
        local imgParams = imageParams ~= "" and "|" .. imageParams or ""
        
        -- Render the logo image
        return string.format(
            '|-\n| colspan="2" class="%s" | [[Image:%s%s]]', 
            cssClass, logo, imgParams
        )
    end
    
    -- Use the standardized error handling helper
    return p.withErrorHandling(
        options.errorContext,
        "renderLogoBlock",
        renderLogoOperation,
        "", -- Empty string fallback
        args, cssClass, imageParams
    )
end

--------------------------------------------------------------------------------
-- Multi-Value String Processing
--------------------------------------------------------------------------------

-- Default delimiters for splitMultiValueString
-- Defined once as an upvalue to avoid recreating on each function call
local defaultDelimiters = {
    {pattern = "%s+and%s+", replacement = ";"},
    {pattern = ";%s*", replacement = ";"}
}

-- Semicolon-only pattern for backward compatibility with splitSemicolonValues
-- Exposed as a module-level constant for use by other modules
p.SEMICOLON_PATTERN = {{pattern = ";%s*", replacement = ";"}}

-- Generic function to split multi-value strings with various delimiters
function p.splitMultiValueString(value, delimiters)
    return NormalizationText.splitMultiValueString(value, delimiters)
end

--------------------------------------------------------------------------------
-- Configuration Standardization
--------------------------------------------------------------------------------

--[[
    Renders a table of fields with labels and values using TemplateStructure.
    
    Parameters:
      fields       - Array of field objects, each with:
                     - label: The field label to display
                     - value: The field value to display
                     - class: Optional CSS class for the row
      options      - Optional configuration:
                     - tableClass: CSS class for the table (default: "template-field-table")
                     - tableAttrs: Additional table attributes
                     - fieldFormat: Format string for field rows (default: uses FIELD_FORMAT)
                     - errorContext: Optional error context for error handling
    
    Returns:
      Wikitext markup for the field table
]]
function p.renderFieldTable(fields, options)
    -- Early return for empty fields
    if not fields or #fields == 0 then
        return ""
    end
    
    options = options or {}
    
    -- Define the field table rendering operation
    local function renderFieldTableOperation(fields, options)
        local TemplateStructure = require('Module:TemplateStructure')
        
        -- Create a config for the render function with optimized defaults
        local config = {
            tableClass = options.tableClass or "template-field-table",
            tableAttrs = options.tableAttrs or 'cellpadding="2"',
            blocks = {}
        }
        
        -- Pre-allocate blocks array based on field count
        local blocks = {}
        
        -- Use the module's FIELD_FORMAT or a custom format if provided
        local fieldFormat = options.fieldFormat or FIELD_FORMAT
        
        -- Create a block function for each field with direct index assignment
        for i = 1, #fields do
            local field = fields[i]
            blocks[i] = function()
                -- Combine the field's class with the template-data-row class
                local fieldClass = field.class and field.class or ""
                
                -- Create the row with proper classes
                local row = '|- class="template-data-row' .. (fieldClass ~= "" and ' ' .. fieldClass or '') .. '"'
                
                -- Format the cells using the field format but replace the row start
                local cellsFormat = fieldFormat:gsub("^[^|]+", "")
                
                -- Return the complete row
                return row .. string.format(cellsFormat, field.label, field.value)
            end
        end
        
        -- Assign blocks to config
        config.blocks = blocks
        
        -- Use TemplateStructure's render function
        return TemplateStructure.render({}, config, options.errorContext)
    end
    
    -- Use the standardized error handling helper
    return p.withErrorHandling(
        options.errorContext,
        "renderFieldTable",
        renderFieldTableOperation,
        "", -- Empty string fallback
        fields, options
    )
end

return p