Jump to content

Module:LuaTemplateBlueprint

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

--[[
 * LuaTemplateBlueprint.lua
 * Provides a unified foundation for all ICANNWiki templates with feature toggling
 * 
 * This module standardizes template architecture, provides feature toggling,
 * centralizes common functionality, and improves maintainability.
 *
 * Integration with other modules:
 * - ErrorHandling: All operations are protected with centralized error handling
 * - ConfigRepository: Templates load configuration from this central repository
 * - TemplateHelpers: Common utilities for rendering and normalization
 * - TemplateStructure: Block-based rendering engine
 * - TemplateFieldProcessor: Field processing and value retrieval
 * - CountryData: Country normalization and region derivation
 * - SocialMedia: Social media icon rendering
 *
 * Feature Configuration:
 * Templates must explicitly specify which features they want to use when registering:
 *
 * local template = Blueprint.registerTemplate('TemplateName', {
 *     features = {
 *         title = true,
 *         fields = true,
 *         semanticProperties = true,
 *         categories = true,
 *         errorReporting = true
 *         -- logo and socialMedia intentionally disabled
 *     }
 * })
 *
 * Alternatively, templates can use predefined feature sets:
 *
 * local template = Blueprint.registerTemplate('TemplateName', {
 *     features = Blueprint.featureSets.minimal
 * })
 *
 * Available feature sets:
 * - minimal: Just title, fields, and error reporting
 * - standard: All features enabled
 * - content: Standard without social media
 * - data: Just fields, semantics, categories, and error reporting
 *
 * Note on parameter handling:
 * Template parameters are extracted using frame:getParent().args and normalized
 * with TemplateHelpers.normalizeArgumentCase() for case-insensitive access.
]]

local p = {}

-- ========== Constants as Upvalues ==========
-- Common empty string for fast returns
local EMPTY_STRING = ''
-- Common separator for concatenation
local NEWLINE = '\n'
-- Common semicolon separator for multi-values
local SEMICOLON = ';'
-- Common category prefix/suffix
local CATEGORY_PREFIX = '[[Category:'
local CATEGORY_SUFFIX = ']]'
-- Template class prefixes
local TEMPLATE_TITLE_CLASS_PREFIX = 'template-title template-title-'
local TEMPLATE_LOGO_CLASS_PREFIX = 'template-logo template-logo-'
-- Default table class
local DEFAULT_TABLE_CLASS = 'template-table'
-- Wiki link pattern for regex matching
local WIKI_LINK_PATTERN = '%[%[.-%]%]'
-- Semantic property HTML templates
local PROPERTY_DIV_PREFIX = '<div style="display:none;">\n  {{#set: '
local PROPERTY_DIV_SUFFIX = ' }}\n</div>'
-- Empty table for returning when needed
local EMPTY_OBJECT = {}

-- ========== Required modules ==========
-- Core modules that are always needed
local ErrorHandling = require('Module:ErrorHandling')
local ConfigRepository = require('Module:ConfigRepository')
local TemplateHelpers = require('Module:TemplateHelpers')
local TemplateStructure = require('Module:TemplateStructure')
local SemanticAnnotations = require('Module:SemanticAnnotations')
local LinkParser = require('Module:LinkParser')
local TemplateFieldProcessor = require('Module:TemplateFieldProcessor')
local mw = mw -- MediaWiki API

-- Module-level caches for expensive operations
local featureCache = {}
local moduleCache = {}  -- Cache for lazy-loaded modules

-- Lazy module loader
local function lazyRequire(moduleName)
    return function()
        if not moduleCache[moduleName] then
            moduleCache[moduleName] = require(moduleName)
        end
        return moduleCache[moduleName]
    end
end

-- Lazily loaded modules
local getSocialMedia = lazyRequire('Module:SocialMedia')
local getNormalizationDate = lazyRequire('Module:NormalizationDate')
local getCountryData = lazyRequire('Module:CountryData')

-- ========== Template Registry ==========
-- Registry to store all registered templates
p.registry = {}

-- Element registry for custom Blueprint elements
p.elementRegistry = {}

-- Register and lookup functions for Blueprint elements
function p.registerElement(name, module)
    if not name or not module then return end
    p.elementRegistry[name] = module
    return module
end

function p.getElement(name)
    return p.elementRegistry[name]
end

