Module:Dependencies: Difference between revisions
From Shroudforge
More actions
starcitizen>Alistar Bot Sync from Git (becda4f): draw SVG thumbnails as silhouettes (via update-page on MediaWiki MCP Server) |
Bonsaibauer (talk | contribs) m 1 revision imported: Import Star Citizen Modules |
||
(No difference)
| |||
Latest revision as of 22:22, 4 November 2026
Documentation for this module may be created at Module:Dependencies/doc
require('strict')
--- @module Dependencies
--- The folded "Technical details" panel Module:Documentation shows on module and
--- template pages: which pages use the page, what it uses, and the functions a
--- module defines; and the `dependencies` Bucket row each page writes so the
--- modules and stylesheets it uses can list it. Forward lists come from the
--- page's own source; reverse lists come from the rows other pages wrote.
local parse = require('Module:Dependencies/Parse')
local mbox = require('Module:Mbox')
local icon = require('Module:Icon')
local yesno = require('Module:Yesno')
local p = {}
local BUCKET = 'dependencies'
local NS_TEMPLATE = 10
local NS_MODULE = 828
local HEADING = 'Technical details'
local ICON = 'WikimediaUI-Code.svg'
local ROW_ICONS = {
source = 'CdxIconReference.svg',
usedBy = 'CdxIconArrowPrevious.svg',
uses = 'CdxIconArrowNext.svg',
functions = 'CdxIconFunction.svg',
}
local UNUSED_TEXT = 'No template invokes it and no module requires or loads it. '
.. 'If an article invokes it directly, call it through a template instead.'
local UNAVAILABLE_TEXT = 'Unavailable: looking up the pages that use it failed on this render. '
.. 'Purge the page to retry.'
--- @class DependenciesContext
--- @field title string the documented page, e.g. `Module:Mbox`
--- @field prefix string its namespace with the colon, e.g. `Module:`
--- @field namespace integer
--- @field content string|nil its source
--- @field isCurrent boolean the render is the page itself, not its /doc or another page
--- @field excluded boolean a sandbox or testcases page, whose dependencies are not real usage
--- @class DependenciesGroup
--- @field label string e.g. `Required by`
--- @field items string[] wikitext, one per list item
--- @field note string|nil wikitext under the list
--- @field modifier string|nil the list's `t-dependencies__list--` modifier
--- @class DependenciesReport
--- @field usedBy DependenciesGroup[]|nil nil when the reverse lookup failed
--- @field uses DependenciesGroup[]
--- @field usedByCount integer distinct pages across `usedBy`
--- @field usesCount integer entries across `uses`
--- @field unused boolean a module nothing invokes, requires or loads
--- @field functions ModuleTocFunction[]
--- @class DependenciesSource
--- @field label string plain text for the folded header, e.g. `wiki-tools`
--- @field text string wikitext, one line of the Source row
--- @class DependenciesPanelOptions
--- @field page string|nil the page to describe; default the current page, or the page a /doc documents
--- @field addCategories boolean|string|nil defaults to true except on a /doc render
--- @field sources DependenciesSource[]|nil
--- @param title table mw.title
--- @return boolean
local function isExcluded(title)
local text = title.text:lower()
return text:find('^sandbox/') ~= nil or text:find('/sandbox') ~= nil or text:find('/testcases') ~= nil
end
--- @param pageName string|nil
--- @return DependenciesContext|nil
local function context(pageName)
local current = mw.title.getCurrentTitle()
local target = pageName and mw.title.new(pageName) or current
if target == nil then
return nil
end
if target.isSubpage and target.subpageText:lower() == 'doc' then
target = target.basePageTitle
end
if target.namespace ~= NS_TEMPLATE and target.namespace ~= NS_MODULE then
return nil
end
return {
title = target.prefixedText,
prefix = target.prefixedText:match('^[^:]+:'),
namespace = target.namespace,
content = target:getContent(),
isCurrent = current.prefixedText == target.prefixedText,
excluded = isExcluded(target),
}
end
--- @param list any
--- @param value string
--- @return boolean
local function contains(list, value)
for _, item in ipairs(type(list) == 'table' and list or {}) do
if item == value then
return true
end
end
return false
end
--- `files` with the files `extra` names added.
--- @param files DependenciesFiles
--- @param extra DependenciesFiles
--- @return DependenciesFiles
local function mergeFiles(files, extra)
local merged = { names = {}, urls = {}, dynamic = {} }
for _, key in ipairs({ 'names', 'dynamic' }) do
local seen = {}
for _, list in ipairs({ files[key], extra[key] }) do
for _, value in ipairs(list) do
if not seen[value] then
seen[value] = true
merged[key][#merged[key] + 1] = value
end
end
end
table.sort(merged[key])
end
local seen = {}
for _, list in ipairs({ files.urls, extra.urls }) do
for _, url in ipairs(list) do
local key = url.name .. '|' .. url.path
if not seen[key] then
seen[key] = true
merged.urls[#merged.urls + 1] = url
end
end
end
return merged
end
--- What the page itself uses: a module's requires, loads, stylesheets and
--- files, a template's invokes, stylesheets and files. The files include those
--- the page's own `/styles.css` names when the page loads it; any other
--- stylesheet lists its files on the page that owns it.
--- @param ctx DependenciesContext
--- @return table
local function forward(ctx)
local found
if ctx.namespace == NS_MODULE then
found = parse.lua(ctx.content)
found.invokes = {}
else
found = {
requires = {},
loads = {},
dynamic = {},
strict = false,
invokes = parse.invokes(ctx.content),
styles = parse.styles(ctx.content),
files = parse.files(ctx.content),
}
end
local own = ctx.title .. '/styles.css'
local stylesheet = contains(found.styles, own) and mw.title.new(own)
local css = stylesheet and stylesheet:getContent()
if css then
found.files = mergeFiles(found.files, parse.stylesheetFiles(css))
end
return found
end
--- The page's `dependencies` row, or nil when it uses nothing.
--- @param found table
--- @return table|nil
local function rowFor(found)
local invokes, calls, seen = {}, {}, {}
for _, call in ipairs(found.invokes) do
if not seen[call.module] then
seen[call.module] = true
invokes[#invokes + 1] = call.module
end
calls[#calls + 1] = call.module .. '|' .. call.func
end
local row = {}
local function set(field, list)
if list[1] ~= nil then
row[field] = list
end
end
set('requires', found.requires)
set('loads', found.loads)
set('invokes', invokes)
set('invoke_calls', calls)
set('styles', found.styles)
if next(row) == nil then
return nil
end
return row
end
--- @param ctx DependenciesContext
--- @param found table
local function write(ctx, found)
if not ctx.isCurrent or ctx.excluded then
return
end
local row = rowFor(found)
if row == nil then
return
end
pcall(function()
mw.ext.bucket(BUCKET).put(row)
end)
end
--- Splits the rows naming `title` into the pages that invoke, require and load
--- it, and the pages that load its `/styles.css`.
--- @param rows table[] raw Bucket rows keyed by field name (page_name, requires, loads, invokes, invoke_calls, styles)
--- @param title string
--- @return { invokedBy: { page: string, funcs: string[] }[], requiredBy: string[], loadedBy: string[], styledBy: string[] }
local function group(rows, title)
local back = { invokedBy = {}, requiredBy = {}, loadedBy = {}, styledBy = {} }
local prefix = title .. '|'
local stylesheet = title .. '/styles.css'
for _, row in ipairs(rows) do
local page = row.page_name
if page and page ~= title then
if contains(row.invokes, title) then
local funcs, seen = {}, {}
for _, call in ipairs(type(row.invoke_calls) == 'table' and row.invoke_calls or {}) do
local func = call:sub(1, #prefix) == prefix and call:sub(#prefix + 1) or nil
if func and not seen[func] then
seen[func] = true
funcs[#funcs + 1] = func
end
end
back.invokedBy[#back.invokedBy + 1] = { page = page, funcs = funcs }
end
if contains(row.requires, title) then
back.requiredBy[#back.requiredBy + 1] = page
end
if contains(row.loads, title) then
back.loadedBy[#back.loadedBy + 1] = page
end
if contains(row.styles, stylesheet) then
back.styledBy[#back.styledBy + 1] = page
end
end
end
table.sort(back.invokedBy, function(a, b)
return a.page < b.page
end)
table.sort(back.requiredBy)
table.sort(back.loadedBy)
table.sort(back.styledBy)
return back
end
--- The pages that use `title`, or nil when the lookup failed.
--- Queries Bucket directly: Module:BucketQuery would load and link every
--- manifest registered ahead of this one on each documentation render.
--- @param title string
--- @return table|nil
local function reverse(title)
local ok, rows = pcall(function()
local bucket = mw.ext.bucket
return bucket(BUCKET)
.select('page_name', 'requires', 'loads', 'invokes', 'invoke_calls', 'styles')
.where(
bucket.Or(
{ 'requires', title },
{ 'loads', title },
{ 'invokes', title },
{ 'styles', title .. '/styles.css' }
)
)
.limit(5000)
.run()
end)
if not ok or type(rows) ~= 'table' then
return nil
end
return group(rows, title)
end
--- @param text string
--- @return string
local function code(text)
return '<code>' .. mw.text.nowiki(text) .. '</code>'
end
--- A link to `name` as a row shows it: one of the documented page's own subpages
--- relative (`/styles.css`), otherwise without `prefix`, the namespace the row
--- already implies.
--- @param name string
--- @param ctx DependenciesContext
--- @param prefix string
--- @return string
local function link(name, ctx, prefix)
local own = ctx.title .. '/'
local text = name
if name:sub(1, #own) == own then
text = name:sub(#own)
elseif name:sub(1, #prefix) == prefix then
text = name:sub(#prefix + 1)
end
if text == name then
return '[[' .. name .. ']]'
end
return '[[' .. name .. '|' .. text .. ']]'
end
--- @param names string[]
--- @param ctx DependenciesContext
--- @param prefix string
--- @return string[]
local function links(names, ctx, prefix)
local items = {}
for i, name in ipairs(names) do
items[i] = link(name, ctx, prefix)
end
return items
end
--- The templates that invoke the page, each with the functions it calls. When
--- every one calls the same single function, the note names it once instead.
--- @param entries { page: string, funcs: string[] }[]
--- @param ctx DependenciesContext
--- @return string[] items
--- @return string|nil note
local function invokers(entries, ctx)
local shared = entries[1] and entries[1].funcs[1]
for _, entry in ipairs(entries) do
if #entry.funcs ~= 1 or entry.funcs[1] ~= shared then
shared = nil
break
end
end
local items = {}
for i, entry in ipairs(entries) do
items[i] = link(entry.page, ctx, 'Template:')
if shared == nil and entry.funcs[1] ~= nil then
local funcs = {}
for j, func in ipairs(entry.funcs) do
funcs[j] = code(func)
end
items[i] = items[i] .. ' (' .. table.concat(funcs, ', ') .. ')'
end
end
if shared == nil then
return items, nil
end
return items, (#entries > 1 and 'All via ' or 'Via ') .. code(shared)
end
--- @param into DependenciesGroup[]
--- @param label string
--- @param items string[]
--- @param note string|nil
--- @param modifier string|nil
local function addGroup(into, label, items, note, modifier)
if items[1] ~= nil then
into[#into + 1] = { label = label, items = items, note = note, modifier = modifier }
end
end
--- What is wrong with each media URL, by file name: a path that is not the
--- md5 directories of the name, or a file that has moved, which `[[File:]]`
--- follows but a URL does not.
--- @param urls DependenciesFileUrl[]
--- @return table<string, string[]>
local function urlProblems(urls)
local problems, checked = {}, {}
local function add(name, text)
problems[name] = problems[name] or {}
problems[name][#problems[name] + 1] = text
end
for _, url in ipairs(urls) do
local md5 = mw.hash.hashValue('md5', (url.name:gsub(' ', '_')))
local expected = md5:sub(1, 1) .. '/' .. md5:sub(1, 2)
if url.path ~= expected then
add(url.name, 'URL path ' .. code(url.path) .. ' should be ' .. code(expected))
end
if not checked[url.name] then
checked[url.name] = true
-- isRedirect is an expensive lookup: past the page's limit it throws.
local ok, target = pcall(function()
local title = mw.title.new('File:' .. url.name)
return title and title.isRedirect and title.redirectTarget
end)
if ok and target then
add(url.name, 'moved to [[:File:' .. target.text .. '|' .. target.text .. ']], update the URL')
end
end
end
return problems
end
--- The Files group's items. On the page itself each name follows a thumbnail,
--- which records the file as used by the page; anywhere else (its /doc, a
--- sandbox) the name alone, so only the page whose source names a file is
--- recorded as using it.
--- @param files DependenciesFiles
--- @param ctx DependenciesContext
--- @return string[]
local function fileItems(files, ctx)
local thumbnails = ctx.isCurrent and not ctx.excluded
local problems = urlProblems(files.urls)
local items = {}
for _, name in ipairs(files.names) do
local item = '[[:File:' .. name .. '|' .. name .. ']]'
if thumbnails then
-- The wiki recolours most icons, so an SVG shows as a silhouette in the text
-- colour: styles.css blackens it and `skin-invert` whitens it on a dark theme.
local class = name:lower():find('%.svg$') and 't-dependencies__thumb t-dependencies__thumb--svg skin-invert'
or 't-dependencies__thumb'
item = '<span class="' .. class .. '">[[File:' .. name .. '|20x20px|link=|alt=]]</span>' .. item
end
if problems[name] then
item = item
.. ' <span class="t-dependencies__warning">('
.. table.concat(problems[name], '; ')
.. ')</span>'
end
items[#items + 1] = item
end
for _, pattern in ipairs(files.dynamic) do
items[#items + 1] = code(pattern)
end
return items
end
--- @param back table
--- @return integer
local function countPages(back)
local seen, count = {}, 0
local function add(page)
if not seen[page] then
seen[page] = true
count = count + 1
end
end
for _, entry in ipairs(back.invokedBy) do
add(entry.page)
end
for _, key in ipairs({ 'requiredBy', 'loadedBy', 'styledBy' }) do
for _, page in ipairs(back[key]) do
add(page)
end
end
return count
end
--- What the panel shows for the page, before any markup.
--- @param ctx DependenciesContext
--- @param found table forward()'s result
--- @param back table|nil reverse()'s result; nil when the lookup failed
--- @return DependenciesReport
local function report(ctx, found, back)
local uses, usesCount = {}, 0
if ctx.namespace == NS_TEMPLATE then
local calls = {}
for i, call in ipairs(found.invokes) do
calls[i] = code(call.func) .. ' in [[' .. call.module .. ']]'
end
addGroup(uses, 'Invokes', calls)
usesCount = #calls
else
local requires = links(found.requires, ctx, 'Module:')
for _, pattern in ipairs(found.dynamic) do
requires[#requires + 1] = code(pattern)
end
addGroup(uses, 'Requires', requires)
addGroup(uses, 'Loads', links(found.loads, ctx, 'Module:'))
usesCount = #requires + #found.loads
end
addGroup(uses, 'Styles', links(found.styles, ctx, ctx.prefix))
addGroup(uses, 'Files', fileItems(found.files, ctx), nil, 'files')
usesCount = usesCount + #found.styles + #found.files.names + #found.files.dynamic
local result = {
uses = uses,
usesCount = usesCount,
usedByCount = 0,
unused = false,
functions = {},
}
if back ~= nil then
local usedBy = {}
local items, note = invokers(back.invokedBy, ctx)
addGroup(usedBy, 'Invoked by', items, note)
addGroup(usedBy, 'Required by', links(back.requiredBy, ctx, 'Module:'))
addGroup(usedBy, 'Loaded by', links(back.loadedBy, ctx, 'Module:'))
addGroup(usedBy, 'Styles used by', links(back.styledBy, ctx, ctx.prefix))
result.usedBy = usedBy
result.usedByCount = countPages(back)
result.unused = ctx.namespace == NS_MODULE
and not ctx.excluded
and back.invokedBy[1] == nil
and back.requiredBy[1] == nil
and back.loadedBy[1] == nil
end
if ctx.namespace == NS_MODULE then
-- Required here, not at the top: every page records the modules it loads
-- and re-parses when one changes, and template pages never need this one.
result.functions = require('Module:Module toc').functions(ctx.content)
end
return result
end
--- @param ctx DependenciesContext
--- @param found table
--- @param result DependenciesReport
--- @return string
local function categories(ctx, found, result)
local out = {}
if ctx.namespace == NS_TEMPLATE and found.invokes[1] ~= nil then
out[#out + 1] = '[[Category:Lua-based templates]]'
end
if ctx.namespace == NS_MODULE and found.strict then
out[#out + 1] = '[[Category:Strict mode modules]]'
end
if result.unused then
out[#out + 1] = '[[Category:Unused modules]]'
end
return table.concat(out)
end
--- @param addCategories boolean|string|nil
--- @return boolean
local function categoriesEnabled(addCategories)
-- Yesno's `default` only covers an unrecognised string, not a nil argument
-- (a #invoke omitting the parameter, or a Lua caller passing nothing).
local enabled = yesno(addCategories)
if enabled == nil then
local current = mw.title.getCurrentTitle()
return not (current.isSubpage and current.subpageText:lower() == 'doc')
end
return enabled == true
end
--- @param items string[]
--- @param modifier string|nil
--- @return table mw.html
local function list(items, modifier)
local root = mw.html.create('ul'):addClass('t-dependencies__list')
if modifier then
root:addClass('t-dependencies__list--' .. modifier)
end
for _, item in ipairs(items) do
root:tag('li'):wikitext(item)
end
return root
end
--- @param groups DependenciesGroup[]
--- @return string
local function groupsHtml(groups)
local root = mw.html.create('div'):addClass('t-dependencies__groups')
for _, entry in ipairs(groups) do
root:tag('div')
:addClass('t-dependencies__sublabel')
:wikitext(entry.label)
:tag('span')
:addClass('t-dependencies__count')
:wikitext(#entry.items)
local body = root:tag('div'):addClass('t-dependencies__items'):node(list(entry.items, entry.modifier))
if entry.note then
body:tag('div'):addClass('t-dependencies__note'):wikitext(entry.note)
end
end
return tostring(root)
end
--- @param title string
--- @param functions ModuleTocFunction[]
--- @return string
local function functionsHtml(title, functions)
local items = {}
for i, func in ipairs(functions) do
items[i] = string.format(
'[[%s#L-%d|%s]]<span class="t-dependencies__line">L%d</span>',
title,
func.line,
mw.text.nowiki(func.name),
func.line
)
end
return tostring(list(items, 'code'))
end
--- @param label string
--- @param file string
--- @param content string wikitext
--- @param modifier string|nil
--- @return string
local function row(label, file, content, modifier)
local root = mw.html.create('div'):addClass('t-dependencies__row')
if modifier then
root:addClass('t-dependencies__row--' .. modifier)
end
root:tag('div')
:addClass('t-dependencies__label')
:wikitext(icon.render({ icon = file, mask = true, size = '1em', class = 't-dependencies__icon' }))
:wikitext(label)
root:tag('div'):addClass('t-dependencies__content'):wikitext(content)
return tostring(root)
end
--- The folded header: the heading, then plain-text facts. It sits inside
--- <summary>, so it must hold no link.
--- @param facts string[]
--- @param unused boolean
--- @return string
local function summary(facts, unused)
local root = mw.html.create('span'):addClass('t-dependencies__summary')
root:tag('span'):addClass('t-dependencies__heading'):wikitext(HEADING)
if unused or facts[1] ~= nil then
local meta = root:tag('span'):addClass('t-dependencies__meta')
if unused then
meta:tag('span'):addClass('t-dependencies__chip'):wikitext('Unused')
end
if facts[1] ~= nil then
meta:tag('span'):wikitext(table.concat(facts, ' · '))
end
end
return tostring(root)
end
--- The panel for `options.page`, followed by its categories. Writes the page's
--- row when the render is the page itself.
--- @param options DependenciesPanelOptions|nil
--- @return string
function p.panel(options)
options = options or {}
local rows, facts, unused, cats = {}, {}, false, ''
local lines = {}
for _, source in ipairs(options.sources or {}) do
lines[#lines + 1] = '<div>' .. source.text .. '</div>'
facts[#facts + 1] = source.label
end
if lines[1] ~= nil then
rows[#rows + 1] = row('Source', ROW_ICONS.source, table.concat(lines))
end
local ctx = context(options.page)
if ctx ~= nil and ctx.content ~= nil then
local found = forward(ctx)
write(ctx, found)
local back = reverse(ctx.title)
local result = report(ctx, found, back)
unused = result.unused
if unused then
local content = '<div>' .. UNUSED_TEXT .. '</div>'
if result.usedBy[1] ~= nil then
content = content .. groupsHtml(result.usedBy)
end
rows[#rows + 1] = row('Used by', ROW_ICONS.usedBy, content, 'warning')
elseif back == nil then
if ctx.namespace == NS_MODULE then
rows[#rows + 1] = row('Used by', ROW_ICONS.usedBy, UNAVAILABLE_TEXT, 'muted')
end
elseif result.usedBy[1] ~= nil then
rows[#rows + 1] = row('Used by', ROW_ICONS.usedBy, groupsHtml(result.usedBy))
facts[#facts + 1] = 'used by ' .. result.usedByCount
end
if result.uses[1] ~= nil then
rows[#rows + 1] = row('Uses', ROW_ICONS.uses, groupsHtml(result.uses))
facts[#facts + 1] = 'uses ' .. result.usesCount
end
if result.functions[1] ~= nil then
rows[#rows + 1] = row('Functions', ROW_ICONS.functions, functionsHtml(ctx.title, result.functions))
end
if categoriesEnabled(options.addCategories) then
cats = categories(ctx, found, result)
end
end
if rows[1] == nil then
return cats
end
local frame = mw.getCurrentFrame()
-- Module:Icon leaves its stylesheet to the caller; the row icons need it.
local styles = frame:extensionTag({ name = 'templatestyles', args = { src = 'Module:Icon/styles.css' } })
.. frame:extensionTag({ name = 'templatestyles', args = { src = 'Module:Dependencies/styles.css' } })
return styles
.. mbox.render({
title = summary(facts, unused),
text = table.concat(rows),
icon = ICON,
class = 't-dependencies',
})
.. cats
end
--- `{{#invoke:Dependencies|main}}`, for MediaWiki:Scribunto-doc-page-does-not-exist.
--- @param frame table
--- @return string
function p.main(frame)
return p.panel()
end
-- Test-only exports. Not part of the public API.
p._internal = {
group = group,
report = report,
rowFor = rowFor,
}
return p