Module:T-Campaign
Documentation for this module may be created at Module:T-Campaign/doc
-- T-Campaign.lua
-- Generic campaign template that dynamically loads campaign data from JSON files
-- Usage: {{#invoke:T-Campaign|render|campaign_name=ASP2025}}
local p = {}
-- Required modules
local Blueprint = require('Module:LuaTemplateBlueprint')
local ErrorHandling = require('Module:ErrorHandling')
local DatasetLoader = require('Module:DatasetLoader')
local WikitextProcessor = require('Module:WikitextProcessor')
local TemplateHelpers = require('Module:TemplateHelpers')
local TemplateStructure = require('Module:TemplateStructure')
-- Register the Campaign template with Blueprint
local template = Blueprint.registerTemplate('Campaign', {
features = {
fullPage = true, -- Full-page layout
categories = true,
errorReporting = true
}
})
-- Dynamic configuration (from JSON)
template.config = {
fields = {},
categories = { base = {} },
constants = {
title = "Campaign Information",
tableClass = ""
}
}
Blueprint.initializeConfig(template)
template.config.blockSequence = {
'campaignBanner',
'campaignTitle',
'campaignInstructions',
'campaignContent',
'categories',
'errors'
}
template.config.blocks = template.config.blocks or {}
-- Helper function: Check for "not applicable" values
local function isNotApplicable(value)
if not value or type(value) ~= "string" then
return false
end
local lowerVal = value:lower():match("^%s*(.-)%s*$")
return lowerVal == "n/a" or lowerVal == "na" or lowerVal == "none" or lowerVal == "no" or lowerVal == "false"
end
-- Helper function: Tokenize semicolon-separated strings for instructions
local function tokenizeForInstructions(value)
if not value or value == "" then
return value
end
local items = {}
for item in value:gmatch("[^;]+") do
local trimmed = item:match("^%s*(.-)%s*$") -- trim whitespace
if trimmed and trimmed ~= "" then
table.insert(items, '<span class="campaign-instruction-token">' .. trimmed .. '</span>')
end
end
if #items > 0 then
return table.concat(items, ' ')
else
return value
end
end
-- Helper function: Generate usage instructions
local function generateUsageInstructions(campaignName, campaignData)
local output = {}
-- Generate template syntax with all available parameters in user-friendly format
local templateCall = "{{#invoke:T-Campaign|render|\ncampaign_name=" .. tostring(campaignName) .. "|\n"
-- Add each field as a parameter option with example values (except campaign_intro which is fixed)
if campaignData.field_definitions then
for _, fieldDef in ipairs(campaignData.field_definitions) do
if fieldDef.key ~= "campaign_intro" then
local exampleValue = "text"
if fieldDef.type == "list" then
exampleValue = "text 1; text 2; etc. (semicolon-separated)"
end
templateCall = templateCall .. tostring(fieldDef.key) .. " = " .. exampleValue .. "|\n"
end
end
end
templateCall = templateCall .. "}}"
-- Use JSON config or fallback to default instruction text
local headerText = (campaignData.instructions and campaignData.instructions.header_text)
or "'''READ CAREFULLY'''. These are temporary instructions that will appear only until all parameters outlined here have been filled with data or have 'N/A' in them. Choose 'Edit source' at any time and edit the code below if it already exists. It can also be added manually to any page:"
local parameterIntro = (campaignData.instructions and campaignData.instructions.parameter_intro)
or "'''Available Parameters:'''"
-- Build instruction content with configurable text
table.insert(output, headerText)
table.insert(output, "")
table.insert(output, "<pre>" .. templateCall .. "</pre>")
table.insert(output, "")
table.insert(output, parameterIntro)
if campaignData.field_definitions then
for _, fieldDef in ipairs(campaignData.field_definitions) do
-- Skip campaign_intro since it's a fixed field from JSON defaults
if fieldDef.key ~= "campaign_intro" then
local paramDesc = "* '''<span style=\"color: var(--colored-text);\">" .. tostring(fieldDef.key) .. "</span>''' (" .. tostring(fieldDef.label) .. ", " .. tostring(fieldDef.type) .. "): "
-- Add default value as example if available, with tokenization for lists
local defaultValue = campaignData.defaults[fieldDef.key]
if defaultValue and defaultValue ~= "" then
if fieldDef.type == "list" then
paramDesc = paramDesc .. tokenizeForInstructions(tostring(defaultValue))
else
paramDesc = paramDesc .. tostring(defaultValue)
end
end
-- Add helpful note for list fields
if fieldDef.type == "list" then
paramDesc = paramDesc .. " (separate multiple values with semicolons)"
end
table.insert(output, paramDesc)
end
end
end
return table.concat(output, "\n")
end
-- Helper function: Get campaign name with simplified fallback logic
local function getCampaignName(args, campaignData)
-- Primary: standard parameter name
local campaignName = args.campaign_name
-- Fallback: campaign data template_id or default
if not campaignName then
campaignName = (campaignData and campaignData.template_id) or "CAMPAIGN_NAME"
end
return campaignName
end
-- Helper function: Get recursion depth from frame arguments
local function getRecursionDepth(frame)
local frameArgs = frame.args or {}
local parentArgs = (frame:getParent() and frame:getParent().args) or {}
return tonumber(frameArgs._recursion_depth or parentArgs._recursion_depth) or 0
end
-- Campaign Banner
template.config.blocks.campaignBanner = {
feature = 'fullPage',
render = function(template, args)
local context = template._errorContext
if not args._campaign_data or not args._campaign_data.banner then
ErrorHandling.addStatus(context, 'campaignBanner', 'No banner data found', 'Campaign data or banner config missing')
return ErrorHandling.formatCombinedOutput(context)
end
local banner = args._campaign_data.banner
local bannerContent = banner.content or ""
-- Combine generic notice-box class with specific campaign class
local cssClass = "notice-box"
if banner.css_class and banner.css_class ~= "" then
cssClass = cssClass .. " " .. banner.css_class
end
if bannerContent == "" then
ErrorHandling.addStatus(context, 'campaignBanner', 'Empty banner content', 'No content to display')
return ErrorHandling.formatCombinedOutput(context)
end
local placeholderValues = {
CAMPAIGN_NAME = args._campaign_data.defaults.title or "Campaign"
}
bannerContent = WikitextProcessor.processContentForFrontend(bannerContent, placeholderValues, context)
local noticeData = {
type = "campaign",
position = "top",
content = bannerContent,
cssClass = cssClass
}
local success, result = pcall(function()
return string.format(
'<div style="display:none" class="notice-data" data-notice-type="%s" data-notice-position="%s" data-notice-content="%s" data-notice-css="%s"></div>',
mw.text.encode(noticeData.type),
mw.text.encode(noticeData.position),
mw.text.encode(noticeData.content),
mw.text.encode(noticeData.cssClass)
)
end)
if success then
return result .. ErrorHandling.formatCombinedOutput(context)
else
ErrorHandling.addError(context, 'campaignBanner', 'Data attribute creation failed', tostring(result), false)
return ErrorHandling.formatCombinedOutput(context)
end
end
}
-- Campaign Title
template.config.blocks.campaignTitle = {
feature = 'fullPage',
render = function(template, args)
local titleText = template.config.constants.title or "Campaign Information"
return "== " .. titleText .. " =="
end
}
-- Campaign Instructions
template.config.blocks.campaignInstructions = {
feature = 'fullPage',
render = function(template, args)
if not args._show_instructions or not args._campaign_data then
return ""
end
local campaignName = getCampaignName(args, args._campaign_data)
local instructions = generateUsageInstructions(campaignName, args._campaign_data)
return '<div class="campaign-instructions">\n== ⚠️ Editing Instructions ==\n\n' .. instructions .. '\n</div>'
end
}
-- Campaign Content
template.config.blocks.campaignContent = {
feature = 'fullPage',
render = function(template, args)
local output = {}
for _, field in ipairs(template.config.fields or {}) do
if field.key ~= "usageInstructions" then
local rawValue = args[field.key]
local processor = template._processors[field.key] or template._processors.default
if processor then
local value = processor(rawValue, args, template, field.type)
if value and value ~= "" then
if field.key == "campaign_intro" then
table.insert(output, value)
table.insert(output, "")
else
table.insert(output, "=== " .. field.label .. " ===")
table.insert(output, value)
table.insert(output, "")
end
end
end
end
end
return table.concat(output, "\n")
end
}
-- Generic field processor that handles different data types
local function processFieldValue(value, fieldType)
if isNotApplicable(value) then
return nil
end
if type(value) == "table" then
if #value > 0 then
-- Array of values - render as bullet list
return "* " .. table.concat(value, "\n* ")
else
-- Object with key-value pairs - render as sections
local output = {}
for category, content in pairs(value) do
local categoryTitle = "'''" .. category:gsub("^%l", string.upper):gsub("_", " ") .. "'''"
table.insert(output, categoryTitle)
if type(content) == "table" and #content > 0 then
table.insert(output, "* " .. table.concat(content, "\n* "))
else
table.insert(output, tostring(content))
end
end
return table.concat(output, "\n")
end
elseif fieldType == "list" and type(value) == "string" then
-- Handle semicolon-separated lists with token styling
local items = {}
for item in value:gmatch("[^;]+") do
local trimmed = item:match("^%s*(.-)%s*$") -- trim whitespace
if trimmed and trimmed ~= "" and not isNotApplicable(trimmed) then
table.insert(items, '<span class="campaign-token">' .. trimmed .. '</span>')
end
end
if #items > 0 then
return table.concat(items, ' ')
else
return nil -- Return nil if all items were "not applicable"
end
else
return tostring(value)
end
end
-- Custom preprocessor to load campaign data and generate fields dynamically
Blueprint.addPreprocessor(template, function(template, args)
-- CRITICAL: Get campaign_name from args (merged by Blueprint) or current frame for direct module calls
local campaignName = args.campaign_name
-- Fallback for direct module invocations where args may not be merged yet
if (not campaignName or campaignName == "") and template.current_frame then
local frameArgs = template.current_frame.args or {}
campaignName = frameArgs.campaign_name
end
if not campaignName or campaignName == "" then
return args
end
-- Load campaign data from JSON
local campaignData = DatasetLoader.get('Campaigns/' .. campaignName)
if not campaignData or not campaignData.defaults or not campaignData.field_definitions then
return args
end
-- Simple console message
ErrorHandling.addStatus(
template._errorContext,
'campaignLoader',
'Campaign loaded successfully for ' .. campaignName,
nil
)
-- CRITICAL: Detect custom parameters BEFORE merging defaults to determine template mode
local customParameters = {}
local hasCustomParameters = false
for k, v in pairs(args) do
-- Skip campaign_name, internal parameters, and empty values
if k ~= "campaign_name" and
not k:match("^_") and -- Skip internal parameters like _recursion_depth
v and v ~= "" then
customParameters[k] = true
hasCustomParameters = true
end
end
-- Check if ALL fields are provided as custom parameters (excluding campaign_intro which is fixed)
local hasAllParameters = true
for _, fieldDef in ipairs(campaignData.field_definitions) do
if fieldDef.key ~= "campaign_intro" and not customParameters[fieldDef.key] then
hasAllParameters = false
break
end
end
-- Determine template mode based on parameter completeness
local templateMode
if not hasCustomParameters then
templateMode = "documentation"
elseif hasAllParameters then
templateMode = "complete"
else
templateMode = "partial"
end
-- Store mode state for rendering
args._template_mode = templateMode
args._show_instructions = (templateMode ~= "complete")
args._campaign_data = campaignData
-- Defaults are only used for parameter documentation, not content rendering
-- Generate field definitions based on mode
local fields = {}
-- Always show campaign content fields (they'll show placeholder text when empty)
for _, fieldDef in ipairs(campaignData.field_definitions) do
table.insert(fields, {
key = fieldDef.key,
label = fieldDef.label,
type = fieldDef.type
})
end
-- Add usage instructions in documentation and partial modes
if templateMode ~= "complete" then
table.insert(fields, {
key = "usageInstructions",
label = "Usage Instructions",
type = "text"
})
args.usageInstructions = "documentation"
end
template.config.fields = fields
-- Override title if provided in JSON defaults
if campaignData.defaults.title then
template.config.constants.title = campaignData.defaults.title
end
-- Add campaign-specific category
template.config.categories.base = {campaignName}
return args
end)
-- Initialize field processors for the template
-- Set up a universal processor that can handle any field type
if not template._processors then
template._processors = {}
end
-- Set up a universal field processor that handles all field types
template._processors.default = function(value, args, template, fieldType)
if value and value ~= "" then
return processFieldValue(value, fieldType or "text")
else
-- Show placeholder for empty fields in documentation and partial modes
if args._template_mode ~= "complete" then
return "''Please see usage instructions above to customize this field.''"
end
return nil -- Don't display empty fields in complete mode
end
end
-- SPECIAL: campaign_intro processor - always uses JSON default, never user input
template._processors.campaign_intro = function(value, args, template, fieldType)
-- CRITICAL: Always use campaign data default, ignore user input to maintain consistency
if args._campaign_data and args._campaign_data.defaults and args._campaign_data.defaults.campaign_intro then
local defaultIntro = args._campaign_data.defaults.campaign_intro
return "''" .. tostring(defaultIntro) .. "''"
else
-- Fallback if no campaign data available
if args._template_mode ~= "complete" then
return "''Campaign introduction will appear here from JSON defaults.''"
end
return nil
end
end
-- Special processor for usage instructions
template._processors.usageInstructions = function(value, args, template)
if not args._show_instructions or not args._campaign_data then
return nil -- Only render when instructions should be shown
end
local campaignName = getCampaignName(args, args._campaign_data)
local instructions = generateUsageInstructions(campaignName, args._campaign_data)
return "\n----\n'''Usage Instructions'''\n\n" .. instructions
end
function p.render(frame)
template.current_frame = frame
local depth = getRecursionDepth(frame)
if depth > 3 then
return '<span class="error">Template recursion depth exceeded (limit: 3)</span>'
end
if not template._errorContext then
template._errorContext = require('Module:ErrorHandling').createContext(template.type)
end
if not template.config.meta then
require('Module:LuaTemplateBlueprint').initializeConfig(template)
end
-- Merge arguments: frame args override parent args
local args = {}
local parentArgs = frame:getParent().args or {}
local frameArgs = frame.args or {}
for k, v in pairs(parentArgs) do args[k] = v end
for k, v in pairs(frameArgs) do args[k] = v end
args = TemplateHelpers.normalizeArgumentCase(args)
args._recursion_depth = tostring(depth + 1)
args = Blueprint.runPreprocessors(template, args)
local structureConfig = {
tableClass = (template.config.constants and template.config.constants.tableClass) or "template-table",
blocks = {},
containerTag = template.features.fullPage and "div" or "table"
}
local renderingSequence = Blueprint.buildRenderingSequence(template)
if renderingSequence._length == 0 then
return ""
end
for i = 1, renderingSequence._length do
table.insert(structureConfig.blocks, function(a)
return renderingSequence[i](a)
end)
end
local result = TemplateStructure.render(args, structureConfig, template._errorContext)
template.current_frame = nil
return result
end
return p