function p.getAllElements()
    local list = {}
    for k, v in pairs(p.elementRegistry) do list[k] = v end
    return list
end

-- Create a Blueprint block for a registered element
function p.createElementBlock(name)
    local element = p.getElement(name)
    if not element or not element.createBlock then return end
    return {
        feature = name,
        render = function(template, args)
            return p.protectedExecute(
                template,
                "ElementBlock_" .. name,
                function()
                    return element.createBlock()(template, args)
                end,
                "",
                args
            )
        end
    }
end

-- Add a registered element's block to a template at a given position
function p.addElementToTemplate(template, name, position)
    local block = p.createElementBlock(name)
    if not block then return false end
    template.config.blocks = template.config.blocks or {}
    template.config.blocks[name] = block
    template.features[name] = true
    -- Build a fresh block sequence
    local seq = {}
    for _, b in ipairs(template.config.blockSequence or p.standardBlockSequence) do
        table.insert(seq, b)
    end
    if position and type(position) == "number" and position <= #seq + 1 then
        table.insert(seq, position, name)
    else
        table.insert(seq, name)
    end
    template.config.blockSequence = seq
    return true
end

-- Automatically initialize and register built-in elements
function p.initializeElements()
    local ok, mod = pcall(require, 'Module:ElementNavigation')
    if ok and mod and mod.elementName then
        p.registerElement(mod.elementName, mod)
    end
end

-- ========== Feature Management ==========
-- Common feature sets that templates can use as a starting point
-- Templates must explicitly include the features they want to use
p.featureSets = {
    -- Minimal feature set with just the essentials
    minimal = {
        title = true,
        fields = true,
        errorReporting = true
    },

    -- Standard feature set with all common features
    standard = {
        -- Core rendering features
        title = true,
        logo = true,
        fields = true,
        socialMedia = true,
        
        -- Semantic features
        semanticProperties = true,
        categories = true,
        
        -- Error handling
        errorReporting = true
    },
}

-- Create a cache key for features
-- @param featureOverrides table Optional table of feature overrides
-- @return string Cache key
local function createFeatureCacheKey(featureOverrides)
    if not featureOverrides or not next(featureOverrides) then
        return 'default'
    end
    
    local keyCount = 0
    for _ in pairs(featureOverrides) do keyCount = keyCount + 1 end
    
    local keys = {}
    local keyIndex = 1
    
    for k in pairs(featureOverrides) do
        keys[keyIndex] = k
        keyIndex = keyIndex + 1
    end
    table.sort(keys)
    
    local parts = {}
    for i = 1, keyCount do
        local k = keys[i]
        parts[i] = k .. '=' .. tostring(featureOverrides[k])
    end
    
    return table.concat(parts, ',')
end

-- Initialize feature toggles for a template
-- @param featureOverrides table Table of feature configurations
-- @return table The initialized features
function p.initializeFeatures(featureOverrides)
    -- If no features are specified, return an empty features table
    -- This ensures templates must explicitly define their features
    if not featureOverrides then
        return {}
    end
    
    -- Check if we have a cached version of this feature set
    local cacheKey = createFeatureCacheKey(featureOverrides)
    if featureCache[cacheKey] then
        -- Return a copy of the cached features to prevent modification
        local features = {}
        for k, v in pairs(featureCache[cacheKey]) do
            features[k] = v
        end
        return features
    end
    
    -- Create a new features table with only the specified features
    local features = {}
    for featureId, enabled in pairs(featureOverrides) do
        features[featureId] = enabled
    end
    
    -- Cache the features for future use
    featureCache[cacheKey] = {}
    for k, v in pairs(features) do
        featureCache[cacheKey][k] = v
    end
    
    return features
end

-- ========== Template Registration ==========
-- Register a new template
-- @param templateType string The template type (e.g., "Event", "Person", "TLD")
-- @param config table Configuration overrides for the template
-- @return table The registered template object
function p.registerTemplate(templateType, config)
    local template = {
        type = templateType,
        config = config or {},
        features = p.initializeFeatures(config and config.features or nil)
    }
    
    template.render = function(frame) 
        return p.renderTemplate(template, frame) 
    end
    
    p.registry[templateType] = template
    
    return template
end

