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 = {}
-- ============================================================
-- Module:PhysicsQC
-- Quantum Collection table-of-contents and gallery renderer.
--
-- Restored rules:
-- * Index main groups: 2 columns.
-- * Book II / Book III / Book IV: one horizontal row below Index.
-- * Full contents: ONE column.
-- * Full contents preserves raw body, including images.
-- * Counts exclude File/Image/Book/etc.
-- * Gallery uses compact cards.
-- ============================================================
local function trim(s)
if not s then
return ''
end
return mw.text.trim(s)
end
local function escapeHtml(s)
return mw.text.encode(s or '')
end
local function getPageContent(pageName)
local title = mw.title.new(pageName or '')
if not title then
return ''
end
local text = title:getContent() or ''
-- Prevent visible <includeonly> / </includeonly> leaks from data/template pages.
text = text:gsub('<%s*/?%s*includeonly%s*>', '')
return text
end
local function getCurrentPageName()
local t = mw.title.getCurrentTitle()
if not t then
return ''
end
return t.prefixedText or ''
end
local function isCurrentBookPage()
return getCurrentPageName():match('^Book:Quantum Collection') ~= nil
end
local function stripNamespaceForLabel(page)
page = page or ''
page = page:gsub('^Physics:Quantum data analysis/', '')
page = page:gsub('^Physics:Quantum methods/', '')
page = page:gsub('^Physics:Quantum materials/', '')
page = page:gsub('^Physics:Quantum matter/', '')
page = page:gsub('^Physics:Quantum atoms/', '')
page = page:gsub('^Physics:Quantum basics/', '')
page = page:gsub('^Physics:Quantum ', '')
page = page:gsub('^Physics:', '')
page = page:gsub('^Book:Quantum Collection/', '')
page = page:gsub('_', ' ')
return page
end
local function makeAnchorId(s)
s = s or ''
s = mw.text.decode(s, true)
s = s:gsub('<[^>]+>', '')
s = s:gsub(' ', ' ')
s = trim(s)
s = s:gsub('%s+', '_')
return mw.uri.anchorEncode(s)
end
local function isSkippedLink(link)
if not link or link == '' then
return true
end
link = trim(link)
if link:match('^#') then return true end
if link:match('^File:') then return true end
if link:match('^Image:') then return true end
if link:match('^Category:') then return true end
if link:match('^Help:') then return true end
if link:match('^Special:') then return true end
if link:match('^Template:') then return true end
if link:match('^Module:') then return true end
return false
end
local function isSkippedArticleCountLink(link)
if isSkippedLink(link) then return true end
if link:match('^Book:') then return true end
return false
end
local function parseWikiLink(raw)
raw = raw or ''
local target, label = raw:match('^([^|]+)|(.+)$')
if not target then
target = raw
label = stripNamespaceForLabel(raw)
end
target = trim(target)
label = trim(label)
return target, label
end
local function countArticleLinks(text)
local count = 0
local seen = {}
for raw in (text or ''):gmatch('%[%[([^%]]+)%]%]') do
local target = raw:match('^([^|#]+)') or raw
target = trim(target)
if not isSkippedArticleCountLink(target) and not seen[target] then
seen[target] = true
count = count + 1
end
end
return count
end
-- ============================================================
-- Parse == Index ==
-- ============================================================
local function parseIndex(content)
local indexText = content:match('==%s*Index%s*==%s*(.-)%s*==%s*Full contents%s*==')
if not indexText then
indexText = content:match('==%s*Index%s*==%s*(.*)') or ''
end
local groups = {}
local currentGroup = nil
for line in indexText:gmatch('[^\r\n]+') do
line = trim(line)
if line ~= '' then
local boldContent = line:match("^'''%s*(.-)%s*'''%s*$")
if boldContent then
currentGroup = {
name = trim(boldContent),
links = {}
}
table.insert(groups, currentGroup)
else
for raw in line:gmatch('%[%[([^%]]+)%]%]') do
local target, label = parseWikiLink(raw)
target = target:match('^([^#]+)') or target
target = trim(target)
if currentGroup and not isSkippedLink(target) then
table.insert(currentGroup.links, {
target = target,
label = label
})
end
end
end
end
end
return groups
end
local function isBookGroup(group)
if not group or not group.name then
return false
end
local name = group.name
if name == 'Book II' then return true end
if name == 'Book III' then return true end
if name == 'Book IV' then return true end
if name == 'Quantum Book II' then return true end
if name == 'Quantum Book III' then return true end
if name == 'Quantum Book IV' then return true end
return false
end
local function displayBookGroupName(name)
name = name or ''
name = name:gsub('^Quantum%s+', '')
return name
end
local function renderOrdinaryIndexGroup(group, startNumber)
local out = {}
local n = startNumber or 1
table.insert(out, '<div style="break-inside:avoid; margin-bottom:0.8em;">')
table.insert(out, '<div style="font-weight:bold; margin-bottom:0.25em;">' .. escapeHtml(group.name) .. '</div>')
if #group.links > 0 then
table.insert(out, '<div style="margin-top:0; margin-left:1.2em;">')
for _, link in ipairs(group.links) do
local anchor = makeAnchorId(link.label)
if link.target:match('^Book:') then
table.insert(out, '<div>' .. n .. '. [[' .. link.target .. '|' .. escapeHtml(link.label) .. ']]</div>')
n = n + 1
else
table.insert(out, '<div>' .. n .. '. [[#' .. anchor .. '|' .. escapeHtml(link.label) .. ']]</div>')
n = n + 1
end
end
table.insert(out, '</div>')
end
table.insert(out, '</div>')
return table.concat(out, '\n'), n
end
local function renderBookRow(bookGroups)
local out = {}
table.insert(out, '<div class="noexcerpt" style="margin:1em 0 0.8em 0;">')
table.insert(out, '<div style="display:flex; gap:2em; align-items:flex-start;">')
for _, group in ipairs(bookGroups) do
table.insert(out, '<div style="flex:1; min-width:0;">')
table.insert(out, '<div style="font-weight:bold; margin-bottom:0.25em;">' .. escapeHtml(displayBookGroupName(group.name)) .. '</div>')
if #group.links > 0 then
table.insert(out, '<ul style="margin-top:0;">')
for _, link in ipairs(group.links) do
if link.target:match('^Book:') then
table.insert(out, '<li>[[' .. link.target .. '|' .. escapeHtml(link.label) .. ']]</li>')
else
local anchor = makeAnchorId(link.label)
table.insert(out, '<li>[[#' .. anchor .. '|' .. escapeHtml(link.label) .. ']]</li>')
end
end
table.insert(out, '</ul>')
end
table.insert(out, '</div>')
end
table.insert(out, '</div>')
table.insert(out, '</div>')
return table.concat(out, '\n')
end
local function renderIndex(groups)
if #groups == 0 then
return ''
end
local normalGroups = {}
local bookGroups = {}
for _, group in ipairs(groups) do
if isBookGroup(group) then
table.insert(bookGroups, group)
else
table.insert(normalGroups, group)
end
end
local out = {}
table.insert(out, '= Index =')
table.insert(out, '<div class="noexcerpt" style="column-count:2; column-gap:2em;">')
local indexNumber = 1
for _, group in ipairs(normalGroups) do
local rendered
rendered, indexNumber = renderOrdinaryIndexGroup(group, indexNumber)
table.insert(out, rendered)
end
table.insert(out, '</div>')
if #bookGroups > 0 then
table.insert(out, renderBookRow(bookGroups))
end
return table.concat(out, '\n')
end
-- ============================================================
-- Parse == Full contents ==
-- ============================================================
local function parseFullContents(content)
local full = content:match('==%s*Full contents%s*==%s*(.*)') or ''
local sections = {}
local current = nil
for line in full:gmatch('[^\r\n]+') do
local heading = line:match('^===%s*(.-)%s*===%s*$')
if heading then
current = {
name = trim(heading),
lines = {}
}
table.insert(sections, current)
elseif current then
table.insert(current.lines, line)
end
end
return sections
end
local function forceOrderedList(body, startNumber)
body = body or ''
startNumber = startNumber or 1
-- Extra safety in case includeonly tags survive from copied content.
body = body:gsub('<%s*/?%s*includeonly%s*>', '')
-- If the data page already contains an ordered list, preserve it.
if body:match('<%s*ol[%s>]') then
return body, startNumber
end
local out = {}
local inList = false
local n = startNumber
for line in body:gmatch('([^\r\n]*)\r?\n?') do
if line ~= '' then
local liContent = line:match('^%s*<%s*li%s*>(.-)<%s*/%s*li%s*>%s*$')
if liContent then
if not inList then
table.insert(out, '<div style="margin-top:0; margin-left:1.6em;">')
inList = true
end
table.insert(out, '<div>' .. n .. '. ' .. liContent .. '</div>')
n = n + 1
else
if inList then
table.insert(out, '</div>')
inList = false
end
table.insert(out, line)
end
end
end
if inList then
table.insert(out, '</div>')
end
return table.concat(out, '\n'), n
end
local function renderSectionList(section, collapse, sectionNumber, articleStartNumber)
local body, nextArticleNumber = forceOrderedList(table.concat(section.lines, '\n'), articleStartNumber)
local count = countArticleLinks(body)
local anchor = makeAnchorId(section.name)
local collapsedClass = ''
if collapse then
collapsedClass = ' mw-collapsed'
end
local sectionLabel = section.name
if sectionNumber then
sectionLabel = tostring(sectionNumber) .. '. ' .. sectionLabel
end
local out = {}
table.insert(out, '<div id="' .. anchor .. '" class="mw-collapsible' .. collapsedClass .. '" style="border:1px solid #e0d890; background:#fffdf0; margin:0.8em 0; padding:0.6em;">')
table.insert(out, '<div style="font-weight:bold; margin-bottom:0.4em;">' .. escapeHtml(sectionLabel) .. ' (' .. count .. ') <span style="font-size:85%; font-weight:normal;">[[#Index|↑ Back to index]]</span></div>')
-- IMPORTANT:
-- Full contents is intentionally ONE column and raw.
-- This preserves original list formatting and any images.
table.insert(out, '<div>')
table.insert(out, body)
table.insert(out, '</div>')
table.insert(out, '</div>')
return table.concat(out, '\n'), nextArticleNumber
end
local function renderFullContents(sections)
if #sections == 0 then
return ''
end
local out = {}
local collapse = false
table.insert(out, '= Full contents =')
local articleNumber = 1
for i, section in ipairs(sections) do
local rendered
rendered, articleNumber = renderSectionList(section, collapse, i, articleNumber)
table.insert(out, rendered)
end
return table.concat(out, '\n')
end
local function totalArticleCount(sections)
local n = 0
for _, section in ipairs(sections) do
n = n + countArticleLinks(table.concat(section.lines, '\n'))
end
return n
end
-- ============================================================
-- Public TOC functions
-- ============================================================
function p.tocHeading(frame)
local args = frame.args or {}
local dataPage = args[1] or 'Physics:Quantum basics/See also'
local content = getPageContent(dataPage)
local sections = parseFullContents(content)
local n = totalArticleCount(sections)
return '= Table of contents (' .. n .. ' articles) ='
end
function p.tocHeadingAndList(frame)
local args = frame.args or {}
local dataPage = args[1] or 'Physics:Quantum basics/See also'
local content = getPageContent(dataPage)
local groups = parseIndex(content)
local sections = parseFullContents(content)
local n = totalArticleCount(sections)
local out = {}
table.insert(out, '= Table of contents (' .. n .. ' articles) =')
table.insert(out, renderIndex(groups))
table.insert(out, renderFullContents(sections))
return table.concat(out, '\n\n')
end
-- ============================================================
-- Child-book wrappers
-- ============================================================
function p.qm(frame)
frame = frame or {}
frame.args = frame.args or {}
frame.args[1] = frame.args[1] or 'Physics:Quantum basics/See also/Matter'
return p.tocHeadingAndList(frame)
end
function p.qt(frame)
frame = frame or {}
frame.args = frame.args or {}
frame.args[1] = frame.args[1] or 'Physics:Quantum basics/See also/Methods'
return p.tocHeadingAndList(frame)
end
function p.qd(frame)
frame = frame or {}
frame.args = frame.args or {}
frame.args[1] = frame.args[1] or 'Physics:Quantum basics/See also/Data Analysis'
return p.tocHeadingAndList(frame)
end
-- ============================================================
-- Gallery helpers
-- ============================================================
local function extractPagesForGallery(content)
local pages = {}
local seen = {}
local full = content:match('==%s*Full contents%s*==%s*(.*)') or content
for raw in full:gmatch('%[%[([^%]]+)%]%]') do
local target = raw:match('^([^|#]+)') or raw
target = trim(target)
if not isSkippedLink(target) and not target:match('^Book:') and not seen[target] then
seen[target] = true
table.insert(pages, target)
end
end
return pages
end
local function firstFileInArticle(page)
local text = getPageContent(page)
if text == '' then
return nil
end
local file = text:match('%[%[%s*[Ff]ile%s*:%s*([^%]|%]]+)')
if not file then
file = text:match('%[%[%s*[Ii]mage%s*:%s*([^%]|%]]+)')
end
if file then
file = trim(file)
file = file:gsub('|.*$', '')
file = trim(file)
end
return file
end
local function normalizeGalleryWidth(value)
local width = tonumber(value) or 150
if width < 40 then
width = 40
elseif width > 400 then
width = 400
end
return math.floor(width)
end
local function renderGalleryCard(page, file, width)
local label = stripNamespaceForLabel(page)
local out = {}
table.insert(out, '<div style="display:inline-block; vertical-align:top; width:180px; margin:6px; padding:5px; border:1px solid #e0d890; background:#fff8cc; text-align:center;">')
if file then
table.insert(out, '[[File:' .. file .. '|' .. width .. 'px]]')
else
table.insert(out, '<div style="height:120px; display:flex; align-items:center; justify-content:center; border:1px dashed #c0a850; background:#fffdf0; color:#900; font-size:90%;">No image found</div>')
end
table.insert(out, '<div style="font-size:90%; line-height:1.25; margin-top:4px;">[[' .. page .. '|' .. escapeHtml(label) .. ']]</div>')
table.insert(out, '</div>')
return table.concat(out, '\n')
end
-- ============================================================
-- Public gallery function
-- ============================================================
function p.gallery(frame)
local args = frame.args or {}
local dataPage = args[1] or 'Physics:Quantum basics/See also'
local width = normalizeGalleryWidth(args.width or args[2])
local content = getPageContent(dataPage)
local pages = extractPagesForGallery(content)
local out = {}
local imageCount = 0
local missingCount = 0
local pageCount = #pages
table.insert(out, '<div class="noexcerpt" style="max-width:100%; clear:both;">')
for _, page in ipairs(pages) do
local file = firstFileInArticle(page)
if file then
imageCount = imageCount + 1
else
missingCount = missingCount + 1
end
table.insert(out, renderGalleryCard(page, file, width))
end
table.insert(out, '</div>')
local heading = '== Gallery (' .. imageCount .. ' images, ' .. missingCount .. ' missing, ' .. pageCount .. ' pages) =='
return heading .. '\n' .. table.concat(out, '\n')
end
return p