Conceptual illustration of plasma physics in a fusion context, showing magnetically confined ionized gas in a tokamak and the collective behavior governed by electromagnetic fields and transport processes.
...
are counted.
The following namespaces are excluded:
Template:
File:
Image:
Category:
Help:
Special:
Duplicate links are counted only once in the total.
Rendering behavior
Each section:
is converted into a collapsible block
receives an automatic item count
is assigned correct numbering via <ol start="...">
The module handles:
layout
numbering
counting
The source page should NOT contain:
manual collapsible blocks
manual <ol> or start=
layout styling
Important notes
Templates should NOT be inside <li> if you want numbering to match totals.
Images and descriptive text are preserved inside sections.
Section headings must use:
=== Section name ===
Output structure
The module returns:
= Table of content (N articles)=
== Core pathway ==
...
== Full contents ==
[collapsible sections]
Maintenance
To update content:
Edit the source page (Q)
Do NOT modify the module (M)
The module automatically updates:
numbering
totals
layout
Summary
Q = content and structure
M = logic and rendering
B = invocation
This separation ensures scalability, consistency, and minimal maintenance.
local p = {}
-- =========================
-- Styling configuration
-- =========================
local STYLE = {}
STYLE.sectionHeader =
"font-family:'Segoe UI','Helvetica Neue',Arial,sans-serif;" ..
"font-weight:400;" ..
"font-size:100%;" ..
"color:#000;" ..
"display:inline;" ..
"line-height:1.2em;"
STYLE.sectionBox =
"font-family:'Segoe UI','Helvetica Neue',Arial,sans-serif;" ..
"font-size:90%;" ..
"color:#000;" ..
"border:0;" ..
"background:transparent;" ..
"padding:0;" ..
"margin:0.05em 0;"
STYLE.backLink =
"font-family:'Segoe UI','Helvetica Neue',Arial,sans-serif;" ..
"font-size:100%;" ..
"font-weight:400;" ..
"float:right;" ..
"margin-left:1em;" ..
"color:#3366cc;"
STYLE.indexSubheading =
"font-weight:700;" ..
"margin:0.45em 0 0.15em 0;"
STYLE.indexSubbook =
"margin:0.15em 0 0.55em 1.4em;" ..
"font-size:100%;"
STYLE.galleryWrap =
"display:flex;" ..
"flex-wrap:wrap;" ..
"gap:12px;" ..
"align-items:flex-start;"
STYLE.galleryCard =
"text-align:center;" ..
"vertical-align:top;" ..
"border:1px solid #c8ccd1;" ..
"background:#fff8dc;" ..
"padding:6px;" ..
"box-sizing:border-box;"
STYLE.galleryCaption =
"font-size:90%;" ..
"display:block;" ..
"margin-top:4px;" ..
"line-height:1.25em;"
-- =========================
-- Helpers
-- =========================
local function trim(s)
return (s or ""):gsub("^%s+", ""):gsub("%s+$", "")
end
local function makeAnchorId(s)
s = trim(s)
s = s:gsub("%s+", "_")
s = s:gsub("[^%w_%-]", "")
return s
end
local function getContent(page)
if not page or page == "" then
return ""
end
local title = mw.title.new(page)
if not title then
return ""
end
return title:getContent() or ""
end
local function getCurrentPageName()
local title = mw.title.getCurrentTitle()
if not title then
return ""
end
return title.prefixedText or ""
end
local function isBookPage()
local page = getCurrentPageName()
return page == "Book:Quantum Collection"
or page == "Book:Quantum Collection/Matter (by scale)"
or page == "Book:Quantum Collection/Methods and tools"
end
local function shouldCollapseSections()
return not isBookPage()
end
local function shouldUseTwoColumnSections()
return not isBookPage()
end
local function isIgnoredIndexTarget(target)
return target:match("^(File|Image|Category|Help|Special):")
end
local function isRealPageTarget(target)
return target:match("^Book:")
or target:match("^Physics:")
or target:match("^Template:")
or target:match("^Module:")
or target:find("/")
end
local function isSubbookLine(line)
return line and line:find("QC:subbook", 1, true) ~= nil
end
local function displayText(target, display)
if display and display ~= "" then
return display
end
if target:match("^Book:Quantum Collection/") then
return target:gsub("^Book:Quantum Collection/", "")
end
if target:match("^Physics:Quantum basics/See also/") then
return target:gsub("^Physics:Quantum basics/See also/", "")
end
if target:match("^Physics:") then
return target:gsub("^Physics:", "")
end
return target
end
function p.test(frame)
return "Physics QC module working"
end
-- =========================
-- Content extraction
-- =========================
local function extractIndexBlock(content)
local indexBlock = content:match("==%s*Index%s*==%s*(.-)\n==%s*Full contents%s*==")
if not indexBlock then
indexBlock = content:match("==%s*Index%s*==%s*(.-)</includeonly>")
end
if not indexBlock then
indexBlock = content:match("==%s*Index%s*==%s*(.*)")
end
return indexBlock or ""
end
local function extractFullContentsBlock(content)
local fullBlock = content:match("==%s*Full contents%s*==%s*(.-)</includeonly>")
if not fullBlock then
fullBlock = content:match("==%s*Full contents%s*==%s*(.*)")
end
return fullBlock or ""
end
local function countFullContentsItems(content)
local seen = {}
local count = 0
local fullBlock = extractFullContentsBlock(content)
for target in fullBlock:gmatch("<li>%s*%[%[([^%]|#]+)") do
target = trim(target)
if not target:match("^(File|Image|Category|Help|Special|Template):") then
local key = mw.ustring.lower(target)
if not seen[key] then
seen[key] = true
count = count + 1
end
end
end
return count
end
-- =========================
-- Section parsing
-- =========================
local function parseSections(fullBlock)
local sections = {}
local currentTitle = nil
local currentLines = {}
local function flushSection()
if currentTitle then
table.insert(sections, {
title = currentTitle,
body = table.concat(currentLines, "\n")
})
end
currentLines = {}
end
for line in mw.text.gsplit(fullBlock, "\n", true) do
local heading = line:match("^===%s*(.-)%s*===%s*$")
if heading then
flushSection()
currentTitle = trim(heading)
else
if currentTitle then
table.insert(currentLines, line)
end
end
end
flushSection()
return sections
end
local function countRenderableItems(sectionBody)
local count = 0
local seen = {}
for target in sectionBody:gmatch("<li>%s*%[%[([^%]|#]+)") do
target = trim(target)
if not target:match("^(File|Image|Category|Help|Special|Template):") then
local key = mw.ustring.lower(target)
if not seen[key] then
seen[key] = true
count = count + 1
end
end
end
return count
end
local function normalizeSectionBody(sectionBody)
local lines = {}
for line in mw.text.gsplit(sectionBody, "\n", true) do
local trimmed = trim(line)
if trimmed ~= "<ol>"
and trimmed ~= "</ol>"
and trimmed ~= '<div class="mw-collapsible mw-collapsed" style="clear:both; border:1px solid #c8ccd1; background:#f8f9fa; padding:0.6em; margin:0.5em 0 1em 0;">'
and trimmed ~= "</div>" then
local templateTarget = trimmed:match("^<li>%s*%[%[(Template:[^%]]+)%]%]%s*</li>%s*$")
if templateTarget then
table.insert(lines, "[[" .. templateTarget .. "]]")
else
table.insert(lines, line)
end
end
end
return table.concat(lines, "\n")
end
local function adjustSectionImageMarkup(sectionBody)
if not sectionBody or sectionBody == "" then
return sectionBody
end
local function adjustOne(markup)
local out = markup
out = out:gsub("|%s*right%s*", "")
out = out:gsub("|%s*left%s*", "")
out = out:gsub("|%s*center%s*", "")
out = out:gsub("|%s*%d+px%s*", "|180px")
if not out:match("|%s*thumb%s*") then
out = out:gsub("^(%[%[%s*[Ff][Ii][Ll][Ee]:[^%]|%]]+)", "%1|thumb", 1)
out = out:gsub("^(%[%[%s*[Ii][Mm][Aa][Gg][Ee]:[^%]|%]]+)", "%1|thumb", 1)
end
out = out:gsub("|%s*thumb%s*", "|thumb|center", 1)
return out
end
sectionBody = sectionBody:gsub("(%[%[%s*[Ff][Ii][Ll][Ee]:.-%]%])", adjustOne, 1)
sectionBody = sectionBody:gsub("(%[%[%s*[Ii][Mm][Aa][Gg][Ee]:.-%]%])", adjustOne, 1)
return sectionBody
end
-- =========================
-- Index parsing/rendering
-- =========================
local function parseExplicitIndex(indexBlock)
local entries = {}
for line in mw.text.gsplit(indexBlock, "\n", true) do
local trimmed = trim(line)
if trimmed ~= "" then
local boldHeading = trimmed:match("^'''(.-)'''$")
local isSubbook = isSubbookLine(trimmed)
-- Match links anywhere in the line, so <li><!--QC:subbook-->[[...]]</li> works.
local target, display = trimmed:match("%[%[([^%]|%]]+)|([^%]]+)%]%]")
local plainTarget = nil
if not target then
plainTarget = trimmed:match("%[%[([^%]|%]]+)%]%]")
end
if boldHeading then
table.insert(entries, {
kind = "heading",
text = trim(boldHeading)
})
elseif target then
target = trim(target)
display = trim(display)
if not isIgnoredIndexTarget(target)
and not display:match("^%d+px")
and display ~= "right"
and display ~= "left"
and display ~= "thumb" then
table.insert(entries, {
kind = "item",
target = target,
display = display,
isSubbook = isSubbook
})
end
elseif plainTarget then
plainTarget = trim(plainTarget)
if not isIgnoredIndexTarget(plainTarget)
and not plainTarget:match("^%d+px")
and plainTarget ~= "right"
and plainTarget ~= "left"
and plainTarget ~= "thumb" then
table.insert(entries, {
kind = "item",
target = plainTarget,
display = nil,
isSubbook = isSubbook
})
end
end
end
end
return entries
end
local function renderIndexFromExplicitBlock(indexBlock)
local entries = parseExplicitIndex(indexBlock)
if #entries == 0 then
return ""
end
local groups = {}
local currentGroup = nil
for _, entry in ipairs(entries) do
if entry.kind == "heading" then
currentGroup = {
heading = entry.text,
items = {}
}
table.insert(groups, currentGroup)
elseif entry.kind == "item" then
if not currentGroup then
currentGroup = {
heading = "",
items = {}
}
table.insert(groups, currentGroup)
end
table.insert(currentGroup.items, {
target = entry.target,
display = entry.display,
isSubbook = entry.isSubbook
})
end
end
if #groups == 0 then
return ""
end
local function countNumberedItems(groupList)
local n = 0
for _, g in ipairs(groupList) do
for _, item in ipairs(g.items) do
if not item.isSubbook then
n = n + 1
end
end
end
return n
end
local leftGroups = {}
local rightGroups = {}
local split = math.ceil(#groups / 2)
for i, group in ipairs(groups) do
if i <= split then
table.insert(leftGroups, group)
else
table.insert(rightGroups, group)
end
end
if #rightGroups == 0 and #leftGroups > 1 then
table.insert(rightGroups, table.remove(leftGroups))
end
local function renderItemLink(item)
local target = item.target
local label = displayText(target, item.display)
if isRealPageTarget(target) then
return '[[' .. target .. '|' .. label .. ']]'
else
local anchorId = makeAnchorId(target)
return '[[#' .. anchorId .. '|' .. label .. ']]'
end
end
local function renderColumn(groupList, startNumber)
local out = {}
local itemNumber = startNumber
for _, group in ipairs(groupList) do
if trim(group.heading) ~= "" then
table.insert(out,
'<div style="' .. STYLE.indexSubheading .. '">' ..
mw.text.encode(group.heading) ..
'</div>'
)
end
if #group.items > 0 then
local olOpen = false
for _, item in ipairs(group.items) do
if item.isSubbook then
if olOpen then
table.insert(out, '</ol>')
olOpen = false
end
table.insert(out,
'<div style="' .. STYLE.indexSubbook .. '">→ ' ..
renderItemLink(item) ..
'</div>'
)
else
if not olOpen then
table.insert(out, '<ol start="' .. tostring(itemNumber) .. '">')
olOpen = true
end
table.insert(out, '<li>' .. renderItemLink(item) .. '</li>')
itemNumber = itemNumber + 1
end
end
if olOpen then
table.insert(out, '</ol>')
end
end
end
return table.concat(out, "\n"), itemNumber
end
local leftCount = countNumberedItems(leftGroups)
local leftHtml = select(1, renderColumn(leftGroups, 1))
local rightHtml = ""
if #rightGroups > 0 then
rightHtml = select(1, renderColumn(rightGroups, leftCount + 1))
end
local out = {}
table.insert(out, '<div style="display:flex; gap:30px; align-items:flex-start;">')
table.insert(out, '<div style="flex:1; min-width:0;">')
table.insert(out, leftHtml)
table.insert(out, '</div>')
table.insert(out, '<div style="flex:1; min-width:0;">')
table.insert(out, rightHtml)
table.insert(out, '</div>')
table.insert(out, '</div>')
return table.concat(out, "\n")
end
local function renderIndexFromSections(sections)
if #sections == 0 then
return ""
end
local out = {}
for _, section in ipairs(sections) do
local anchorId = makeAnchorId(section.title)
table.insert(out, "# [[#" .. anchorId .. "|" .. section.title .. "]]")
end
return table.concat(out, "\n")
end
-- =========================
-- Rendering full contents
-- =========================
local function renderSingleSectionBox(section, sectionIndex, startIndex, collapseClass, adjustImagesForColumns)
local out = {}
local body = normalizeSectionBody(section.body)
if adjustImagesForColumns then
body = adjustSectionImageMarkup(body)
end
local itemCount = countRenderableItems(body)
local anchorId = makeAnchorId(section.title)
table.insert(out, '<div class="' .. collapseClass .. '" style="' .. STYLE.sectionBox .. '">')
table.insert(out,
'<div id="' .. anchorId .. '" style="' .. STYLE.sectionHeader .. '">' ..
'<span>' ..
tostring(sectionIndex) .. '. ' ..
mw.text.encode(section.title) ..
' (' .. tostring(itemCount) .. ')' ..
'</span>' ..
'<span style="' .. STYLE.backLink .. '">[[#QCIndex|↑]]</span>' ..
'</div>'
)
table.insert(out, '<div class="mw-collapsible-content" style="margin-top:0;">')
if itemCount > 0 then
table.insert(out, '<ol start="' .. tostring(startIndex) .. '">')
table.insert(out, body)
table.insert(out, '</ol>')
else
table.insert(out, body)
end
table.insert(out, '</div>')
table.insert(out, '</div>')
return table.concat(out, "\n"), itemCount
end
local function renderCollapsibleSections(fullBlock)
local sections = parseSections(fullBlock)
if #sections == 0 then
return fullBlock
end
local collapseClass = "mw-collapsible"
if shouldCollapseSections() then
collapseClass = "mw-collapsible mw-collapsed"
end
if not shouldUseTwoColumnSections() then
local out = {}
local startIndex = 1
local sectionIndex = 1
for _, section in ipairs(sections) do
local html, itemCount = renderSingleSectionBox(section, sectionIndex, startIndex, collapseClass, false)
table.insert(out, html)
startIndex = startIndex + itemCount
sectionIndex = sectionIndex + 1
end
return table.concat(out, "\n")
end
local leftSections = {}
local rightSections = {}
local halfway = math.ceil(#sections / 2)
for i, section in ipairs(sections) do
if i <= halfway then
table.insert(leftSections, section)
else
table.insert(rightSections, section)
end
end
local function renderSectionColumn(sectionList, startSectionIndex, startItemIndex)
local out = {}
local sectionIndex = startSectionIndex
local itemIndex = startItemIndex
for _, section in ipairs(sectionList) do
local html, itemCount = renderSingleSectionBox(section, sectionIndex, itemIndex, collapseClass, true)
table.insert(out, html)
sectionIndex = sectionIndex + 1
itemIndex = itemIndex + itemCount
end
return table.concat(out, "\n")
end
local leftHtml = renderSectionColumn(leftSections, 1, 1)
local leftItemCount = 0
for _, section in ipairs(leftSections) do
local body = adjustSectionImageMarkup(normalizeSectionBody(section.body))
leftItemCount = leftItemCount + countRenderableItems(body)
end
local rightHtml = renderSectionColumn(rightSections, #leftSections + 1, leftItemCount + 1)
local out = {}
table.insert(out, '<div style="display:flex; gap:20px; align-items:flex-start;">')
table.insert(out, '<div style="flex:1; min-width:0;">')
table.insert(out, leftHtml)
table.insert(out, '</div>')
table.insert(out, '<div style="flex:1; min-width:0;">')
table.insert(out, rightHtml)
table.insert(out, '</div>')
table.insert(out, '</div>')
return table.concat(out, "\n")
end
-- =========================
-- Gallery helpers
-- =========================
local function extractPhysicsLinks(content)
local fullBlock = extractFullContentsBlock(content)
local pages = {}
local seenPages = {}
for target in fullBlock:gmatch("<li>%s*%[%[([^%]|#]+)") do
target = trim(target)
if target:match("^Physics:") then
local key = mw.ustring.lower(target)
if not seenPages[key] then
seenPages[key] = true
table.insert(pages, target)
end
end
end
return pages
end
local function normalizeFileTarget(fileTarget)
if not fileTarget or fileTarget == "" then
return nil
end
fileTarget = trim(fileTarget)
fileTarget = fileTarget:gsub("^[Ii][Mm][Aa][Gg][Ee]:", "File:")
return fileTarget
end
local function extractFirstFileFromText(text)
if not text or text == "" then
return nil
end
local fileTarget = text:match("%[%[%s*([Ff][Ii][Ll][Ee]:[^%]|]+)")
if fileTarget and fileTarget ~= "" then
return normalizeFileTarget(fileTarget)
end
fileTarget = text:match("%[%[%s*([Ii][Mm][Aa][Gg][Ee]:[^%]|]+)")
if fileTarget and fileTarget ~= "" then
return normalizeFileTarget(fileTarget)
end
return nil
end
local function extractFirstFileFromPage(pageName)
local content = getContent(pageName)
if not content or content == "" then
return nil
end
return extractFirstFileFromText(content)
end
local function makeCleanGalleryTitle(targetPage)
local cleanTitle = targetPage or ""
cleanTitle = cleanTitle:gsub("^Physics:Quantum%s+", "")
cleanTitle = cleanTitle:gsub("^Physics:", "")
return cleanTitle
end
-- =========================
-- Core renderer
-- =========================
local function renderTocPage(pageName, showHeading)
local content = getContent(pageName)
if content == "" then
if showHeading then
return "= Table of contents (0 articles)=\n"
end
return ""
end
local count = countFullContentsItems(content)
local indexBlock = extractIndexBlock(content)
local fullBlock = extractFullContentsBlock(content)
local sections = parseSections(fullBlock)
local renderedIndex = ""
if trim(indexBlock) ~= "" then
renderedIndex = renderIndexFromExplicitBlock(indexBlock)
else
renderedIndex = renderIndexFromSections(sections)
end
local renderedFullBlock = renderCollapsibleSections(fullBlock)
local result = {}
if showHeading then
table.insert(result, string.format("= Table of contents (%d articles)=", count))
end
if trim(renderedIndex) ~= "" then
table.insert(result, '<span id="QCIndex"></span>')
table.insert(result, "== Index ==")
table.insert(result, renderedIndex)
end
table.insert(result, "== Full contents ==")
table.insert(result, renderedFullBlock)
return table.concat(result, "\n")
end
-- =========================
-- Public functions
-- =========================
function p.tocHeading(frame)
local pageName = frame.args[1]
local content = getContent(pageName)
local count = countFullContentsItems(content)
return string.format("= Table of contents (%d articles)=", count)
end
function p.tocHeadingAndList(frame)
local pageName = frame.args[1] or "Physics:Quantum basics/See also"
return renderTocPage(pageName, true)
end
function p.qm(frame)
return renderTocPage("Physics:Quantum basics/See also/Matter", true)
end
function p.qt(frame)
return renderTocPage("Physics:Quantum basics/See also/Methods", true)
end
function p.gallery(frame)
local pageName = trim(frame.args[1] or "")
local content = getContent(pageName)
if content == "" then
return '[[Book:Quantum Collection|← Back to Book:Quantum Collection]]\n\n== Gallery ==\n\nNo content found.'
end
local thumbWidth = tonumber(frame.args.width or frame.args[2]) or 150
local cardWidth = thumbWidth + 20
local pages = extractPhysicsLinks(content)
local totalPages = #pages
local out = {}
local seenFiles = {}
local imageCount = 0
local missingCount = 0
local items = {}
for _, targetPage in ipairs(pages) do
local fileName = extractFirstFileFromPage(targetPage)
local cleanTitle = makeCleanGalleryTitle(targetPage)
if fileName and fileName ~= "" then
local key = mw.ustring.lower(fileName)
if not seenFiles[key] then
seenFiles[key] = true
imageCount = imageCount + 1
table.insert(items,
'<div style="' .. STYLE.galleryCard .. ' width:' .. tostring(cardWidth) .. 'px;">' ..
'[[' .. fileName .. '|' .. tostring(thumbWidth) .. 'px]]' ..
'<span style="' .. STYLE.galleryCaption .. '">[[' .. targetPage .. '|' .. cleanTitle .. ']]</span>' ..
'</div>'
)
else
table.insert(items,
'<div style="' .. STYLE.galleryCard .. ' width:' .. tostring(cardWidth) .. 'px; min-height:' .. tostring(thumbWidth + 40) .. 'px; display:flex; flex-direction:column; justify-content:center; align-items:center;">' ..
'<div style="font-size:90%; line-height:1.3em;">[[' .. targetPage .. '|' .. cleanTitle .. ']]</div>' ..
'<div style="font-size:85%; color:#666; margin-top:6px;">Duplicate image</div>' ..
'</div>'
)
end
else
missingCount = missingCount + 1
table.insert(items,
'<div style="' .. STYLE.galleryCard .. ' width:' .. tostring(cardWidth) .. 'px; min-height:' .. tostring(thumbWidth + 40) .. 'px; display:flex; flex-direction:column; justify-content:center; align-items:center;">' ..
'<div style="font-size:90%; line-height:1.3em;">[[' .. targetPage .. '|' .. cleanTitle .. ']]</div>' ..
'<div style="font-size:85%; color:#aa0000; margin-top:6px;">Image missing</div>' ..
'</div>'
)
end
end
table.insert(out, '[[Book:Quantum Collection|← Back to Book:Quantum Collection]]')
table.insert(out, '')
table.insert(out,
'== Gallery (' ..
tostring(imageCount) .. ' images, ' ..
tostring(missingCount) .. ' missing, ' ..
tostring(totalPages) .. ' pages) =='
)
table.insert(out, '')
table.insert(out, '<div style="' .. STYLE.galleryWrap .. '">')
for _, item in ipairs(items) do
table.insert(out, item)
end
table.insert(out, '</div>')
return table.concat(out, "\n")
end
function p.galleryMissing(frame)
local pageName = trim(frame.args[1] or "")
local content = getContent(pageName)
if content == "" then
return "No content found."
end
local pages = extractPhysicsLinks(content)
local out = {}
table.insert(out, "== Pages with missing gallery image ==")
for _, targetPage in ipairs(pages) do
local fileName = extractFirstFileFromPage(targetPage)
if not fileName or fileName == "" then
table.insert(out, "* [[" .. targetPage .. "]]")
end
end
return table.concat(out, "\n")
end
function p.galleryDuplicates(frame)
local pageName = trim(frame.args[1] or "")
local content = getContent(pageName)
if content == "" then
return "No content found."
end
local pages = extractPhysicsLinks(content)
local seenFiles = {}
local out = {}
table.insert(out, "== Pages with duplicate first gallery image ==")
for _, targetPage in ipairs(pages) do
local fileName = extractFirstFileFromPage(targetPage)
if fileName and fileName ~= "" then
local key = mw.ustring.lower(fileName)
if seenFiles[key] then
table.insert(out, "* [[" .. targetPage .. "]] → [[:"
.. fileName .. "]]")
else
seenFiles[key] = targetPage
end
end
end
return table.concat(out, "\n")
end
function p.testwidth(frame)
return 'arg1=' .. tostring(frame.args[1]) ..
', width=' .. tostring(frame.args.width) ..
', arg2=' .. tostring(frame.args[2])
end
return p