-- ========== Error Handling Integration ==========
-- Create an error context for a template
-- @param template table The template object
-- @return table The error context
function p.createErrorContext(template)
    local context = ErrorHandling.createContext(template.type .. "Template")
    template._errorContext = context
    return context
end

-- Execute a function with error protection
-- @param template table The template object
-- @param functionName string Name of the function being protected
-- @param func function The function to execute
-- @param fallback any The fallback value if an error occurs
-- @param ... any Arguments to pass to the function
-- @return any The result of the function or fallback
function p.protectedExecute(template, functionName, func, fallback, ...)
    if not template._errorContext then
        template._errorContext = p.createErrorContext(template)
    end
    
    return ErrorHandling.protect(
        template._errorContext,
        functionName,
        func,
        fallback,
        ...
    )
end

-- ========== Configuration Integration ==========
-- Standard configuration sections used by templates
p.configSections = {
    'meta',
    'categories',
    'patterns',
    'fields',
    'mappings',
    'constants',
    'semantics'
}

-- Initialize the standard configuration for a template
-- Combines base config from ConfigRepository with template overrides
-- @param template table The template object
-- @return table The complete configuration
function p.initializeConfig(template)
    local templateType = template.type
    local configOverrides = template.config or {}
    
    -- Get base configuration from repository
    local baseConfig = ConfigRepository.getStandardConfig(templateType)
    
    -- Apply overrides to each section
    local config = {}
    for _, section in ipairs(p.configSections) do
        config[section] = config[section] or {}
        
        -- Copy base config for this section if available
        if baseConfig[section] then
            for k, v in pairs(baseConfig[section]) do
                config[section][k] = v
            end
        end
        
        -- Apply overrides for this section if available
        if configOverrides[section] then
            for k, v in pairs(configOverrides[section]) do
                config[section][k] = v
            end
        end
    end
    
    -- Store complete config in template
    template.config = config
    
    return config
end

-- ========== Block Framework ==========
-- Standard sequence of blocks for template rendering
p.standardBlockSequence = {
    'title',
    'logo',
    'fields',
    'socialMedia',
    'semanticProperties',
    'categories',
    'errors'
}

