Module:T-Campaign: Difference between revisions
Appearance
// via Wikitext Extension for VSCode Tag: Reverted |
// via Wikitext Extension for VSCode Tag: Reverted |
||
| Line 1: | Line 1: | ||
-- T-Campaign.lua | --[[ | ||
* T-Campaign.lua | |||
* Generic campaign template that dynamically loads campaign data from JSON files. This template provides a flexible, data-driven approach to campaign templates without cluttering ConfigRepository. It dynamically generates field definitions based on the JSON structure and renders any campaign content. | |||
* | |||
* Usage: {{#invoke:T-Campaign|render|campaign_name=ASP2025}} | |||
]] | |||
local p = {} | local p = {} | ||
| Line 9: | Line 12: | ||
local ErrorHandling = require('Module:ErrorHandling') | local ErrorHandling = require('Module:ErrorHandling') | ||
local DatasetLoader = require('Module:DatasetLoader') | local DatasetLoader = require('Module:DatasetLoader') | ||
-- 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 = false -- REVIEW | ||
} | } | ||
}) | }) | ||
| Line 24: | Line 24: | ||
-- Dynamic configuration (from JSON) | -- Dynamic configuration (from JSON) | ||
template.config = { | template.config = { | ||
fields = {}, | fields = {}, -- Dynamically generated | ||
categories = { base = {} }, | categories = { | ||
base = {} -- Populated dynamically based on campaign | |||
}, | |||
constants = { | constants = { | ||
title = "Campaign Information", | title = "Campaign Information", -- Default title, can be overridden by JSON | ||
tableClass = "" | tableClass = "" -- Empty to prevent template box styling | ||
} | } | ||
} | } | ||
-- Blueprint default: Initialize standard configuration | |||
Blueprint.initializeConfig(template) | Blueprint.initializeConfig(template) | ||
-- Custom block sequence (bypass standard Blueprint blocks) | |||
template.config.blockSequence = { | template.config.blockSequence = { | ||
'campaignBanner', | 'campaignBanner', | ||
'campaignTitle', | 'campaignTitle', | ||
'campaignInstructions', | 'campaignInstructions', | ||
'campaignContent', | 'campaignContent', | ||
| Line 43: | Line 47: | ||
} | } | ||
-- Initialize custom blocks | |||
template.config.blocks = template.config.blocks or {} | template.config.blocks = template.config.blocks or {} | ||
-- Helper function: Tokenize semicolon-separated strings for instructions | -- Helper function: Tokenize semicolon-separated strings for instructions | ||
| Line 96: | Line 92: | ||
templateCall = templateCall .. "}}" | templateCall = templateCall .. "}}" | ||
-- | -- Build instruction content | ||
table.insert(output, "'''READ CAREFULLY'''. These are temporary instructions that will appear only until all parameters outlined here have been filled. Choose 'Edit source' at any time and edit the code below if it already exists. It can also be added manually to any page:") | |||
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, "'''Available Parameters:'''") | ||
if campaignData.field_definitions then | if campaignData.field_definitions then | ||
| Line 138: | Line 127: | ||
end | end | ||
-- Helper function: Get campaign name with | -- Helper function: Get campaign name with fallback logic (consolidates repeated logic) | ||
local function getCampaignName(args, campaignData) | local function getCampaignName(args, campaignData) | ||
-- | local campaignName = args.campaign_name or args.campaignname or args.Campaign_name or args.CampaignName | ||
-- If still nil, try to get it from the campaign data | |||
if not campaignName and campaignData and campaignData.template_id then | |||
campaignName = campaignData.template_id | |||
end | |||
-- | -- Final fallback | ||
if not campaignName then | if not campaignName then | ||
campaignName = | campaignName = "CAMPAIGN_NAME" | ||
end | end | ||
| Line 151: | Line 144: | ||
end | end | ||
-- | -- Block 1: Campaign Banner (sets noticeHandler config for top-of-page injection) | ||
-- | |||
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 or ErrorHandling.createContext('T-Campaign') | ||
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) -- Return debug output | ||
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 or "campaign-banner" | |||
local cssClass = | |||
if bannerContent == "" then | if bannerContent == "" then | ||
| Line 183: | Line 164: | ||
end | end | ||
-- Apply string normalization to ALL content first (critical for wiki link processing) | |||
bannerContent = bannerContent:gsub("%s+", " "):gsub("^%s+", ""):gsub("%s+$", "") | |||
-- Use TemplateStarter's proven placeholder replacement strategy | |||
local placeholderValues = { | local placeholderValues = { | ||
CAMPAIGN_NAME = | CAMPAIGN_NAME = args._campaign_data.defaults.title or "Campaign" | ||
} | } | ||
-- Apply TemplateStarter's replacePlaceholders function logic | |||
local function replacePlaceholders(text, values) | |||
if not text or not values then | |||
return text | |||
end | |||
local result = text | |||
for key, value in pairs(values) do | |||
if value and value ~= "" then | |||
result = result:gsub("%$" .. key .. "%$", value) | |||
end | |||
end | |||
return result | |||
end | |||
-- Apply placeholder replacement | |||
bannerContent = replacePlaceholders(bannerContent, placeholderValues) | |||
-- Clean up any remaining unfilled placeholders (TemplateStarter's removeEmptyPlaceholders logic) | |||
bannerContent = bannerContent:gsub("%$[A-Z_]+%$", "") | |||
bannerContent = bannerContent:gsub("%s+", " "):gsub("^%s+", ""):gsub("%s+$", "") | |||
-- UNIFIED PIPELINE: Process ALL wiki links with custom patterns (no frame:preprocess) | |||
local originalContent = bannerContent | |||
-- Pattern 1: [[Page Name]] -> HTML link (simple page links) | |||
bannerContent = bannerContent:gsub('%[%[([^#|%]]+)%]%]', function(pageName) | |||
local spacesReplaced = pageName:gsub(' ', '_') | |||
local success1, pageUrl = pcall(function() | |||
return tostring(mw.uri.fullUrl(spacesReplaced)) | |||
end) | |||
if not success1 then | |||
ErrorHandling.addStatus(context, 'campaignBanner', 'mw.uri.fullUrl failed for page link', 'Page: ' .. pageName .. ', Error: ' .. tostring(pageUrl)) | |||
return '[[' .. pageName .. ']]' | |||
end | |||
local success2, encodedName = pcall(function() | |||
return mw.text.encode(pageName) | |||
end) | |||
if not success2 then | |||
ErrorHandling.addStatus(context, 'campaignBanner', 'mw.text.encode failed for page link', 'Page: ' .. pageName .. ', Error: ' .. tostring(encodedName)) | |||
return '[[' .. pageName .. ']]' | |||
end | |||
local success3, result = pcall(function() | |||
return string.format('<a href="%s">%s</a>', pageUrl, encodedName) | |||
end) | |||
if not success3 then | |||
ErrorHandling.addStatus(context, 'campaignBanner', 'string.format failed for page link', 'Page: ' .. pageName .. ', Error: ' .. tostring(result)) | |||
return '[[' .. pageName .. ']]' | |||
end | |||
return result | |||
end) | |||
-- Pattern 2: [[Page|text]] -> HTML link (page links with custom text) | |||
bannerContent = bannerContent:gsub('%[%[([^#|%]]+)|([^%]]+)%]%]', function(pageName, text) | |||
local success1, pageUrl = pcall(function() | |||
return tostring(mw.uri.fullUrl((pageName:gsub(' ', '_')))) | |||
end) | |||
if not success1 then | |||
ErrorHandling.addStatus(context, 'campaignBanner', 'Pattern 2 mw.uri.fullUrl failed', 'Error: ' .. tostring(pageUrl)) | |||
return '[[' .. pageName .. '|' .. text .. ']]' | |||
end | |||
local success2, encodedText = pcall(function() | |||
local encoded = mw.text.encode(text) | |||
return encoded | |||
end) | |||
if not success2 then | |||
ErrorHandling.addStatus(context, 'campaignBanner', 'Pattern 2 mw.text.encode failed', 'Error: ' .. tostring(encodedText)) | |||
return '[[' .. pageName .. '|' .. text .. ']]' | |||
end | |||
local success3, result = pcall(function() | |||
return string.format('<a href="%s">%s</a>', pageUrl, encodedText) | |||
end) | |||
if not success3 then | |||
ErrorHandling.addStatus(context, 'campaignBanner', 'Pattern 2 string.format failed', 'Error: ' .. tostring(result)) | |||
return '[[' .. pageName .. '|' .. text .. ']]' | |||
end | |||
return result | |||
end) | |||
-- Pattern 3: [[#anchor|text]] -> HTML anchor link | |||
bannerContent = bannerContent:gsub('%[%[#([^|%]]+)|([^%]]+)%]%]', function(anchor, text) | |||
local success1, encodedAnchor = pcall(function() | |||
return mw.uri.anchorEncode(anchor) | |||
end) | |||
if not success1 then | |||
ErrorHandling.addStatus(context, 'campaignBanner', 'Pattern 3 mw.uri.anchorEncode failed', 'Error: ' .. tostring(encodedAnchor)) | |||
return '[[#' .. anchor .. '|' .. text .. ']]' | |||
end | |||
local success2, encodedText = pcall(function() | |||
local encoded = mw.text.encode(text) | |||
return encoded | |||
end) | |||
if not success2 then | |||
ErrorHandling.addStatus(context, 'campaignBanner', 'Pattern 3 mw.text.encode failed', 'Error: ' .. tostring(encodedText)) | |||
return '[[#' .. anchor .. '|' .. text .. ']]' | |||
end | |||
local success3, result = pcall(function() | |||
return string.format('<a href="#%s">%s</a>', encodedAnchor, encodedText) | |||
end) | |||
if not success3 then | |||
ErrorHandling.addStatus(context, 'campaignBanner', 'Pattern 3 string.format failed', 'Error: ' .. tostring(result)) | |||
return '[[#' .. anchor .. '|' .. text .. ']]' | |||
end | |||
return result | |||
end) | |||
-- Set notice data for JavaScript injection | |||
local noticeData = { | local noticeData = { | ||
type = "campaign", | type = "campaign", | ||
| Line 196: | Line 304: | ||
} | } | ||
-- Output data attributes for NoticeHandler to read (instead of mw.config) | |||
local success, result = pcall(function() | local success, result = pcall(function() | ||
return string.format( | return string.format( | ||
| Line 215: | Line 324: | ||
} | } | ||
-- Campaign Title | -- Block 2: Campaign Title | ||
template.config.blocks.campaignTitle = { | template.config.blocks.campaignTitle = { | ||
feature = 'fullPage', | feature = 'fullPage', | ||
| Line 224: | Line 333: | ||
} | } | ||
-- Campaign Instructions | -- Block 2: Campaign Instructions (only in documentation/partial modes) | ||
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 "" -- Only render when instructions should be shown | ||
end | end | ||
| Line 239: | Line 348: | ||
} | } | ||
-- Campaign Content | -- Block 3: Campaign Content (all campaign fields) | ||
template.config.blocks.campaignContent = { | template.config.blocks.campaignContent = { | ||
feature = 'fullPage', | feature = 'fullPage', | ||
| Line 246: | Line 355: | ||
for _, field in ipairs(template.config.fields or {}) do | for _, field in ipairs(template.config.fields or {}) do | ||
-- Skip usage instructions field (handled by campaignInstructions block) | |||
if field.key ~= "usageInstructions" then | if field.key ~= "usageInstructions" then | ||
local rawValue = args[field.key] | local rawValue = args[field.key] | ||
| Line 251: | Line 361: | ||
if processor then | if processor then | ||
-- Pass field type as additional parameter to fix broken detection | |||
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 | ||
-- Special handling for campaign_intro - no section header | |||
if field.key == "campaign_intro" then | if field.key == "campaign_intro" then | ||
table.insert(output, value) | table.insert(output, value) | ||
| Line 272: | Line 384: | ||
-- 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 type(value) == "table" then | if type(value) == "table" then | ||
if #value > 0 then | if #value > 0 then | ||
| Line 299: | Line 407: | ||
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 ~= "" | if trimmed and trimmed ~= "" then | ||
table.insert(items, '<span class="campaign-token">' .. trimmed .. '</span>') | table.insert(items, '<span class="campaign-token">' .. trimmed .. '</span>') | ||
end | end | ||
| Line 306: | Line 414: | ||
return table.concat(items, ' ') | return table.concat(items, ' ') | ||
else | else | ||
return | return tostring(value) | ||
end | end | ||
else | else | ||
| Line 315: | Line 423: | ||
-- 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 (which should already be merged by Blueprint) | ||
-- But also check the current frame for direct module invocations | |||
local campaignName = args.campaign_name | local campaignName = args.campaign_name | ||
-- | -- If not found in args, try to get it from the current frame | ||
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 336: | Line 445: | ||
-- Simple console message | -- Simple console message | ||
ErrorHandling.addStatus( | ErrorHandling.addStatus( | ||
template._errorContext, | template._errorContext or ErrorHandling.createContext('T-Campaign'), | ||
'campaignLoader', | 'campaignLoader', | ||
'Campaign loaded successfully for ' .. campaignName, | 'Campaign loaded successfully for ' .. campaignName, | ||
| Line 342: | Line 451: | ||
) | ) | ||
-- | -- Detect what custom parameters are provided BEFORE merging defaults | ||
local customParameters = {} | local customParameters = {} | ||
local hasCustomParameters = false | local hasCustomParameters = false | ||
| Line 364: | Line 473: | ||
end | end | ||
-- Determine | -- Determine mode: | ||
-- Pure Documentation: only campaign_name provided | |||
-- Partial Mode: some but not all fields provided | |||
-- Complete Mode: all fields provided | |||
local isPureDocumentation = not hasCustomParameters | |||
local isPartialMode = hasCustomParameters and not hasAllParameters | |||
local isCompleteMode = hasCustomParameters and hasAllParameters | |||
local showInstructions = isPureDocumentation or isPartialMode | |||
-- Store mode | -- Store mode flags for use in rendering | ||
args. | args._documentation_mode = isPureDocumentation | ||
args._show_instructions = | args._partial_mode = isPartialMode | ||
args._campaign_data = campaignData | args._complete_mode = isCompleteMode | ||
args._show_instructions = showInstructions | |||
args._campaign_data = campaignData -- Store for usage instruction generation | |||
-- Defaults are only used for parameter documentation, not content rendering | -- Defaults are only used for parameter documentation, not content rendering | ||
| Line 394: | Line 504: | ||
-- Add usage instructions in documentation and partial modes | -- Add usage instructions in documentation and partial modes | ||
if | if isPureDocumentation or isPartialMode then | ||
table.insert(fields, { | table.insert(fields, { | ||
key = "usageInstructions", | key = "usageInstructions", | ||
| Line 400: | Line 510: | ||
type = "text" | type = "text" | ||
}) | }) | ||
-- Set a dummy value so the field gets processed | |||
args.usageInstructions = "documentation" | args.usageInstructions = "documentation" | ||
end | end | ||
| Line 428: | Line 539: | ||
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._documentation_mode or args._partial_mode then | ||
return "''Please see usage instructions above to customize this field.''" | return "''Please see usage instructions above to customize this field.''" | ||
end | end | ||
| Line 435: | Line 546: | ||
end | end | ||
-- | -- Special processor for campaign introduction - always uses JSON default, not user input | ||
template._processors.campaign_intro = function(value, args, template, fieldType) | template._processors.campaign_intro = function(value, args, template, fieldType) | ||
-- | -- Always use the campaign data default, ignore user input | ||
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 443: | Line 554: | ||
else | else | ||
-- Fallback if no campaign data available | -- Fallback if no campaign data available | ||
if args. | if args._documentation_mode or args._partial_mode then | ||
return "''Campaign introduction will appear here from JSON defaults.''" | return "''Campaign introduction will appear here from JSON defaults.''" | ||
end | end | ||
| Line 462: | Line 573: | ||
end | end | ||
-- Export the render function | |||
function p.render(frame) | function p.render(frame) | ||
-- Create a custom render function that bypasses Blueprint's argument extraction | |||
-- and handles arguments properly for direct module invocation | |||
local depth = | template.current_frame = frame -- Store frame on template instance | ||
-- Check recursion depth to prevent infinite loops | |||
local depth = 0 | |||
if frame.args and frame.args._recursion_depth then | |||
depth = tonumber(frame.args._recursion_depth) or 0 | |||
elseif frame:getParent() and frame:getParent().args and frame:getParent().args._recursion_depth then | |||
depth = tonumber(frame:getParent().args._recursion_depth) or 0 | |||
end | |||
if depth > 3 then | if depth > 3 then | ||
| Line 479: | Line 600: | ||
end | end | ||
-- | -- Handle arguments from both direct module invocation and template calls | ||
local args = {} | local args = {} | ||
-- Get arguments from parent frame (template parameters) | |||
local parentArgs = frame:getParent().args or {} | local parentArgs = frame:getParent().args or {} | ||
for k, v in pairs(parentArgs) do | |||
args[k] = v | |||
end | |||
-- Get arguments from current frame (module invocation parameters) - these take precedence | |||
local frameArgs = frame.args or {} | local frameArgs = frame.args or {} | ||
for k, v in pairs(frameArgs) do | |||
args[k] = v | |||
end | |||
-- Normalize argument case like Blueprint does | |||
local TemplateHelpers = require('Module:TemplateHelpers') | |||
args = TemplateHelpers.normalizeArgumentCase(args) | args = TemplateHelpers.normalizeArgumentCase(args) | ||
-- Increment recursion depth for any child template calls | |||
args._recursion_depth = tostring(depth + 1) | args._recursion_depth = tostring(depth + 1) | ||
-- Run preprocessors manually (since we're bypassing Blueprint's renderTemplate) | |||
local Blueprint = require('Module:LuaTemplateBlueprint') | |||
args = Blueprint.runPreprocessors(template, args) | args = Blueprint.runPreprocessors(template, args) | ||
-- Get table class configuration | |||
local tableClass = "template-table" | |||
if template.config.constants and template.config.constants.tableClass then | |||
tableClass = template.config.constants.tableClass | |||
end | |||
-- Set up structure configuration for rendering | |||
local structureConfig = { | local structureConfig = { | ||
tableClass = | tableClass = tableClass, | ||
blocks = {}, | blocks = {}, | ||
containerTag = template.features.fullPage and "div" or "table" | containerTag = template.features.fullPage and "div" or "table" | ||
} | } | ||
-- Build rendering sequence manually | |||
local renderingSequence = Blueprint.buildRenderingSequence(template) | local renderingSequence = Blueprint.buildRenderingSequence(template) | ||
| Line 505: | Line 646: | ||
end | end | ||
-- Add rendering functions to structure config | |||
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 511: | Line 653: | ||
end | end | ||
-- Render using TemplateStructure | |||
local TemplateStructure = require('Module:TemplateStructure') | |||
local result = TemplateStructure.render(args, structureConfig, template._errorContext) | local result = TemplateStructure.render(args, structureConfig, template._errorContext) | ||
template.current_frame = nil | template.current_frame = nil -- Clear frame from template instance | ||
return result | return result | ||
Revision as of 00:32, 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. This template provides a flexible, data-driven approach to campaign templates without cluttering ConfigRepository. It dynamically generates field definitions based on the JSON structure and renders any campaign content.
*
* 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')
-- Register the Campaign template with Blueprint
local template = Blueprint.registerTemplate('Campaign', {
features = {
fullPage = true, -- Full-page layout
categories = true,
errorReporting = false -- REVIEW
}
})
-- Dynamic configuration (from JSON)
template.config = {
fields = {}, -- Dynamically generated
categories = {
base = {} -- Populated dynamically based on campaign
},
constants = {
title = "Campaign Information", -- Default title, can be overridden by JSON
tableClass = "" -- Empty to prevent template box styling
}
}
-- Blueprint default: Initialize standard configuration
Blueprint.initializeConfig(template)
-- Custom block sequence (bypass standard Blueprint blocks)
template.config.blockSequence = {
'campaignBanner',
'campaignTitle',
'campaignInstructions',
'campaignContent',
'categories',
'errors'
}
-- Initialize custom blocks
template.config.blocks = template.config.blocks or {}
-- 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 .. "}}"
-- Build instruction content
table.insert(output, "'''READ CAREFULLY'''. These are temporary instructions that will appear only until all parameters outlined here have been filled. Choose 'Edit source' at any time and edit the code below if it already exists. It can also be added manually to any page:")
table.insert(output, "")
table.insert(output, "<pre>" .. templateCall .. "</pre>")
table.insert(output, "")
table.insert(output, "'''Available Parameters:'''")
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 fallback logic (consolidates repeated logic)
local function getCampaignName(args, campaignData)
local campaignName = args.campaign_name or args.campaignname or args.Campaign_name or args.CampaignName
-- If still nil, try to get it from the campaign data
if not campaignName and campaignData and campaignData.template_id then
campaignName = campaignData.template_id
end
-- Final fallback
if not campaignName then
campaignName = "CAMPAIGN_NAME"
end
return campaignName
end
-- Block 1: Campaign Banner (sets noticeHandler config for top-of-page injection)
template.config.blocks.campaignBanner = {
feature = 'fullPage',
render = function(template, args)
local context = template._errorContext or ErrorHandling.createContext('T-Campaign')
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) -- Return debug output
end
local banner = args._campaign_data.banner
local bannerContent = banner.content or ""
local cssClass = banner.css_class or "campaign-banner"
if bannerContent == "" then
ErrorHandling.addStatus(context, 'campaignBanner', 'Empty banner content', 'No content to display')
return ErrorHandling.formatCombinedOutput(context)
end
-- Apply string normalization to ALL content first (critical for wiki link processing)
bannerContent = bannerContent:gsub("%s+", " "):gsub("^%s+", ""):gsub("%s+$", "")
-- Use TemplateStarter's proven placeholder replacement strategy
local placeholderValues = {
CAMPAIGN_NAME = args._campaign_data.defaults.title or "Campaign"
}
-- Apply TemplateStarter's replacePlaceholders function logic
local function replacePlaceholders(text, values)
if not text or not values then
return text
end
local result = text
for key, value in pairs(values) do
if value and value ~= "" then
result = result:gsub("%$" .. key .. "%$", value)
end
end
return result
end
-- Apply placeholder replacement
bannerContent = replacePlaceholders(bannerContent, placeholderValues)
-- Clean up any remaining unfilled placeholders (TemplateStarter's removeEmptyPlaceholders logic)
bannerContent = bannerContent:gsub("%$[A-Z_]+%$", "")
bannerContent = bannerContent:gsub("%s+", " "):gsub("^%s+", ""):gsub("%s+$", "")
-- UNIFIED PIPELINE: Process ALL wiki links with custom patterns (no frame:preprocess)
local originalContent = bannerContent
-- Pattern 1: [[Page Name]] -> HTML link (simple page links)
bannerContent = bannerContent:gsub('%[%[([^#|%]]+)%]%]', function(pageName)
local spacesReplaced = pageName:gsub(' ', '_')
local success1, pageUrl = pcall(function()
return tostring(mw.uri.fullUrl(spacesReplaced))
end)
if not success1 then
ErrorHandling.addStatus(context, 'campaignBanner', 'mw.uri.fullUrl failed for page link', 'Page: ' .. pageName .. ', Error: ' .. tostring(pageUrl))
return '[[' .. pageName .. ']]'
end
local success2, encodedName = pcall(function()
return mw.text.encode(pageName)
end)
if not success2 then
ErrorHandling.addStatus(context, 'campaignBanner', 'mw.text.encode failed for page link', 'Page: ' .. pageName .. ', Error: ' .. tostring(encodedName))
return '[[' .. pageName .. ']]'
end
local success3, result = pcall(function()
return string.format('<a href="%s">%s</a>', pageUrl, encodedName)
end)
if not success3 then
ErrorHandling.addStatus(context, 'campaignBanner', 'string.format failed for page link', 'Page: ' .. pageName .. ', Error: ' .. tostring(result))
return '[[' .. pageName .. ']]'
end
return result
end)
-- Pattern 2: [[Page|text]] -> HTML link (page links with custom text)
bannerContent = bannerContent:gsub('%[%[([^#|%]]+)|([^%]]+)%]%]', function(pageName, text)
local success1, pageUrl = pcall(function()
return tostring(mw.uri.fullUrl((pageName:gsub(' ', '_'))))
end)
if not success1 then
ErrorHandling.addStatus(context, 'campaignBanner', 'Pattern 2 mw.uri.fullUrl failed', 'Error: ' .. tostring(pageUrl))
return '[[' .. pageName .. '|' .. text .. ']]'
end
local success2, encodedText = pcall(function()
local encoded = mw.text.encode(text)
return encoded
end)
if not success2 then
ErrorHandling.addStatus(context, 'campaignBanner', 'Pattern 2 mw.text.encode failed', 'Error: ' .. tostring(encodedText))
return '[[' .. pageName .. '|' .. text .. ']]'
end
local success3, result = pcall(function()
return string.format('<a href="%s">%s</a>', pageUrl, encodedText)
end)
if not success3 then
ErrorHandling.addStatus(context, 'campaignBanner', 'Pattern 2 string.format failed', 'Error: ' .. tostring(result))
return '[[' .. pageName .. '|' .. text .. ']]'
end
return result
end)
-- Pattern 3: [[#anchor|text]] -> HTML anchor link
bannerContent = bannerContent:gsub('%[%[#([^|%]]+)|([^%]]+)%]%]', function(anchor, text)
local success1, encodedAnchor = pcall(function()
return mw.uri.anchorEncode(anchor)
end)
if not success1 then
ErrorHandling.addStatus(context, 'campaignBanner', 'Pattern 3 mw.uri.anchorEncode failed', 'Error: ' .. tostring(encodedAnchor))
return '[[#' .. anchor .. '|' .. text .. ']]'
end
local success2, encodedText = pcall(function()
local encoded = mw.text.encode(text)
return encoded
end)
if not success2 then
ErrorHandling.addStatus(context, 'campaignBanner', 'Pattern 3 mw.text.encode failed', 'Error: ' .. tostring(encodedText))
return '[[#' .. anchor .. '|' .. text .. ']]'
end
local success3, result = pcall(function()
return string.format('<a href="#%s">%s</a>', encodedAnchor, encodedText)
end)
if not success3 then
ErrorHandling.addStatus(context, 'campaignBanner', 'Pattern 3 string.format failed', 'Error: ' .. tostring(result))
return '[[#' .. anchor .. '|' .. text .. ']]'
end
return result
end)
-- Set notice data for JavaScript injection
local noticeData = {
type = "campaign",
position = "top",
content = bannerContent,
cssClass = cssClass
}
-- Output data attributes for NoticeHandler to read (instead of mw.config)
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
}
-- Block 2: Campaign Title
template.config.blocks.campaignTitle = {
feature = 'fullPage',
render = function(template, args)
local titleText = template.config.constants.title or "Campaign Information"
return "== " .. titleText .. " =="
end
}
-- Block 2: Campaign Instructions (only in documentation/partial modes)
template.config.blocks.campaignInstructions = {
feature = 'fullPage',
render = function(template, args)
if not args._show_instructions or not args._campaign_data then
return "" -- Only render when instructions should be shown
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
}
-- Block 3: Campaign Content (all campaign fields)
template.config.blocks.campaignContent = {
feature = 'fullPage',
render = function(template, args)
local output = {}
for _, field in ipairs(template.config.fields or {}) do
-- Skip usage instructions field (handled by campaignInstructions block)
if field.key ~= "usageInstructions" then
local rawValue = args[field.key]
local processor = template._processors[field.key] or template._processors.default
if processor then
-- Pass field type as additional parameter to fix broken detection
local value = processor(rawValue, args, template, field.type)
if value and value ~= "" then
-- Special handling for campaign_intro - no section header
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 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 ~= "" then
table.insert(items, '<span class="campaign-token">' .. trimmed .. '</span>')
end
end
if #items > 0 then
return table.concat(items, ' ')
else
return tostring(value)
end
else
return tostring(value)
end
end
-- Custom preprocessor to load campaign data and generate fields dynamically
Blueprint.addPreprocessor(template, function(template, args)
-- Get campaign_name from args (which should already be merged by Blueprint)
-- But also check the current frame for direct module invocations
local campaignName = args.campaign_name
-- If not found in args, try to get it from the current frame
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 or ErrorHandling.createContext('T-Campaign'),
'campaignLoader',
'Campaign loaded successfully for ' .. campaignName,
nil
)
-- Detect what custom parameters are provided BEFORE merging defaults
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 mode:
-- Pure Documentation: only campaign_name provided
-- Partial Mode: some but not all fields provided
-- Complete Mode: all fields provided
local isPureDocumentation = not hasCustomParameters
local isPartialMode = hasCustomParameters and not hasAllParameters
local isCompleteMode = hasCustomParameters and hasAllParameters
local showInstructions = isPureDocumentation or isPartialMode
-- Store mode flags for use in rendering
args._documentation_mode = isPureDocumentation
args._partial_mode = isPartialMode
args._complete_mode = isCompleteMode
args._show_instructions = showInstructions
args._campaign_data = campaignData -- Store for usage instruction generation
-- 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 isPureDocumentation or isPartialMode then
table.insert(fields, {
key = "usageInstructions",
label = "Usage Instructions",
type = "text"
})
-- Set a dummy value so the field gets processed
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._documentation_mode or args._partial_mode 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 processor for campaign introduction - always uses JSON default, not user input
template._processors.campaign_intro = function(value, args, template, fieldType)
-- Always use the campaign data default, ignore user input
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._documentation_mode or args._partial_mode 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
-- Export the render function
function p.render(frame)
-- Create a custom render function that bypasses Blueprint's argument extraction
-- and handles arguments properly for direct module invocation
template.current_frame = frame -- Store frame on template instance
-- Check recursion depth to prevent infinite loops
local depth = 0
if frame.args and frame.args._recursion_depth then
depth = tonumber(frame.args._recursion_depth) or 0
elseif frame:getParent() and frame:getParent().args and frame:getParent().args._recursion_depth then
depth = tonumber(frame:getParent().args._recursion_depth) or 0
end
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
-- Handle arguments from both direct module invocation and template calls
local args = {}
-- Get arguments from parent frame (template parameters)
local parentArgs = frame:getParent().args or {}
for k, v in pairs(parentArgs) do
args[k] = v
end
-- Get arguments from current frame (module invocation parameters) - these take precedence
local frameArgs = frame.args or {}
for k, v in pairs(frameArgs) do
args[k] = v
end
-- Normalize argument case like Blueprint does
local TemplateHelpers = require('Module:TemplateHelpers')
args = TemplateHelpers.normalizeArgumentCase(args)
-- Increment recursion depth for any child template calls
args._recursion_depth = tostring(depth + 1)
-- Run preprocessors manually (since we're bypassing Blueprint's renderTemplate)
local Blueprint = require('Module:LuaTemplateBlueprint')
args = Blueprint.runPreprocessors(template, args)
-- Get table class configuration
local tableClass = "template-table"
if template.config.constants and template.config.constants.tableClass then
tableClass = template.config.constants.tableClass
end
-- Set up structure configuration for rendering
local structureConfig = {
tableClass = tableClass,
blocks = {},
containerTag = template.features.fullPage and "div" or "table"
}
-- Build rendering sequence manually
local renderingSequence = Blueprint.buildRenderingSequence(template)
if renderingSequence._length == 0 then
return ""
end
-- Add rendering functions to structure config
for i = 1, renderingSequence._length do
table.insert(structureConfig.blocks, function(a)
return renderingSequence[i](a)
end)
end
-- Render using TemplateStructure
local TemplateStructure = require('Module:TemplateStructure')
local result = TemplateStructure.render(args, structureConfig, template._errorContext)
template.current_frame = nil -- Clear frame from template instance
return result
end
return p