模組:Section sizes
外观
--- Builds sortable section-size tables for wiki pages.
---
--- Public functions:
--- * size(frame): renders byte and prose-size heat maps.
--- * section_size_get(frame): returns one section-size value.
---
--- Internal helpers parse headings, count prose, select byte heat-map
--- values, compute hierarchical totals, format section numbers, and render
--- the final table with mw.html.
require("strict")
local lang_obj = mw.language.getContentLanguage()
local strf = mw.ustring.format
local yesno = require("Module:Yesno")
local appendix_patterns = {
"Notes",
"[腳脚][註注]",
"[註注][釋释]",
"[備备][註注]",
"[註注]解",
"附[註注]",
"References",
"[參参]考",
"[參参]考[資资]料",
"[參参]考文[獻献]",
"[參参]考[來来]源",
"文[獻献]",
"引用",
"[來来]源",
"See also",
"[參参][見见]",
"[參参][閱阅]",
"另[見见]",
"External links",
"外部[連连][結结]",
"外部[鏈链]接",
"Further reading",
"[擴扩]展[閱阅][讀读]",
"延伸[閱阅][讀读]",
"[進进]一步[閱阅][讀读]",
}
local i18n_t = {
["byte_count"] = "字节数",
["appendix_ignored"] = "附錄章節原始碼不計入比例",
["byte_title"] = "占原始碼總長度的%.1f%%",
["appendix_title"] = "占原始碼總長度的%.1f%%(不計附錄章節)",
["ldr_title"] = "占原始碼總長度的%.1f%%(不計參考列表)",
["error_category"] = "[[Category:有$1错误的页面]]",
["article_error"] = "错误:页面$1未創建",
["sections_error"] = "错误:页面$1未分章节",
["redirect_error"] = "错误:页面$1是重定向",
["help_link"] = "([[$1|帮助]])",
["markup_removed"] = "标记已移除,无法链接",
["prose_title"] = "占总字数的%.1f%%",
["prose_size"] = table.concat({
'<abbr title="不含列表、模板中的文字,',
'西文单词以两字计,粗略值">正文字数</abbr>',
}),
["section_header"] = table.concat({
'<abbr title="不包括該章節下的子章節">',
"本章</abbr>",
}),
["section_name"] = "章节标题",
["section_total"] = "合计",
["table_caption"] = "「[[$1]]」各章节大小(共含$2节)",
["top"] = nil,
["total"] = "总计",
}
local section_top = i18n_t.top or mw.message
.new("vector-toc-beginning")
:inLanguage(lang_obj:getCode())
:plain()
--- Returns one invoke argument or its parent-template fallback.
local function frame_arg_get(frame, name)
local value = frame.args[name]
if nil ~= value and "" ~= value then
return value
end
local parent = frame:getParent()
if not parent then
return nil
end
value = parent.args[name]
if "" == value then
return nil
end
return value
end
--- Returns a parsed yes/no argument with an explicit default.
local function yesno_arg_get(frame, name, default)
local value = yesno(frame_arg_get(frame, name))
if nil == value then
return default
end
return value
end
local bytes_heatmap_modes = {
["all"] = true,
["ignore_appendices"] = true,
["ignore_ldr"] = true,
}
--- Returns the selected byte heat-map mode.
local function bytes_heatmap_mode_get(frame)
local value = frame_arg_get(frame, "bytes_heatmap")
value = value and string.lower(mw.text.trim(value)) or "all"
if bytes_heatmap_modes[value] then
return value
end
return "all"
end
local protected_tags = {
"math",
"nowiki",
"pre",
"ref",
"source",
"syntaxhighlight",
}
local heatmap_base = {0xF8, 0xF9, 0xFA}
local byte_heatmap_target = {0xD7, 0x7C, 0x7C}
local byte_heatmap_max = {0x9B, 0x00, 0x00}
local prose_heatmap_target = {0x73, 0xA2, 0x73}
local prose_heatmap_max = {0x00, 0x64, 0x00}
local max_heading_level = 7
local err_cat_added
local stripmarker_pattern =
"\127[^\127]*UNIQ%-%-%a+%-[%x]+%-QINU[^\127]*\127"
--- Returns a Unicode character-range fragment.
local function u_range(first_code, last_code)
return mw.ustring.char(first_code) .. "-" ..
mw.ustring.char(last_code)
end
local han_class = "[" ..
u_range(0x3400, 0x4DBF) ..
u_range(0x4E00, 0x9FFF) ..
u_range(0xF900, 0xFAFF) ..
"]"
local kana_class = "[" ..
u_range(0x3040, 0x309F) ..
u_range(0x30A0, 0x30FF) ..
"]"
local punct_class = "[" ..
"%p" ..
u_range(0x2000, 0x206F) ..
u_range(0x2E00, 0x2E7F) ..
u_range(0x3000, 0x303F) ..
u_range(0xFF00, 0xFF65) ..
u_range(0xFE10, 0xFE6F) ..
mw.ustring.char(0x00B7) ..
mw.ustring.char(0x30FB) ..
"]"
--- Returns true when a heading is excluded from prose counting.
local function section_noprose(section_name)
local trimmed = mw.text.trim(section_name)
for _, pattern in ipairs(appendix_patterns) do
if mw.ustring.match(trimmed, pattern) == trimmed then
return true
end
end
return false
end
--- Counts readable characters in mixed CJK and non-CJK prose.
local function charcount_mixed(text)
local value = mw.ustring.gsub(text, punct_class, " ")
value = mw.ustring.gsub(value, "%s+", " ")
value = mw.text.trim(value)
local _, han_count = mw.ustring.gsub(value, han_class, "")
local _, kana_count = mw.ustring.gsub(value, kana_class, "")
value = mw.ustring.gsub(value, han_class, " ")
value = mw.ustring.gsub(value, kana_class, " ")
value = mw.ustring.gsub(value, "%s+", " ")
value = mw.text.trim(value)
local _, word_count = value:gsub("%S+", "")
return han_count + kana_count * 0.5 + word_count * 2
end
--- Removes nested templates from prose-counting text.
local function templates_remove(text)
text = text:gsub("([^{]){([^{])", "%1%2")
text = text:gsub("([^}])}([^}])", "%1%2")
local removed = 1
while 0 < removed do
text, removed = text:gsub("{{[^{}]-}}", "")
end
return text
end
--- Removes list lines from prose-counting text.
local function list_lines_remove(text)
local lines = {}
for line in text:gmatch("[^\n]+") do
if not line:match("^%s*[%*#;:]") then
table.insert(lines, line)
end
end
return table.concat(lines, "\n")
end
--- Cleans wikitext before prose-character counting.
local function prose_text_clean(wikitext)
local text = wikitext:gsub("<!%-%-.-%-%->", "")
text = text:gsub("<ref[^>]-/>", "")
text = text:gsub("<ref.->.-</ref>", "")
text = text:gsub("{|.-|}", "")
text = templates_remove(text)
text = text:gsub("%[%[(File|Image):.-%]%]", "")
text = text:gsub("%[%[[^|%]]-|", "[[")
text = text:gsub("%[%[(.-)%]%]", "%1")
text = text:gsub("<.->", "")
text = text:gsub("=+.-=+", "")
text = text:gsub("''+", "")
text = list_lines_remove(text)
text = text:gsub("%s+", " ")
return text:match("^%s*(.-)%s*$")
end
--- Returns cleaned byte length and readable-character count.
local function prose_size_get(wikitext)
local text = prose_text_clean(wikitext)
return #text, charcount_mixed(text)
end
--- Finds the closing angle bracket of one HTML tag.
local function tag_end_find(text, start_pos)
local quote
for index = start_pos, #text do
local char = text:sub(index, index)
if quote and char == quote then
quote = nil
elseif not quote and ('"' == char or "'" == char) then
quote = char
elseif not quote and ">" == char then
return index
end
end
return nil
end
--- Tests whether one tag name begins at a byte position.
local function tag_name_at(lower_text, start_pos, tag_name)
local prefix = "<" .. tag_name
local last_pos = start_pos + #prefix - 1
if lower_text:sub(start_pos, last_pos) ~= prefix then
return false
end
local following = lower_text:sub(last_pos + 1, last_pos + 1)
return "" == following or following:match("[%s/>]") ~= nil
end
--- Finds the end of one matching closing tag.
local function closing_tag_end_find(text, lower_text, start_pos, tag_name)
local prefix = "</" .. tag_name
local search_pos = start_pos
while true do
local open_pos = lower_text:find(prefix, search_pos, true)
if not open_pos then
return nil
end
local following = lower_text:sub(
open_pos + #prefix,
open_pos + #prefix
)
if "" == following or following:match("[%s>]") then
return tag_end_find(text, open_pos)
end
search_pos = open_pos + 1
end
end
--- Finds the end of a protected HTML-like block.
local function protected_block_end_find(text, lower_text, start_pos)
for _, tag_name in ipairs(protected_tags) do
if tag_name_at(lower_text, start_pos, tag_name) then
local open_end = tag_end_find(text, start_pos)
if not open_end then
return #text
end
local opening = text:sub(start_pos, open_end - 1)
if opening:match("/%s*$") then
return open_end
end
return closing_tag_end_find(
text,
lower_text,
open_end + 1,
tag_name
) or #text
end
end
return nil
end
--- Replaces protected ranges with spaces of equal byte length.
local function mask_range_append(chunks, text, first_pos, last_pos)
table.insert(chunks, text:sub(first_pos[1], first_pos[2]))
table.insert(chunks, string.rep(" ", last_pos[2] - last_pos[1] + 1))
end
--- Returns masked wikitext for safe template scanning.
local function wikitext_mask_get(text)
local lower_text = string.lower(text)
local chunks = {}
local chunk_start = 1
local index = 1
while index <= #text do
local range_end
if "<!--" == text:sub(index, index + 3) then
local close_pos = text:find("-->", index + 4, true)
range_end = close_pos and close_pos + 2 or #text
elseif "<" == text:sub(index, index) then
range_end = protected_block_end_find(text, lower_text, index)
end
if range_end then
mask_range_append(
chunks,
text,
{chunk_start, index - 1},
{index, range_end}
)
index = range_end + 1
chunk_start = index
else
index = index + 1
end
end
table.insert(chunks, text:sub(chunk_start))
return table.concat(chunks)
end
--- Normalizes a template name for comparison.
local function template_name_normalize(name)
local namespace_name = mw.site.namespaces[10].name or "Template"
name = name:gsub("_", " ")
name = mw.text.trim(name)
name = name:gsub("%s+", " ")
name = name:gsub("^:%s*", "")
name = string.lower(name)
local prefixes = {
"template:",
string.lower(namespace_name) .. ":",
}
for _, prefix in ipairs(prefixes) do
if prefix == name:sub(1, #prefix) then
return mw.text.trim(name:sub(#prefix + 1))
end
end
return name
end
--- Reads the name of a template call.
local function template_name_get(text, start_pos)
local index = start_pos + 2
while index <= #text do
local char = text:sub(index, index)
local pair = text:sub(index, index + 1)
if "|" == char or "}" == char or "{{" == pair then
break
end
index = index + 1
end
return template_name_normalize(
text:sub(start_pos + 2, index - 1)
)
end
--- Pushes one template or parameter frame onto a scanner stack.
local function template_stack_push(stack, text, index, kind)
local item = {
kind = kind,
start = index,
}
if "template" == kind then
item.is_reflist = "reflist" == template_name_get(text, index)
end
table.insert(stack, item)
end
--- Closes one template frame and records Reflist ranges.
local function template_stack_close(stack, ranges, index)
local item = table.remove(stack)
if item and item.is_reflist then
table.insert(ranges, {item.start, index + 1})
end
end
--- Returns balanced ranges occupied by Reflist templates.
local function reflist_ranges_get(text)
local ranges = {}
local stack = {}
local index = 1
while index <= #text do
local triple = text:sub(index, index + 2)
local pair = text:sub(index, index + 1)
local top = stack[#stack]
if "{{{" == triple then
template_stack_push(stack, text, index, "parameter")
index = index + 3
elseif "{{" == pair then
template_stack_push(stack, text, index, "template")
index = index + 2
elseif top and "parameter" == top.kind and "}}}" == triple then
table.remove(stack)
index = index + 3
elseif top and "template" == top.kind and "}}" == pair then
template_stack_close(stack, ranges, index)
index = index + 2
else
index = index + 1
end
end
return ranges
end
--- Records one references-tag range and returns the next position.
local function references_range_add(text, lower_text, ranges, start_pos)
local open_end = tag_end_find(text, start_pos)
if not open_end then
return #text + 1
end
local opening = text:sub(start_pos, open_end - 1)
if opening:match("/%s*$") then
table.insert(ranges, {start_pos, open_end})
return open_end + 1
end
local close_end = closing_tag_end_find(
text,
lower_text,
open_end + 1,
"references"
)
if not close_end then
return open_end + 1
end
table.insert(ranges, {start_pos, close_end})
return close_end + 1
end
--- Returns ranges occupied by references tags.
local function references_ranges_get(text)
local ranges = {}
local lower_text = string.lower(text)
local search_pos = 1
while true do
local start_pos = lower_text:find(
"<references",
search_pos,
true
)
if not start_pos then
return ranges
end
if tag_name_at(lower_text, start_pos, "references") then
search_pos = references_range_add(
text,
lower_text,
ranges,
start_pos
)
else
search_pos = start_pos + 1
end
end
end
--- Sorts ranges by start and then end position.
local function ranges_sort(ranges)
table.sort(ranges, function(left, right)
if left[1] == right[1] then
return left[2] < right[2]
end
return left[1] < right[1]
end)
end
--- Returns the byte length covered by merged ranges.
local function ranges_size_get(ranges)
if 0 == #ranges then
return 0
end
ranges_sort(ranges)
local total = 0
local first_pos = ranges[1][1]
local last_pos = ranges[1][2]
for index = 2, #ranges do
local current = ranges[index]
if current[1] <= last_pos + 1 then
last_pos = math.max(last_pos, current[2])
else
total = total + last_pos - first_pos + 1
first_pos = current[1]
last_pos = current[2]
end
end
return total + last_pos - first_pos + 1
end
--- Returns the byte length of all LDR containers.
local function ldr_size_get(wikitext)
local masked = wikitext_mask_get(wikitext)
local ranges = reflist_ranges_get(masked)
for _, range in ipairs(references_ranges_get(masked)) do
table.insert(ranges, range)
end
return ranges_size_get(ranges)
end
--- Returns the byte value used by the selected heat-map mode.
local function heatmap_size_get(wikitext, mode)
if "ignore_ldr" ~= mode then
return #wikitext
end
return math.max(0, #wikitext - ldr_size_get(wikitext))
end
--- Converts one sRGB channel to relative luminance.
local function luminance_channel(value)
value = value / 255
if value <= 0.04045 then
return value / 12.92
end
return ((value + 0.055) / 1.055) ^ 2.4
end
--- Returns contrast against white for one RGB triplet.
local function white_contrast_get(rgb)
local luminance = 0.2126 * luminance_channel(rgb[1])
luminance = luminance + 0.7152 * luminance_channel(rgb[2])
luminance = luminance + 0.0722 * luminance_channel(rgb[3])
return 1.05 / (luminance + 0.05)
end
--- Interpolates one heat-map RGB color.
local function heatmap_rgb_get(proportion, target)
local intensity = 0
if 0 < proportion then
intensity = math.log(1 + 99 * proportion) / math.log(100)
end
local rgb = {}
for index = 1, 3 do
local difference = target[index] - heatmap_base[index]
rgb[index] = math.floor(
heatmap_base[index] + difference * intensity + 0.5
)
end
return rgb
end
--- Returns a CSS rgb() color string.
local function css_rgb_get(rgb)
return strf("rgb(%d, %d, %d)", rgb[1], rgb[2], rgb[3])
end
--- Returns one heat-map cell color pair.
local function heatmap_colors_get(value, total, palette, threshold)
local proportion = 0 < total and value / total or 0
local is_long = proportion > threshold
local rgb = is_long and palette.maximum or
heatmap_rgb_get(proportion, palette.target)
local text_color = "rgb(0, 0, 0)"
if is_long and 7 <= white_contrast_get(rgb) then
text_color = "rgb(255, 255, 255)"
end
return {
background = css_rgb_get(rgb),
text = text_color,
}
end
local byte_palette = {
maximum = byte_heatmap_max,
target = byte_heatmap_target,
}
local prose_palette = {
maximum = prose_heatmap_max,
target = prose_heatmap_target,
}
--- Returns one percentage tooltip or nil.
local function heatmap_title_get(value, total, format_text)
if 0 >= value or 0 >= total then
return nil
end
return strf(format_text, 100 * value / total)
end
--- Returns one byte heat-map cell tooltip.
local function byte_heatmap_title_get(value, total, mode, ignored)
if ignored then
return i18n_t["appendix_ignored"]
end
local keys = {
["all"] = "byte_title",
["ignore_appendices"] = "appendix_title",
["ignore_ldr"] = "ldr_title",
}
return heatmap_title_get(value, total, i18n_t[keys[mode]])
end
--- Returns a localized message with parameters substituted.
local function raw_message_get(message, args)
return mw.message.newRawMessage(message, args):plain()
end
--- Returns the error-category link when it should be emitted.
local function error_category_get(template_name)
if err_cat_added or not i18n_t["error_category"] then
return ""
end
if 1 ~= mw.title.getCurrentTitle().namespace then
return ""
end
err_cat_added = true
return raw_message_get(
i18n_t["error_category"],
{template_name}
)
end
--- Builds one fatal or nonfatal error message.
local function error_msg_make(template_name, message, args, fatal)
local node = mw.html.create("span")
if fatal then
node:addClass("error")
node:css("font-size", "100%")
else
node:css("color", "#d33")
end
node:wikitext(raw_message_get(message, args))
node:wikitext(" ")
node:wikitext(raw_message_get(
i18n_t["help_link"],
{template_name}
))
local prefix = fatal and "" or " "
return prefix .. tostring(node) ..
error_category_get(template_name)
end
--- Returns article content or a localized fatal error.
local function article_content_get(article, template_name)
local title_obj = mw.title.new(article)
if not title_obj then
return nil, error_msg_make(
template_name,
i18n_t["article_error"],
{article},
true
)
end
local content = title_obj:getContent()
if not content then
return nil, error_msg_make(
template_name,
i18n_t["article_error"],
{article},
true
)
end
if title_obj.isRedirect then
return nil, error_msg_make(
template_name,
i18n_t["redirect_error"],
{article},
true
)
end
return content, nil
end
--- Returns heading level from one heading start.
local function heading_level_get(content, start_pos)
local marks = content:sub(start_pos):match("^(=+)")
return marks and #marks or 2
end
--- Finds all heading positions and names.
local function headings_find(content)
local headings = {}
local search_pos = 1
while true do
local open_pos, end_pos, name = content:find(
"\n==+ *(.-) *==+",
search_pos
)
if not open_pos then
return headings
end
local start_pos = open_pos + 1
table.insert(headings, {
level = heading_level_get(content, start_pos),
name = name,
start = start_pos,
})
search_pos = end_pos + 1
end
end
--- Returns one section body by heading index.
local function section_content_get(content, headings, index)
local current = headings[index]
local following = headings[index + 1]
if following then
return content:sub(current.start, following.start - 1)
end
return content:sub(current.start)
end
--- Creates the lead-section record.
local function lead_section_make(
content,
first_heading,
get_prose,
bytes_heatmap
)
local lead_content = content:sub(1, first_heading.start - 1)
local prose_size = 0
if get_prose then
prose_size = select(2, prose_size_get(lead_content))
end
return {
content = lead_content,
heat_size = heatmap_size_get(lead_content, bytes_heatmap),
level = 2,
name = section_top,
no_prose = false,
prose_size = prose_size,
size = #lead_content,
start = 1,
}
end
--- Creates one named-section record.
local function named_section_make(
content,
heading,
get_prose,
bytes_heatmap
)
local no_prose = section_noprose(heading.name)
local prose_size = 0
if get_prose and not no_prose then
prose_size = select(2, prose_size_get(content))
end
return {
content = content,
heat_size = heatmap_size_get(content, bytes_heatmap),
level = heading.level,
name = heading.name,
no_prose = no_prose,
prose_size = prose_size,
size = #content,
start = heading.start,
}
end
--- Propagates no-prose status to descendant sections.
local function noprose_inheritance_set(sections)
local excluded_level
for index = 2, #sections do
local section = sections[index]
if excluded_level and section.level <= excluded_level then
excluded_level = nil
end
if excluded_level then
section.no_prose = true
section.prose_size = 0
elseif section.no_prose then
excluded_level = section.level
end
end
end
--- Excludes appendix sections from byte heat-map colors.
local function appendix_heat_exclusions_set(sections, mode)
if "ignore_appendices" ~= mode then
return
end
for _, section in ipairs(sections) do
if section.no_prose then
section.byte_heat_ignored = true
section.heat_size = 0
end
end
end
--- Builds all section records from article wikitext.
local function sections_build(content, get_prose, bytes_heatmap)
local headings = headings_find(content)
if 0 == #headings then
return nil
end
local sections = {
lead_section_make(
content,
headings[1],
get_prose,
bytes_heatmap
),
}
for index, heading in ipairs(headings) do
local section_content = section_content_get(
content,
headings,
index
)
table.insert(sections, named_section_make(
section_content,
heading,
get_prose,
bytes_heatmap
))
end
noprose_inheritance_set(sections)
appendix_heat_exclusions_set(sections, bytes_heatmap)
return sections
end
--- Adds one numeric field across all sections.
local function sections_field_sum(sections, field)
local total = 0
for _, section in ipairs(sections) do
total = total + (section[field] or 0)
end
return total
end
--- Returns the largest value of one numeric section field.
local function sections_field_largest(sections, field)
local largest_value = 0
for _, section in ipairs(sections) do
largest_value = math.max(
largest_value,
section[field] or 0
)
end
return largest_value
end
--- Returns true when all sections are level two.
local function sections_are_flat(sections)
for _, section in ipairs(sections) do
if 2 < section.level then
return false
end
end
return true
end
--- Returns section-number text for one hierarchy level.
local function section_number_get(counts, level)
local parts = {}
for current = 2, level do
table.insert(parts, tostring(counts[current] or 0))
end
return table.concat(parts, ".")
end
--- Assigns visible and sortable section numbers.
local function section_numbers_set(sections)
local counts = {}
sections[1].number = "0"
sections[1].sort_index = 0
for index = 2, #sections do
local level = sections[index].level
counts[level] = (counts[level] or 0) + 1
for deeper = level + 1, max_heading_level do
counts[deeper] = nil
end
sections[index].number = section_number_get(counts, level)
sections[index].sort_index = index - 1
end
end
--- Creates zero-filled hierarchy accumulators.
local function level_sums_new()
local sums = {
heat_size = {},
prose_size = {},
size = {},
}
for level = 1, max_heading_level do
sums.heat_size[level] = 0
sums.prose_size[level] = 0
sums.size[level] = 0
end
return sums
end
--- Clears hierarchy accumulators from one level downward.
local function level_sums_clear(sums, first_level)
for level = first_level, max_heading_level do
sums.heat_size[level] = 0
sums.prose_size[level] = 0
sums.size[level] = 0
end
end
--- Sets totals for one section from current accumulators.
local function section_totals_set(section, sums, last_level)
local level = section.level
if level < last_level then
section.total_size = sums.size[level] + section.size
section.total_heat_size = sums.heat_size[level] +
section.heat_size
section.total_prose_size = sums.prose_size[level] +
section.prose_size
level_sums_clear(sums, level)
return
end
section.total_size = section.size
section.total_heat_size = section.heat_size
section.total_prose_size = section.prose_size
end
--- Adds one section total to its parent-level accumulator.
local function section_totals_accumulate(section, sums)
if 1 >= section.level then
return
end
local parent = section.level - 1
sums.size[parent] = sums.size[parent] + section.total_size
sums.heat_size[parent] = sums.heat_size[parent] +
section.total_heat_size
sums.prose_size[parent] = sums.prose_size[parent] +
section.total_prose_size
end
--- Computes hierarchical totals for every section.
local function section_totals_compute(sections)
local sums = level_sums_new()
local last_level = 0
for index = #sections, 1, -1 do
local section = sections[index]
section_totals_set(section, sums, last_level)
section_totals_accumulate(section, sums)
last_level = section.level
end
end
--- Counts level-two sections, optionally excluding no-prose ones.
local function h2_count_get(sections, exclude_no_prose)
local count = 0
for _, section in ipairs(sections) do
local include = 2 == section.level
if include and exclude_no_prose then
include = not section.no_prose
end
if include then
count = count + 1
end
end
return count
end
--- Returns the shared white-text threshold for byte cells.
local function byte_threshold_get(sections, mode)
local exclude_appendices = "ignore_appendices" == mode
local count = h2_count_get(sections, exclude_appendices)
if "ignore_ldr" == mode then
count = math.max(1, count - 1)
end
if 0 == count then
return math.huge
end
return 1.5 / count
end
--- Returns the shared white-text threshold for prose cells.
local function prose_threshold_get(sections)
local count = h2_count_get(sections, true)
if 0 == count then
return math.huge
end
return 1.5 / count
end
--- Removes red-linked template names from a rendered heading.
local function redlink_template_remove(section_name)
local pattern = "%[%[:" ..
mw.site.namespaces[10].name .. ":.-%]%]"
local count
section_name, count = section_name:gsub(pattern, "[...]")
return section_name, 0 < count
end
--- Removes allowed anchor spans from a rendered heading.
local function anchors_remove(section_name)
local patterns = {
'<span +[^>]*class *= *"anchor"[^>]*></span>',
'<span +%f[%a]id *= *".-" *></span>',
}
for _, pattern in ipairs(patterns) do
section_name = section_name:gsub(pattern, "")
end
return section_name
end
--- Removes reference markup from one section heading.
local function refs_remove(section_name)
local name, first_count = section_name:gsub("<ref.-/>", "")
local second_count
name, second_count = name:gsub("<ref.->.-</ref>", "")
return name, 0 < first_count or 0 < second_count
end
--- Removes parser strip markers from one rendered heading.
local function stripmarkers_remove(section_name)
local count
section_name, count = section_name:gsub(stripmarker_pattern, "")
return section_name, 0 < count
end
--- Returns display text from one wikilink.
local function remove_wiki_link(text)
return text:gsub("%[%[([^%[%]]*)%]%]", function(link)
return link:gsub("^[^|]*|(.*)$", "%1")
:gsub("^%s*(.-)%s*$", "%1")
end)
end
--- Removes HTML-like containers from one rendered heading.
local function remove_container(text)
local count
text, count = text:gsub("<([^>]*)>", function(value)
return value:gsub("^%s*(.-)%s*$", "")
end)
return text, 0 < count
end
--- Returns a wikilink or plain display text.
local function make_wikilink(link, display)
if not link or "" == link then
return display or ""
end
if display and "" ~= display then
return strf("[[%s|%s]]", link, display)
end
return strf("[[%s]]", link)
end
--- Preprocesses one section heading for display and linking.
local function heading_preprocess(frame, section_name)
local value, modified = refs_remove(section_name)
value = remove_wiki_link(value)
value = value:gsub("<nowiki/>", "__sss_nowiki/__")
value = frame:preprocess(value)
local changed
value, changed = redlink_template_remove(value)
modified = modified or changed
value = anchors_remove(value)
value = value:gsub("__sss_nowiki/__", "<nowiki/>")
value, changed = stripmarkers_remove(value)
modified = modified or changed
value = value:gsub("</?i>", "''")
value = value:gsub("</?b>", "'''")
value, changed = remove_container(value)
return value, modified or changed
end
--- Returns a safe section heading display string.
local function heading_display_get(frame, section, template_name, article)
local value, modified = heading_preprocess(frame, section.name)
value = value:gsub("[%[%]]", {
["["] = "[",
["]"] = "]",
})
value = mw.text.trim(value)
if not modified then
local anchor = value:gsub("''+", "")
return make_wikilink(article .. "#" .. anchor, value)
end
return value .. error_msg_make(
template_name,
i18n_t["markup_removed"],
{},
false
)
end
--- Appends the visible section number to a heading cell.
local function section_number_add(parent, section)
local number = parent:tag("span")
number:attr("aria-hidden", "true")
number:css("color", "#72777d")
number:css("font-size", "86%")
number:css("-moz-user-select", "none")
number:css("-webkit-user-select", "none")
number:css("user-select", "none")
number:wikitext(strf("§%s", section.number))
parent:wikitext(" ")
end
--- Appends one rendered heading to a table header or cell.
local function section_heading_add(cell, section, display, flat)
local parent = cell
if not flat and 2 < section.level then
parent = cell:tag("span")
local indent = (section.level - 2) * 1.6
parent:css("margin-left", strf("%.1fem", indent))
end
section_number_add(parent, section)
parent:wikitext(display)
end
--- Applies heat-map colors to one cell.
local function heatmap_colors_apply(cell, spec)
if spec.hidden then
cell:css("color", "transparent")
return
end
cell:css("background-color", spec.colors.background)
cell:css("color", spec.colors.text)
end
--- Adds one percentage heat-map cell.
local function heatmap_cell_add(row, spec)
local cell = row:tag("td")
cell:css("text-align", "right")
heatmap_colors_apply(cell, spec)
if spec.bold then
cell:css("font-weight", "bold")
end
if spec.title then
cell:attr("title", spec.title)
end
if spec.sort_value ~= nil then
cell:attr("data-sort-value", tostring(spec.sort_value))
end
cell:wikitext(spec.display)
return cell
end
--- Returns one byte heat-map cell specification.
local function byte_cell_spec(
value,
total,
threshold,
mode,
ignored
)
return {
colors = heatmap_colors_get(value, total, byte_palette, threshold),
display = lang_obj:formatNum(value),
title = byte_heatmap_title_get(
value,
total,
mode,
ignored
),
}
end
--- Returns one prose heat-map cell specification.
local function prose_cell_spec(value, total, threshold, no_prose)
return {
colors = heatmap_colors_get(value, total, prose_palette, threshold),
display = lang_obj:formatNum(value),
sort_value = no_prose and 0 or nil,
title = heatmap_title_get(
value,
total,
i18n_t["prose_title"]
),
}
end
--- Returns one hidden total-cell specification.
local function hidden_total_spec(spec)
spec.hidden = true
return spec
end
--- Returns whether one total is excluded from byte colors.
local function total_byte_heat_ignored(section, mode)
if "ignore_appendices" ~= mode then
return false
end
return 0 == section.total_heat_size and 0 < section.total_size
end
--- Returns one section-row rendering context.
local function row_context_get(context, section)
return {
bytes_heatmap = context.bytes_heatmap,
byte_threshold = context.byte_threshold,
prose_threshold = context.prose_threshold,
total_heat = context.total_heat,
total_prose = context.total_prose,
get_prose = context.get_prose,
flat = context.flat,
section = section,
}
end
--- Adds the section-title cell for one row.
local function section_title_cell_add(row, context, display)
local cell = row:tag("td")
cell:css("text-align", "left")
cell:attr("data-sort-value", context.section.sort_index)
section_heading_add(
cell,
context.section,
display,
context.flat
)
end
--- Adds one byte cell for a flat table row.
local function flat_byte_cell_add(row, context)
local section = context.section
local spec = byte_cell_spec(
section.heat_size,
context.total_heat,
context.byte_threshold,
context.bytes_heatmap,
section.byte_heat_ignored
)
spec.display = lang_obj:formatNum(section.size)
heatmap_cell_add(row, spec)
end
--- Adds one prose cell for a flat table row.
local function flat_prose_cell_add(row, context)
if not context.get_prose then
return
end
local section = context.section
local spec = prose_cell_spec(
section.prose_size,
context.total_prose,
context.prose_threshold,
section.no_prose
)
heatmap_cell_add(row, spec)
end
--- Adds the byte cells for a hierarchical table row.
local function grouped_byte_cells_add(row, context)
local section = context.section
local own = byte_cell_spec(
section.heat_size,
context.total_heat,
context.byte_threshold,
context.bytes_heatmap,
section.byte_heat_ignored
)
own.display = lang_obj:formatNum(section.size)
heatmap_cell_add(row, own)
local total = byte_cell_spec(
section.total_heat_size,
context.total_heat,
context.byte_threshold,
context.bytes_heatmap,
total_byte_heat_ignored(section, context.bytes_heatmap)
)
total.display = lang_obj:formatNum(section.total_size)
if 2 == section.level then
total.bold = true
elseif section.total_size == section.size then
total = hidden_total_spec(total)
end
heatmap_cell_add(row, total)
end
--- Adds the prose cells for a hierarchical table row.
local function grouped_prose_cells_add(row, context)
if not context.get_prose then
return
end
local section = context.section
local own = prose_cell_spec(
section.prose_size,
context.total_prose,
context.prose_threshold,
section.no_prose
)
heatmap_cell_add(row, own)
local total = prose_cell_spec(
section.total_prose_size,
context.total_prose,
context.prose_threshold,
section.no_prose
)
if 2 == section.level then
total.bold = true
elseif section.total_prose_size == section.prose_size then
total = hidden_total_spec(total)
end
heatmap_cell_add(row, total)
end
--- Adds one body row to the table.
local function body_row_add(table_node, context, display)
local row = table_node:tag("tr")
section_title_cell_add(row, context, display)
if context.flat then
flat_byte_cell_add(row, context)
flat_prose_cell_add(row, context)
return
end
grouped_byte_cells_add(row, context)
grouped_prose_cells_add(row, context)
end
--- Adds a header cell with optional attributes.
local function header_cell_add(row, text, attrs, plain)
local cell = row:tag("th")
if attrs then
cell:attr(attrs)
end
if plain then
cell:css("font-weight", "normal")
end
cell:css("text-align", "center")
cell:wikitext(text)
return cell
end
--- Adds the single-row header used for flat articles.
local function flat_header_add(table_node, get_prose)
local row = table_node:tag("tr")
header_cell_add(row, i18n_t.section_name)
header_cell_add(row, i18n_t.byte_count)
if get_prose then
header_cell_add(row, i18n_t.prose_size)
end
end
--- Adds the first grouped-header row.
local function grouped_header_top_add(table_node, get_prose)
local row = table_node:tag("tr")
header_cell_add(row, i18n_t.section_name, {
rowspan = "2",
})
header_cell_add(row, i18n_t.byte_count, {
colspan = "2",
})
if get_prose then
header_cell_add(row, i18n_t.prose_size, {
colspan = "2",
})
end
end
--- Adds the second grouped-header row.
local function grouped_header_bottom_add(table_node, get_prose)
local row = table_node:tag("tr")
header_cell_add(row, i18n_t.section_header, nil, true)
header_cell_add(row, i18n_t.section_total, nil, true)
if get_prose then
header_cell_add(row, i18n_t.section_header, nil, true)
header_cell_add(row, i18n_t.section_total, nil, true)
end
end
--- Adds the two-row header used for hierarchical articles.
local function grouped_header_add(table_node, get_prose)
grouped_header_top_add(table_node, get_prose)
grouped_header_bottom_add(table_node, get_prose)
end
--- Adds the table caption.
local function caption_add(table_node, article, section_count)
local caption = table_node:tag("caption")
caption:wikitext(raw_message_get(
i18n_t["table_caption"],
{article, section_count}
))
end
--- Adds one footer numeric cell.
local function footer_value_add(row, value, colspan)
local cell = row:tag("th")
cell:css("text-align", "right")
if colspan then
cell:attr("colspan", tostring(colspan))
end
cell:wikitext(lang_obj:formatNum(value))
end
--- Adds the table footer.
local function footer_add(table_node, context)
local row = table_node:tag("tr")
local label = row:tag("th")
label:attr("scope", "row")
label:wikitext(i18n_t.total)
local span
if not context.flat then
span = 2
end
footer_value_add(row, context.total_size, span)
if context.get_prose then
footer_value_add(row, context.total_prose, span)
end
end
--- Creates the root sortable table node.
local function table_node_make(style)
local node = mw.html.create("table")
node:addClass("wikitable")
node:addClass("sortable")
if style and "" ~= style then
node:cssText(style)
end
return node
end
--- Builds the complete table from sections and context.
local function table_render(frame, sections, context)
local article = frame_arg_get(frame, 1)
local style = frame_arg_get(frame, "style")
local table_node = table_node_make(style)
local table_header = frame_arg_get(frame, "table_header")
if yesno(table_header) ~= false then
caption_add(table_node, article, #sections)
end
if context.flat then
flat_header_add(table_node, context.get_prose)
else
grouped_header_add(table_node, context.get_prose)
end
for _, section in ipairs(sections) do
local display = heading_display_get(
frame,
section,
context.template_name,
article
)
local row_context = row_context_get(context, section)
body_row_add(table_node, row_context, display)
end
footer_add(table_node, context)
return tostring(table_node)
end
--- Builds rendering context shared by all rows.
local function render_context_make(
frame,
sections,
get_prose,
bytes_heatmap
)
return {
byte_threshold = byte_threshold_get(
sections,
bytes_heatmap
),
bytes_heatmap = bytes_heatmap,
flat = sections_are_flat(sections),
get_prose = get_prose,
prose_threshold = prose_threshold_get(sections),
template_name = frame:getParent():getTitle(),
total_heat = sections_field_sum(sections, "heat_size"),
total_prose = sections_field_sum(sections, "prose_size"),
total_size = sections_field_sum(sections, "size"),
}
end
--- Returns parsed sections or a no-sections error.
local function sections_or_error_get(
content,
template_name,
article,
get_prose,
bytes_heatmap
)
local sections = sections_build(
content,
get_prose,
bytes_heatmap
)
if sections then
return sections, nil
end
return nil, error_msg_make(
template_name,
i18n_t["sections_error"],
{article},
true
)
end
--- Prepares parsed sections and rendering context.
local function table_data_prepare(
frame,
content,
get_prose,
bytes_heatmap,
template_name
)
local sections, message = sections_or_error_get(
content,
template_name,
frame_arg_get(frame, 1),
get_prose,
bytes_heatmap
)
if message then
return nil, nil, message
end
section_numbers_set(sections)
section_totals_compute(sections)
local context = render_context_make(
frame,
sections,
get_prose,
bytes_heatmap
)
return sections, context, nil
end
--- Module entry point that renders the section-size table.
local function size(frame)
local template_name = frame:getParent():getTitle()
local get_prose = yesno_arg_get(frame, "getprose", true)
local bytes_heatmap = bytes_heatmap_mode_get(frame)
local content, message = article_content_get(
frame_arg_get(frame, 1),
template_name
)
if message then
return message
end
local sections, context
sections, context, message = table_data_prepare(
frame,
content,
get_prose,
bytes_heatmap,
template_name
)
if message then
return message
end
return table_render(frame, sections, context)
end
--- Returns one section size formatted per caller options.
local function section_size_format(value, total, no_separator, percent)
if percent then
return strf("%2.2f%%", 100 * value / total)
end
return lang_obj:formatNum(value, {
noCommafy = no_separator,
})
end
--- Returns the total size of one named section and descendants.
local function named_section_total_get(sections, target_index)
local target = sections[target_index]
local total = target.size
for index = target_index + 1, #sections do
local current = sections[index]
if current.level <= target.level then
return total
end
total = total + current.size
end
return total
end
--- Finds one named section by case-folded heading text.
local function named_section_index_get(sections, target_name)
local folded = lang_obj:caseFold(target_name)
for index, section in ipairs(sections) do
local current = lang_obj:caseFold(section.name)
if current == folded then
return index
end
end
return nil
end
--- Returns the requested raw section-size value.
local function section_value_get(sections, key, include_children)
if "_lead" == key then
return sections[1].size
end
if "_max" == key then
return sections_field_largest(sections, "size")
end
if "_total" == key then
return sections_field_sum(sections, "size")
end
if not key then
return nil
end
local index = named_section_index_get(sections, key)
if not index then
return nil
end
if include_children then
return named_section_total_get(sections, index)
end
return sections[index].size
end
--- Resolves a section-size request from parsed arguments.
local function section_request_get(args, template_name)
local content, message = article_content_get(
args[1],
template_name
)
if message then
return nil, nil, message
end
local sections
sections, message = sections_or_error_get(
content,
template_name,
args[1],
false,
true
)
if message then
return nil, nil, message
end
local value = section_value_get(
sections,
args[2],
"_all" == args[3]
)
return value, sections, nil
end
--- Module entry point that returns one section-size value.
local function section_size_get(frame)
local args = require("Module:Arguments").getArgs(frame)
local template_name = frame:getParent():getTitle()
local value, sections, message = section_request_get(
args,
template_name
)
if message then
return message
end
if value == nil then
return nil
end
return section_size_format(
value,
sections_field_sum(sections, "size"),
yesno(args._nosep, false),
yesno(args._pct, false)
)
end
return {
section_size_get = section_size_get,
size = size,
}