-- Standard blocks available to all templates
p.standardBlocks = {
    -- Title block - renders the template title
    title = {
        feature = 'title',
        render = function(template, args)
            return p.protectedExecute(
                template,
                'StandardBlock_title',
                function()
                -- Get title from config if available, otherwise use template type
                local titleText = template.type
                if template.config.constants and template.config.constants.title then
                    titleText = template.config.constants.title
                end
                
                -- Get template ID from config or use the template name as fallback
                local templateId = template.type
                if template.config.meta and template.config.meta.templateId then
                    templateId = template.config.meta.templateId
                end
                
                return TemplateHelpers.renderTitleBlock(
                    args,
                    TEMPLATE_TITLE_CLASS_PREFIX .. string.lower(templateId),
                    titleText,
                    template.config.meta and template.config.meta.titleOptions or {}
                )
                end,
                EMPTY_STRING,
                args
            )
        end
    },
    
    -- Logo block - renders the template logo/image
    logo = {
        feature = 'logo',
        render = function(template, args)
            return p.protectedExecute(
                template,
                'StandardBlock_logo',
                function()
                    local logoClass = TEMPLATE_LOGO_CLASS_PREFIX .. string.lower(template.type)
                    local logoOptions = template.config.meta and template.config.meta.logoOptions or {}
                    
                    -- Use logoField from config or default to 'logo'
                    local logoField = logoOptions.logoField or 'logo'
                    local logoValue = args[logoField]
                    
                    if not logoValue or logoValue == '' then
                        return EMPTY_STRING
                    end
                    
                    -- Create options object for renderLogoBlock
                    local options = {
                        cssClass = logoClass,
                        errorContext = template._errorContext
                    }
                    
                    -- Add any additional options from logoOptions
                    if logoOptions then
                        for k, v in pairs(logoOptions) do
                            options[k] = v
                        end
                    end
                    
                    -- Create args object with logo value
                    local logoArgs = {
                        logo = logoValue
                    }
                    
                    return TemplateHelpers.renderLogoBlock(logoArgs, options)
                end,
                EMPTY_STRING,
                args
            )
        end
    },
    
    -- Fields block - renders all configured fields
    fields = {
        feature = 'fields',
        render = function(template, args)
            return p.protectedExecute(
                template,
                'StandardBlock_fields',
                function()
                    -- Get field definitions from config
                    local fieldDefs = template.config.fields or {}
                    local fields = {}
                    
                    -- Get property mappings from semantics config
                    local propertyMappings = template.config.semantics and 
                                            template.config.semantics.properties or {}
                    
                    -- Add special processor for logo field when logo feature is enabled
                    if not template._processors then
                        template._processors = p.initializeProcessors(template)
                    end
                    
                    -- If logo feature is enabled, add a processor to skip the logo field
                    if template.features.logo then
                        template._processors.logo = function() return false end
                    end
                    
                    -- Process field values using appropriate processors
                    for _, field in ipairs(fieldDefs) do
                        -- Skip hidden fields
                        if not field.hidden then
                            local fieldKey = field.key or (field.keys and field.keys[1] or "unknown")
                            local fieldValue = p.processField(template, field, args)
                            
                            -- Only include fields with values
                            if fieldValue and fieldValue ~= '' then
                                table.insert(fields, {
                                    label = field.label or field.key,
                                    value = fieldValue,
                                    class = field.class
                                })
                            end
                        end
                    end
                    
                    -- Create a processors table for renderFieldsBlock
                    local processors = {}
                    for _, field in ipairs(fields) do
                        local fieldKey = field.label or field.key or (field.keys and field.keys[1] or "unknown")
                        processors[fieldKey] = function(value, args)
                            return field.value
                        end
                    end
                    
                    -- Use renderFieldsBlock which generates only rows, not a complete table
                    -- Pass property mappings for tooltip generation
                    local result = TemplateHelpers.renderFieldsBlock(
                        args,
                        fieldDefs,
                        template._processors,
                        propertyMappings
                    )
                    
                    return result
                end,
                '',
                args
            )
        end
    },
    
    -- Social media block - renders social media links
    socialMedia = {
        feature = 'socialMedia',
        render = function(template, args)
            return p.protectedExecute(
                template,
                'StandardBlock_socialMedia',
                function()
                    -- Use lazily loaded SocialMedia module
                    return getSocialMedia().render(args)
                end,
                '',
                args
            )
        end
    },
    
    -- Semantic properties block - renders semantic properties
    semanticProperties = {
        feature = 'semanticProperties',
        render = function(template, args)
            return p.protectedExecute(
                template,
                'StandardBlock_semanticProperties',
                function()
                    return p.generateSemanticProperties(template, args)
                end,
                '',
                args
            )
        end
    },
    
    -- Categories block - renders category links
    categories = {
        feature = 'categories',
        render = function(template, args)
            return p.protectedExecute(
                template,
                'StandardBlock_categories',
                function()
                    return p.generateCategories(template, args)
                end,
                '',
                args
            )
        end
    },
    
    -- Errors block - renders error messages
    errors = {
        feature = 'errorReporting',
        render = function(template, args)
            return p.protectedExecute(
                template,
                'StandardBlock_errors',
                function()
                    if not template._errorContext then
                        return ''
                    end
                    
                    return ErrorHandling.formatOutput(template._errorContext)
                end,
                '',
                args
            )
        end
    }
}

-- Initialize blocks for a template
-- @param template table The template object
-- @return table The initialized blocks
function p.initializeBlocks(template)
    local customBlocks = template.config.blocks or {}
    local blocks = {}
    local blockSequence = template.config.blockSequence or p.standardBlockSequence
    
    for _, blockId in ipairs(blockSequence) do
        blocks[blockId] = customBlocks[blockId] or p.standardBlocks[blockId]
    end
    
    template._blocks = blocks
    return blocks
end

-- Get block rendering function, respecting feature toggles
-- @param template table The template object
-- @param blockId string The ID of the block to get
-- @return function|nil The rendering function or nil if disabled
function p.getBlockRenderer(template, blockId)
    if not template._blocks then
        template._blocks = p.initializeBlocks(template)
    end
    
    local block = template._blocks[blockId]
    if not block then
        return nil
    end
    
    if block.feature and not template.features[block.feature] then
        return nil -- Feature is disabled
    end
    
    return block.render
end

