Module:T-Campaign: Difference between revisions
Appearance
// via Wikitext Extension for VSCode Tags: Manual revert Reverted |
// via Wikitext Extension for VSCode Tags: Manual revert Reverted |
||
| Line 1: | Line 1: | ||
-- | -- T-Campaign.lua | ||
-- Generic campaign template that dynamically loads campaign data from JSON files | |||
-- Usage: {{#invoke:T-Campaign|render|campaign_name=ASP2025}} | |||
local p = {} | local p = {} | ||
| Line 12: | Line 9: | ||
local ErrorHandling = require('Module:ErrorHandling') | local ErrorHandling = require('Module:ErrorHandling') | ||
local DatasetLoader = require('Module:DatasetLoader') | 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 | -- Register the Campaign template with Blueprint | ||
| Line 18: | Line 18: | ||
fullPage = true, -- Full-page layout | fullPage = true, -- Full-page layout | ||
categories = true, | categories = true, | ||
errorReporting = | errorReporting = true | ||
} | } | ||
}) | }) | ||
| Line 24: | Line 24: | ||
-- Dynamic configuration (from JSON) | -- Dynamic configuration (from JSON) | ||
template.config = { | template.config = { | ||
fields = {}, | fields = {}, | ||
categories = { | categories = { base = {} }, | ||
constants = { | constants = { | ||
title = "Campaign Information", | title = "Campaign Information", | ||
tableClass = "" | tableClass = "" | ||
} | } | ||
} | } | ||
Blueprint.initializeConfig(template) | Blueprint.initializeConfig(template) | ||
template.config.blockSequence = { | template.config.blockSequence = { | ||
'campaignBanner', | 'campaignBanner', | ||
'campaignTitle', | 'campaignTitle', | ||
'campaignInstructions', | 'campaignInstructions', | ||
'campaignContent', | 'campaignContent', | ||
| Line 47: | Line 43: | ||
} | } | ||
template.config.blocks = template.config.blocks or {} | 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 | -- Helper function: Tokenize semicolon-separated strings for instructions | ||
| Line 92: | Line 96: | ||
templateCall = templateCall .. "}}" | 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 if the field is not applicable. 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, "") | ||
table.insert(output, "<pre>" .. templateCall .. "</pre>") | table.insert(output, "<pre>" .. templateCall .. "</pre>") | ||
table.insert(output, "") | table.insert(output, "") | ||
table.insert(output, | table.insert(output, parameterIntro) | ||
if campaignData.field_definitions then | if campaignData.field_definitions then | ||
| Line 127: | Line 138: | ||
end | end | ||
-- Helper function: Get campaign name with fallback logic | -- Helper function: Get campaign name with simplified fallback logic | ||
local function getCampaignName(args, campaignData) | local function getCampaignName(args, campaignData) | ||
local campaignName = args.campaign_name | -- Primary: standard parameter name | ||
local campaignName = args.campaign_name | |||
-- | -- Fallback: campaign data template_id or default | ||
if not campaignName then | if not campaignName then | ||
campaignName = "CAMPAIGN_NAME" | campaignName = (campaignData and campaignData.template_id) or "CAMPAIGN_NAME" | ||
end | end | ||
| Line 144: | Line 151: | ||
end | 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 = { | template.config.blocks.campaignBanner = { | ||
feature = 'fullPage', | feature = 'fullPage', | ||
render = function(template, args) | render = function(template, args) | ||
local context = template._errorContext | local context = template._errorContext | ||
if not args._campaign_data or not args._campaign_data.banner then | 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') | ErrorHandling.addStatus(context, 'campaignBanner', 'No banner data found', 'Campaign data or banner config missing') | ||
return ErrorHandling.formatCombinedOutput(context) | return ErrorHandling.formatCombinedOutput(context) | ||
end | end | ||
local banner = args._campaign_data.banner | local banner = args._campaign_data.banner | ||
local bannerContent = banner.content or "" | local bannerContent = banner.content or "" | ||
local cssClass = banner.css_class | -- 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 | if bannerContent == "" then | ||
| Line 164: | Line 183: | ||
end | end | ||
-- | -- DEFENSIVE: Validate campaign title before processing | ||
local campaignTitle = nil | |||
local titleSource = "fallback" | |||
local | |||
if args._campaign_data and args._campaign_data.defaults then | |||
local rawTitle = args._campaign_data.defaults.title | |||
if rawTitle and type(rawTitle) == "string" and rawTitle ~= "" then | |||
campaignTitle = rawTitle | |||
titleSource = "json_default" | |||
ErrorHandling.addStatus(context, 'campaignBanner', 'Title validation passed', 'Source: JSON defaults, Value: "' .. campaignTitle .. '"') | |||
else | |||
ErrorHandling.addStatus(context, 'campaignBanner', 'Title validation failed', 'Raw value: "' .. tostring(rawTitle) .. '", Type: ' .. type(rawTitle)) | |||
end | end | ||
else | |||
ErrorHandling.addStatus(context, 'campaignBanner', 'Campaign data structure invalid', 'Missing _campaign_data or defaults') | |||
end | end | ||
-- | -- Robust fallback with error reporting | ||
if not campaignTitle then | |||
campaignTitle = "Campaign" | |||
titleSource = "fallback" | |||
ErrorHandling.addStatus(context, 'campaignBanner', 'Using fallback title', 'Original title was missing or invalid') | |||
end | |||
local placeholderValues = { | |||
CAMPAIGN_NAME = campaignTitle | |||
} | |||
-- | -- Pre-flight validation before calling WikitextProcessor | ||
ErrorHandling.addStatus(context, 'campaignBanner', 'Sending to WikitextProcessor', 'CAMPAIGN_NAME="' .. campaignTitle .. '", Source=' .. titleSource) | |||
bannerContent = WikitextProcessor.processContentForFrontend(bannerContent, placeholderValues, context) | |||
local noticeData = { | local noticeData = { | ||
type = "campaign", | type = "campaign", | ||
| Line 304: | Line 223: | ||
} | } | ||
local success, result = pcall(function() | local success, result = pcall(function() | ||
return string.format( | return string.format( | ||
| Line 324: | Line 242: | ||
} | } | ||
-- | -- Campaign Title | ||
template.config.blocks.campaignTitle = { | template.config.blocks.campaignTitle = { | ||
feature = 'fullPage', | feature = 'fullPage', | ||
| Line 333: | Line 251: | ||
} | } | ||
-- | -- Campaign Instructions | ||
template.config.blocks.campaignInstructions = { | template.config.blocks.campaignInstructions = { | ||
feature = 'fullPage', | feature = 'fullPage', | ||
render = function(template, args) | render = function(template, args) | ||
if not args._show_instructions or not args._campaign_data then | if not args._show_instructions or not args._campaign_data then | ||
return "" | return "" | ||
end | end | ||
| Line 348: | Line 266: | ||
} | } | ||
-- | -- Campaign Content | ||
template.config.blocks.campaignContent = { | template.config.blocks.campaignContent = { | ||
feature = 'fullPage', | feature = 'fullPage', | ||
| Line 355: | Line 273: | ||
for _, field in ipairs(template.config.fields or {}) do | for _, field in ipairs(template.config.fields or {}) do | ||
if field.key ~= "usageInstructions" then | if field.key ~= "usageInstructions" then | ||
local rawValue = args[field.key] | local rawValue = args[field.key] | ||
| Line 361: | Line 278: | ||
if processor then | if processor then | ||
local value = processor(rawValue, args, template, field.type) | local value = processor(rawValue, args, template, field.type) | ||
if value and value ~= "" then | if value and value ~= "" then | ||
if field.key == "campaign_intro" then | if field.key == "campaign_intro" then | ||
table.insert(output, value) | table.insert(output, value) | ||
| Line 384: | Line 299: | ||
-- Generic field processor that handles different data types | -- Generic field processor that handles different data types | ||
local function processFieldValue(value, fieldType) | local function processFieldValue(value, fieldType) | ||
if isNotApplicable(value) then | |||
return nil | |||
end | |||
if type(value) == "table" then | if type(value) == "table" then | ||
if #value > 0 then | if #value > 0 then | ||
| Line 407: | Line 326: | ||
for item in value:gmatch("[^;]+") do | for item in value:gmatch("[^;]+") do | ||
local trimmed = item:match("^%s*(.-)%s*$") -- trim whitespace | local trimmed = item:match("^%s*(.-)%s*$") -- trim whitespace | ||
if trimmed and trimmed ~= "" then | if trimmed and trimmed ~= "" and not isNotApplicable(trimmed) then | ||
table.insert(items, '<span class="campaign-token">' .. trimmed .. '</span>') | table.insert(items, '<span class="campaign-token">' .. trimmed .. '</span>') | ||
end | end | ||
| Line 414: | Line 333: | ||
return table.concat(items, ' ') | return table.concat(items, ' ') | ||
else | else | ||
return | return nil -- Return nil if all items were "not applicable" | ||
end | end | ||
else | else | ||
| Line 423: | Line 342: | ||
-- Custom preprocessor to load campaign data and generate fields dynamically | -- Custom preprocessor to load campaign data and generate fields dynamically | ||
Blueprint.addPreprocessor(template, function(template, args) | Blueprint.addPreprocessor(template, function(template, args) | ||
-- Get campaign_name from args ( | -- CRITICAL: Get campaign_name from args (merged by Blueprint) or current frame for direct module calls | ||
local campaignName = args.campaign_name | 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 | if (not campaignName or campaignName == "") and template.current_frame then | ||
local frameArgs = template.current_frame.args or {} | local frameArgs = template.current_frame.args or {} | ||
| Line 445: | Line 363: | ||
-- Simple console message | -- Simple console message | ||
ErrorHandling.addStatus( | ErrorHandling.addStatus( | ||
template._errorContext | template._errorContext, | ||
'campaignLoader', | 'campaignLoader', | ||
'Campaign loaded successfully for ' .. campaignName, | 'Campaign loaded successfully for ' .. campaignName, | ||
| Line 451: | Line 369: | ||
) | ) | ||
-- Detect | -- CRITICAL: Detect custom parameters BEFORE merging defaults to determine template mode | ||
local customParameters = {} | local customParameters = {} | ||
local hasCustomParameters = false | local hasCustomParameters = false | ||
| Line 473: | Line 391: | ||
end | end | ||
-- Determine mode | -- 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 | -- Store mode state for rendering | ||
args. | args._template_mode = templateMode | ||
args. | args._show_instructions = (templateMode ~= "complete") | ||
args._campaign_data = campaignData | |||
args._campaign_data = campaignData | |||
-- Defaults are only used for parameter documentation, not content rendering | -- Defaults are only used for parameter documentation, not content rendering | ||
| Line 504: | Line 421: | ||
-- Add usage instructions in documentation and partial modes | -- Add usage instructions in documentation and partial modes | ||
if | if templateMode ~= "complete" then | ||
table.insert(fields, { | table.insert(fields, { | ||
key = "usageInstructions", | key = "usageInstructions", | ||
| Line 510: | Line 427: | ||
type = "text" | type = "text" | ||
}) | }) | ||
args.usageInstructions = "documentation" | args.usageInstructions = "documentation" | ||
end | end | ||
| Line 539: | Line 455: | ||
else | else | ||
-- Show placeholder for empty fields in documentation and partial modes | -- Show placeholder for empty fields in documentation and partial modes | ||
if args. | if args._template_mode ~= "complete" then | ||
return "''Please see usage instructions above to customize this field.''" | return "''Please see usage instructions above to customize this field.''" | ||
end | end | ||
| Line 546: | Line 462: | ||
end | end | ||
-- | -- SPECIAL: campaign_intro processor - always uses JSON default, never user input | ||
template._processors.campaign_intro = function(value, args, template, fieldType) | template._processors.campaign_intro = function(value, args, template, fieldType) | ||
-- Always use | -- 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 | 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 | local defaultIntro = args._campaign_data.defaults.campaign_intro | ||
| Line 554: | Line 470: | ||
else | else | ||
-- Fallback if no campaign data available | -- Fallback if no campaign data available | ||
if args. | if args._template_mode ~= "complete" then | ||
return "''Campaign introduction will appear here from JSON defaults.''" | return "''Campaign introduction will appear here from JSON defaults.''" | ||
end | end | ||
| Line 573: | Line 489: | ||
end | end | ||
function p.render(frame) | function p.render(frame) | ||
template.current_frame = frame | |||
local depth = getRecursionDepth(frame) | |||
local depth = | |||
if depth > 3 then | if depth > 3 then | ||
| Line 600: | Line 506: | ||
end | end | ||
-- | -- Merge arguments: frame args override parent args | ||
local args = {} | local args = {} | ||
local parentArgs = frame:getParent().args or {} | 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 | |||
for k, v in pairs(frameArgs) do | |||
args = TemplateHelpers.normalizeArgumentCase(args) | args = TemplateHelpers.normalizeArgumentCase(args) | ||
args._recursion_depth = tostring(depth + 1) | args._recursion_depth = tostring(depth + 1) | ||
args = Blueprint.runPreprocessors(template, args) | args = Blueprint.runPreprocessors(template, args) | ||
local structureConfig = { | local structureConfig = { | ||
tableClass = tableClass, | tableClass = (template.config.constants and template.config.constants.tableClass) or "template-table", | ||
blocks = {}, | blocks = {}, | ||
containerTag = template.features.fullPage and "div" or "table" | containerTag = template.features.fullPage and "div" or "table" | ||
} | } | ||
local renderingSequence = Blueprint.buildRenderingSequence(template) | local renderingSequence = Blueprint.buildRenderingSequence(template) | ||
| Line 646: | Line 532: | ||
end | end | ||
for i = 1, renderingSequence._length do | for i = 1, renderingSequence._length do | ||
table.insert(structureConfig.blocks, function(a) | table.insert(structureConfig.blocks, function(a) | ||
| Line 653: | Line 538: | ||
end | end | ||
local result = TemplateStructure.render(args, structureConfig, template._errorContext) | local result = TemplateStructure.render(args, structureConfig, template._errorContext) | ||
template.current_frame = nil | template.current_frame = nil | ||
return result | return result | ||
Revision as of 02:13, 2 August 2025
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 if the field is not applicable. 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
-- DEFENSIVE: Validate campaign title before processing
local campaignTitle = nil
local titleSource = "fallback"
if args._campaign_data and args._campaign_data.defaults then
local rawTitle = args._campaign_data.defaults.title
if rawTitle and type(rawTitle) == "string" and rawTitle ~= "" then
campaignTitle = rawTitle
titleSource = "json_default"
ErrorHandling.addStatus(context, 'campaignBanner', 'Title validation passed', 'Source: JSON defaults, Value: "' .. campaignTitle .. '"')
else
ErrorHandling.addStatus(context, 'campaignBanner', 'Title validation failed', 'Raw value: "' .. tostring(rawTitle) .. '", Type: ' .. type(rawTitle))
end
else
ErrorHandling.addStatus(context, 'campaignBanner', 'Campaign data structure invalid', 'Missing _campaign_data or defaults')
end
-- Robust fallback with error reporting
if not campaignTitle then
campaignTitle = "Campaign"
titleSource = "fallback"
ErrorHandling.addStatus(context, 'campaignBanner', 'Using fallback title', 'Original title was missing or invalid')
end
local placeholderValues = {
CAMPAIGN_NAME = campaignTitle
}
-- Pre-flight validation before calling WikitextProcessor
ErrorHandling.addStatus(context, 'campaignBanner', 'Sending to WikitextProcessor', 'CAMPAIGN_NAME="' .. campaignTitle .. '", Source=' .. titleSource)
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