-- Build block rendering sequence for template
-- @param template table The template object
-- @return table Array of rendering functions
function p.buildRenderingSequence(template)
    local sequence = template.config.blockSequence or p.standardBlockSequence
    local renderingFunctions = {}
    local funcIndex = 1
    
    for _, blockId in ipairs(sequence) do
        local renderer = p.getBlockRenderer(template, blockId)
        if renderer then
            renderingFunctions[funcIndex] = function(args)
                return renderer(template, args)
            end
            funcIndex = funcIndex + 1
        end
    end
    
    renderingFunctions._length = funcIndex - 1
    return renderingFunctions
end

-- ========== Field Processing System ==========
-- Initialize processors for a template
-- @param template table The template object
-- @return table The initialized processors
function p.initializeProcessors(template)
    if template._processors then
        return template._processors
    end
    
    template._processors = TemplateFieldProcessor.initializeProcessors(template)
    return template._processors
end

-- Get processor for a field
-- @param template table The template object
-- @param field table The field definition
-- @return function The processor function
function p.getFieldProcessor(template, field)
    if not template._processors then
        template._processors = p.initializeProcessors(template)
    end
    
    return TemplateFieldProcessor.getFieldProcessor(template._processors, field)
end

-- Get field value from args (delegated to TemplateHelpers)
-- @param field table The field definition
-- @param args table The template arguments
-- @return string|nil The field value or nil if not found
function p.getFieldValue(field, args, template)
    local _, value = TemplateHelpers.getFieldValue(args, field)
    return value
end

-- Process a field using its processor
-- @param template table The template object
-- @param field table The field definition
-- @param args table The template arguments
-- @return string The processed field value
function p.processField(template, field, args)
    if not field then
        return EMPTY_STRING
    end
    
    -- Initialize processors if needed
    if not template._processors then
        template._processors = p.initializeProcessors(template)
    end
    
    -- Use the TemplateFieldProcessor module with error context
    return p.protectedExecute(
        template,
        'processField',
        function()
            return TemplateFieldProcessor.processField(template, field, args, template._errorContext)
        end,
        EMPTY_STRING,
        template,
        field,
        args
    )
end

-- ========== Preprocessing Pipeline ==========
-- Standard preprocessors
p.preprocessors = {
    -- Derive region from country values
    deriveRegionFromCountry = function(template, args)
        if (not args.region or args.region == "") and args.country then
            -- Use lazily loaded CountryData module
            local region = getCountryData().getRegionByCountry(args.country)
            if region and region ~= "" then
                args.region = region
            end
        end
        
        return args
    end,
    
    -- Set the ID field to the current page ID
    setPageIdField = function(template, args)
        -- Get the current page ID and set it in the args table
        local pageId = TemplateHelpers.getCurrentPageId()
        args.ID = tostring(pageId or "")
        args.id = args.ID

        return args
    end
}

-- Register a preprocessor with a template
-- @param template table The template object
-- @param preprocessor function|string The preprocessor function or name
-- @return table The template object (for chaining)
function p.addPreprocessor(template, preprocessor)
    -- Initialize preprocessors array if not exists
    template._preprocessors = template._preprocessors or {}
    
    -- Add preprocessor to array
    table.insert(template._preprocessors, preprocessor)
    
    return template
end

-- Run all preprocessors in sequence
-- @param template table The template object
-- @param args table The template arguments
-- @return table The processed arguments
function p.runPreprocessors(template, args)
    if not template._preprocessors or #template._preprocessors == 0 then
        return args
    end
    
    local processedArgs = {}
    for k, v in pairs(args) do
        processedArgs[k] = v
    end
    
    local preprocessorCount = #template._preprocessors
    
    for i = 1, preprocessorCount do
        local preprocessor = template._preprocessors[i]
        local preprocessorType = type(preprocessor)
        
        if preprocessorType == "function" then
            local result = preprocessor(template, processedArgs)
            if result then
                processedArgs = result
            end
        elseif preprocessorType == "string" then
            local namedPreprocessor = p.preprocessors[preprocessor]
            if namedPreprocessor then
                local result = namedPreprocessor(template, processedArgs)
                if result then
                    processedArgs = result
                end
            end
        end
    end
    
    return processedArgs
end

-- ========== Semantic and Category Integration ==========
-- Register semantic property provider function
-- @param template table The template object
-- @param provider function The provider function
-- @return table The template object (for chaining)
function p.registerPropertyProvider(template, provider)
    -- Initialize property providers array if not exists
    template._propertyProviders = template._propertyProviders or {}
    
    -- Add provider to array
    table.insert(template._propertyProviders, provider)
    
    return template
end

-- Process a value using a transform function
-- @param value string The value to transform
-- @param property string The property name (for error context)
-- @param transform function The transform function
-- @param args table The template arguments
-- @param template table The template object
-- @return string The transformed value
local function applyTransform(value, property, transform, args, template)
    if not value or value == '' then
        return ''
    end
    
    if not transform then
        return value
    end
    
    local transformedValue = p.protectedExecute(
        template,
        'Transform_' .. property,
        function() return transform(value, args, template) end,
        value,
        value,
        args,
        template
    )
    
    return transformedValue or ''
end

-- Generate semantic properties for template
-- @param template table The template object
-- @param args table The template arguments
-- @return string The generated semantic properties HTML
function p.generateSemanticProperties(template, args)
    if not template.features.semanticProperties then
        return EMPTY_STRING
    end
    
    local semanticConfig = template.config.semantics or {}
    local properties = semanticConfig.properties or {}
    local transforms = semanticConfig.transforms or {}
    local additionalProperties = semanticConfig.additionalProperties or {}
    
    if not next(properties) and not next(additionalProperties) and 
       (not template._propertyProviders or #template._propertyProviders == 0) then
        return EMPTY_STRING
    end
    
    -- Set options for SemanticAnnotations
    local semanticOptions = {
        transform = transforms
    }
    
    -- Collect all properties in a single batch for complete deduplication
    local allProperties = {}
    
    -- Add basic properties with transforms handled by SemanticAnnotations
    for property, param in pairs(properties) do
        -- Perform case-insensitive lookup for the parameter key
        local keyName, _ = TemplateHelpers.getFieldValue(args, { key = param })
        allProperties[property] = keyName or param
    end
    
    -- Process additional properties with transforms
    for property, fields in pairs(additionalProperties) do
        local transform = transforms[property]
        for _, fieldName in ipairs(fields) do
            local rawValue = args[fieldName]
            if rawValue and rawValue ~= '' then
                local transformed = applyTransform(rawValue, property, transform, args, template)
                if transformed and transformed ~= '' then
                    local existing = allProperties[property]
                    if existing == nil then
                        allProperties[property] = transformed
                    else
                        if type(existing) ~= "table" then
                            allProperties[property] = { existing }
                        end
                        -- Add only if not already present
                        local found = false
                        for _, v in ipairs(allProperties[property]) do
                            if v == transformed then
                                found = true
                                break
                            end
                        end
                        if not found then
                            table.insert(allProperties[property], transformed)
                        end
                    end
                end
            end
        end
    end
    
    -- Process property providers with deduplication
    if template._propertyProviders then
        for _, provider in ipairs(template._propertyProviders) do
            local providerResult = p.protectedExecute(
                template,
                'PropertyProvider',
                function() return provider(template, args) end,
                {},
                template,
                args
            )
            
            if providerResult and next(providerResult) then
                -- Merge provider properties into allProperties with deduplication
                for property, value in pairs(providerResult) do
                    if type(value) == "table" then
                        -- Provider returned an array of values
                        if allProperties[property] then
                            -- Property already exists, merge arrays with deduplication
                            if type(allProperties[property]) ~= "table" then
                                -- Convert existing value to array
                                allProperties[property] = {allProperties[property]}
                            end
                            
                            -- Track seen values
                            local seenValues = {}
                            for _, v in ipairs(allProperties[property]) do
                                seenValues[v] = true
                            end
                            
                            -- Add unique values from provider
                            for _, v in ipairs(value) do
                                if not seenValues[v] then
                                    seenValues[v] = true
                                    table.insert(allProperties[property], v)
                                end
                            end
                        else
                            -- Property doesn't exist yet, add with internal deduplication
                            local seenValues = {}
                            local uniqueValues = {}
                            
                            for _, v in ipairs(value) do
                                if not seenValues[v] then
                                    seenValues[v] = true
                                    table.insert(uniqueValues, v)
                                end
                            end
                            
                            allProperties[property] = uniqueValues
                        end
                    else
                        -- Provider returned a single value
                        if allProperties[property] then
                            -- Property already exists
                            if type(allProperties[property]) == "table" then
                                -- Existing property is an array, add value if unique
                                local seen = false
                                for _, v in ipairs(allProperties[property]) do
                                    if v == value then
                                        seen = true
                                        break
                                    end
                                end
                                
                                if not seen then
                                    table.insert(allProperties[property], value)
                                end
                            elseif allProperties[property] ~= value then
                                -- Convert to array with both values
                                allProperties[property] = {allProperties[property], value}
                            end
                            -- If existing value equals new value, no change needed
                        else
                            -- Property doesn't exist yet, add directly
                            allProperties[property] = value
                        end
                    end
                end
            end
        end
    end
    
    -- Process all collected properties in one batch
    local semanticOutput = SemanticAnnotations.setSemanticProperties(
        args,
        allProperties,
        semanticOptions
    )
    
    return semanticOutput
end

-- Register category provider function
-- @param template table The template object
-- @param provider function The provider function
-- @return table The template object (for chaining)
function p.registerCategoryProvider(template, provider)
    -- Initialize category providers array if not exists
    template._categoryProviders = template._categoryProviders or {}
    
    -- Add provider to array
    table.insert(template._categoryProviders, provider)
    
    return template
end

-- Generate categories for template
-- @param template table The template object
-- @param args table The template arguments
-- @return string The generated category HTML
function p.generateCategories(template, args)
    if not template.features.categories then
        return EMPTY_STRING
    end
    
    local configCategories = {}
    
    if template.config.categories and template.config.categories.base and 
       type(template.config.categories.base) == "table" then
        configCategories = template.config.categories.base
    elseif template.config.categories and type(template.config.categories) == "table" then
        if #template.config.categories > 0 then
            configCategories = template.config.categories
        end
    end
    
    if #configCategories == 0 and 
       (not template._categoryProviders or #template._categoryProviders == 0) then
        return EMPTY_STRING
    end
    
    -- Use a seen table for deduplication
    local seen = {}
    local uniqueCategories = {}
    local categoryCount = 0
    
    -- Add config categories with deduplication
    for i = 1, #configCategories do
        local category = configCategories[i]
        if category and category ~= "" and not seen[category] then
            seen[category] = true
            categoryCount = categoryCount + 1
            uniqueCategories[categoryCount] = category
        end
    end
    
    -- Process provider categories with deduplication
    if template._categoryProviders then
        for _, provider in ipairs(template._categoryProviders) do
            local providerCategories = p.protectedExecute(
                template,
                'CategoryProvider',
                function() return provider(template, args) end,
                {},
                template,
                args
            )
            
            if providerCategories then
                for _, category in ipairs(providerCategories) do
                    if category and category ~= "" and not seen[category] then
                        seen[category] = true
                        categoryCount = categoryCount + 1
                        uniqueCategories[categoryCount] = category
                    end
                end
            end
        end
    end
    
    -- Generate HTML for unique categories
    local categoryHtml = {}
    for i = 1, categoryCount do
        categoryHtml[i] = CATEGORY_PREFIX .. uniqueCategories[i] .. CATEGORY_SUFFIX
    end
    
    return table.concat(categoryHtml, NEWLINE)
end

-- ========== Template Rendering ==========
-- Main rendering function for templates
-- @param template table The template object
-- @param frame Frame The MediaWiki frame object
-- @return string The rendered template HTML
function p.renderTemplate(template, frame)
    if not template._errorContext then
        template._errorContext = p.createErrorContext(template)
    end
    
    if not template.config.meta then
        p.initializeConfig(template)
    end
    
    local args = frame:getParent().args or {}
    args = TemplateHelpers.normalizeArgumentCase(args)
    
    args = p.runPreprocessors(template, args)
    
    local tableClass = DEFAULT_TABLE_CLASS
    if template.config.constants and template.config.constants.tableClass then
        tableClass = template.config.constants.tableClass
    end
    
    local structureConfig = {
        tableClass = tableClass,
        blocks = {}
    }
    
    local renderingSequence = p.buildRenderingSequence(template)
    
    if renderingSequence._length == 0 then
        return EMPTY_STRING
    end
    
    for i = 1, renderingSequence._length do
        table.insert(structureConfig.blocks, function(a)
            return renderingSequence[i](a)
        end)
    end
    
    return TemplateStructure.render(args, structureConfig, template._errorContext)
end

-- Return the module
-- Initialize elements after module load
p.initializeElements()

return p