Module:GetRels: Difference between revisions

From Bodhicitta
(VSCode edit)
(VSCode edit)
 
(22 intermediate revisions by the same user not shown)
Line 5: Line 5:
local FILTER_THRESHOLD_RELS = 5
local FILTER_THRESHOLD_RELS = 5
local FILTER_THRESHOLD_PAGES = 10
local FILTER_THRESHOLD_PAGES = 10
-- Show the class pills only once there are more rows than this in total.
local FILTER_THRESHOLD_ROWS = 10


-- Relationship display name mappings
-- Relationship display name mappings
local REL_NAMES = {
local REL_NAMES = {
["Contains a full translation of"] = {forward = 'Translation of', inverse = 'Full translations'},
-- Not a RelatedTo relationship. Generated from the local TextTopics property
["Contains a modern commentary of"] = {forward = 'Commentary of', inverse = 'Commentaries'},
-- by fetchTopicOfRelationships, and only ever in one direction, so both
-- labels are the same.
["Is a topic of"] = {forward = 'A topic of', inverse = 'A topic of'},
["Contains a full translation of"] = {forward = 'Full translation of', inverse = 'Full translations'},
["Contains a modern commentary of"] = {forward = 'Modern commentary of', inverse = 'Modern commentaries'},
["Contains a partial translation of"] = {forward = 'Partial translation of', inverse = 'Partial translations'},
["Contains a partial translation of"] = {forward = 'Partial translation of', inverse = 'Partial translations'},
["Contains a translated commentary of"] = {forward = 'Translated commentary of', inverse = 'Translated Commentaries'},
["Contains a translated commentary of"] = {forward = 'Translated commentary of', inverse = 'Translated Commentaries'},
["Contains a recension of"] = {forward = 'Recension of', inverse = 'Recensions'},
["Contains a recension of"] = {forward = 'Recension of', inverse = 'Recensions'},
["Contains a review of"] = {forward = 'Review of', inverse = 'Reviews'},
["Contains a review of"] = {forward = 'Review of', inverse = 'Reviews'},
["Contains a teaching based on"] = {forward = 'Teaching based on', inverse = 'Teachings'},
["Contains a teaching based on"] = {forward = 'Teaching based on', inverse = 'Works containing teachings'},
["Contains a translation of"] = {forward = 'Translation of', inverse = 'Translations'},
["Contains a translation of"] = {forward = 'Translation of', inverse = 'Translations'},
["Contains polemical content related to"] = {forward = 'Polemical content on', inverse = 'Polemical content'},
["Contains polemical content related to"] = {forward = 'Polemical content on', inverse = 'Polemical content'},
Line 22: Line 28:
["Is a book based on this dissertation"] = {forward = 'Based on dissertation', inverse = 'Based on dissertation'},
["Is a book based on this dissertation"] = {forward = 'Based on dissertation', inverse = 'Based on dissertation'},
["Is a chapter or a part of"] = {forward = 'Chapter or part of', inverse = 'Contains chapter or part'},
["Is a chapter or a part of"] = {forward = 'Chapter or part of', inverse = 'Contains chapter or part'},
["Is a commentary of"] = {forward = 'Commentary of', inverse = 'Commentaries'},
["Is a commentary of"] = {forward = 'Classical commentary of', inverse = 'Classical commentaries'},
["Is a member of"] = {forward = 'Member of', inverse = 'Contains members'},
["Is a member of"] = {forward = 'Member of', inverse = 'Contains members'},
["Is a reading of"] = {forward = 'Reading of', inverse = 'Readings'},
["Is a reading of"] = {forward = 'Reading of', inverse = 'Readings'},
["Is a scholarly presentation about"] = {forward = 'Scholarly presentation about', inverse = 'Scholarly presentations'},
["Is a scholarly presentation about"] = {forward = 'Scholarly presentation about', inverse = 'Scholarly presentations'},
["Is a teaching based on"] = {forward = 'Teaching on', inverse = 'Teachings'},
["Is a teaching based on"] = {forward = 'Teaching on', inverse = 'Teachings'},
["Is cited in"] = {forward = 'Cited in', inverse = 'Citations'},
["Is an interview about"] = {forward = 'Interview about', inverse = 'Interviews'},
["Is an interview about"] = {forward = 'Interview about', inverse = 'Interviews'},
["Is another edition of"] = {forward = 'Other editions', inverse = 'Other editions'},
["Is another edition of"] = {forward = 'Other editions', inverse = 'Other editions'},
Line 61: Line 68:
["Contains a teaching based on"] = 16,
["Contains a teaching based on"] = 16,
["Contains a review of"] = 17,
["Contains a review of"] = 17,
["Contains scholarship related to"] = 18,
["Is cited in"] = 18,
["Is a scholarly presentation about"] = 19,
["Contains scholarship related to"] = 19,
["Is an interview about"] = 20,
["Is a scholarly presentation about"] = 20,
["Shares intertextuality with"] = 21,
["Is an interview about"] = 21,
["Is related to"] = 22,
["Shares intertextuality with"] = 22,
["Contains polemical content related to"] = 23
["Is related to"] = 23,
["Contains polemical content related to"] = 24,
-- TextTopics is not one of the RelatedTo relationships; it is a separate
-- local property. It is listed last so it reads as broader context after the
-- specific bibliographic relationships above.
["Is a topic of"] = 25
}
}


Line 120: Line 132:


-- Helper function: Fetch incoming relationships (other pages have property pointing to this page).
-- Helper function: Fetch incoming relationships (other pages have property pointing to this page).
-- Also fetches Categories, Classification, Display title of, MediaSeries, and Volumenumber for
-- Also fetches Classification, Display title of, MediaSeries, Volumenumber, and tile fields for
-- each result row so that downstream checks (isLibraryItem, isMultiVolumeSeries, buildPageLink)
-- each result row so that downstream checks (isLibraryItem, isMultiVolumeSeries, buildPageLink,
-- can use the cached data instead of issuing additional per-page queries.
-- generateTile) can use the cached data instead of issuing additional per-page queries.
local function fetchIncomingRelationships(pageA)
local function fetchIncomingRelationships(pageA, pageFull)
local results = {}
local results = {}
-- Match either the bare or the full title. Relationship properties store the
-- full title, but accepting both keeps this working for pages that are not
-- subpages, and for any entry recorded the other way round.
local target = pageA
if pageFull and pageFull ~= pageA then
target = pageA .. '||' .. pageFull
end


for relName, _ in pairs(REL_NAMES) do
for relName, _ in pairs(REL_NAMES) do
local query = mw.smw.ask{
local query = mw.smw.ask{
'[[' .. relName .. '::' .. pageA .. ']]',
'[[' .. relName .. '::' .. target .. ']]',
'?#-=Page',
'?#-=Page',
'?Classification=Classification',
'?Classification=Classification',
'?Display title of=Title',
'?Display title of=Title',
'?MediaSeries=MediaSeries',
'?MediaSeries=MediaSeries',
Line 137: Line 157:
'?TileDescription=TileDescription',
'?TileDescription=TileDescription',
'?TileImageUrl=TileImageUrl',
'?TileImageUrl=TileImageUrl',
'?Citation=Citation',
'?Pubdate=Pubdate',
'?FrontCoverImg#=FrontCoverImg',
'?BcaAuthor#=BcaAuthor',
'?Description=Description',
'?FullTitleTib=FullTitleTib',
'?FullTitleTib=FullTitleTib',
'?DescriptionTib=DescriptionTib',
'?DescriptionTib=DescriptionTib',
Line 158: Line 183:
TileDescription = row.TileDescription,
TileDescription = row.TileDescription,
TileImageUrl  = row.TileImageUrl,
TileImageUrl  = row.TileImageUrl,
Citation    = row.Citation,
Pubdate      = row.Pubdate,
FrontCoverImg = row.FrontCoverImg,
BcaAuthor    = row.BcaAuthor,
Description  = row.Description,
FullTitleTib  = row.FullTitleTib,
FullTitleTib  = row.FullTitleTib,
DescriptionTib = row.DescriptionTib,
DescriptionTib = row.DescriptionTib,
Line 192: Line 222:
'?TileDescription=TileDescription',
'?TileDescription=TileDescription',
'?TileImageUrl=TileImageUrl',
'?TileImageUrl=TileImageUrl',
'?Citation=Citation',
'?Pubdate=Pubdate',
'?FrontCoverImg#=FrontCoverImg',
'?BcaAuthor#=BcaAuthor',
'?Description=Description',
'?FullTitleTib=FullTitleTib',
'?FullTitleTib=FullTitleTib',
'?DescriptionTib=DescriptionTib',
'?DescriptionTib=DescriptionTib',
Line 210: Line 245:
TileDescription = row.TileDescription,
TileDescription = row.TileDescription,
TileImageUrl  = row.TileImageUrl,
TileImageUrl  = row.TileImageUrl,
Citation    = row.Citation,
Pubdate      = row.Pubdate,
FrontCoverImg = row.FrontCoverImg,
BcaAuthor    = row.BcaAuthor,
Description  = row.Description,
FullTitleTib  = row.FullTitleTib,
FullTitleTib  = row.FullTitleTib,
DescriptionTib = row.DescriptionTib,
DescriptionTib = row.DescriptionTib,
Line 340: Line 380:
-- Build cachedData from inline fields; fall back to pageCache entry if sparse
-- Build cachedData from inline fields; fall back to pageCache entry if sparse
local cachedData = {
local cachedData = {
Categories      = item.Categories,
Classification  = item.Classification,
Classification  = item.Classification,
Title          = item.Title,
Title          = item.Title,
Line 351: Line 390:
DescriptionTib  = item.DescriptionTib,
DescriptionTib  = item.DescriptionTib,
TileLabel      = item.TileLabel,
TileLabel      = item.TileLabel,
-- Row fields. This table is rebuilt field by field rather than
-- copied, so anything added to the queries must be listed here too
-- or it is silently dropped before it reaches the row.
Citation        = item.Citation,
Pubdate        = item.Pubdate,
FrontCoverImg  = item.FrontCoverImg,
BcaAuthor      = item.BcaAuthor,
Description    = item.Description,
}
}
if not cachedData.Classification and pageCache[pageB] then
if not cachedData.Classification and pageCache[pageB] then
Line 435: Line 482:
end
end


-- Rendered with TopicBiblioRow, the same row used on topic pages and for the
-- Explore section above, so a work looks the same wherever it is met.
--
-- The row falls back Citation -> Title -> page name, so entries with no
-- citation still read correctly, and it does its own reverse
-- AssociatedArticle lookup for narration. Nothing here needs to know about
-- either.
return frame:expandTemplate{
return frame:expandTemplate{
title = 'RelationshipTile',
title = 'TopicBiblioRow',
args = {
args = {
[1]             = page,
[1]             = page,
Title            = firstVal(cachedData and cachedData.Title),
Citation        = firstVal(cachedData and cachedData.Citation),
FullTitleTib    = firstVal(cachedData and cachedData.FullTitleTib),
BcaAuthor      = firstVal(cachedData and cachedData.BcaAuthor),
TileDescription  = firstVal(cachedData and cachedData.TileDescription),
Title          = firstVal(cachedData and cachedData.Title),
DescriptionTib  = firstVal(cachedData and cachedData.DescriptionTib),
Class          = firstVal(cachedData and cachedData.Class),
TileImageUrl    = firstVal(cachedData and cachedData.TileImageUrl),
Pubdate        = firstVal(cachedData and cachedData.Pubdate),
Class            = firstVal(cachedData and cachedData.Class),
FrontCoverImg  = firstVal(cachedData and cachedData.FrontCoverImg),
TileLabel        = firstVal(cachedData and cachedData.TileLabel),
TileDescription = firstVal(cachedData and cachedData.TileDescription),
Description    = firstVal(cachedData and cachedData.Description),
}
}
}
}
Line 477: Line 532:
-- Helper function: Generate output for a relationship section.
-- Helper function: Generate output for a relationship section.
-- pageCache is a lookup table of pre-fetched metadata keyed by plain page name.
-- pageCache is a lookup table of pre-fetched metadata keyed by plain page name.
local function generateRelationshipSection(frame, rel, dir, pages, pageCache)
-- Works that name this page as a topic, via the local TextTopics property.
--
-- This is not a RelatedTo relationship, so it is fetched separately, but the
-- rows carry the same printouts as fetchIncomingRelationships so they can be
-- rendered by the same tile code and appear as one more section.
local function fetchTopicOfRelationships(pageA)
local query = mw.smw.ask{
'[[TextTopics::' .. pageA .. ']]',
'?#-=Page',
'?Classification=Classification',
'?Display title of=Title',
'?MediaSeries=MediaSeries',
'?Volumenumber=Volumenumber',
'?Class=Class',
'?TileDescription=TileDescription',
'?TileImageUrl=TileImageUrl',
'?Citation=Citation',
'?Pubdate=Pubdate',
'?FrontCoverImg#=FrontCoverImg',
'?BcaAuthor#=BcaAuthor',
'?Description=Description',
'?FullTitleTib=FullTitleTib',
'?DescriptionTib=DescriptionTib',
'?TileLabel=TileLabel',
limit = RELATIONSHIP_LIMIT
}
 
if not query then
return nil
end
 
local results = {}
for _, row in ipairs(query) do
if row.Page then
table.insert(results, {
Page            = row.Page,
Relationship    = 'Is a topic of',
RelatedTo      = pageA,
Classification  = row.Classification,
Title          = row.Title,
MediaSeries    = row.MediaSeries,
Volumenumber    = row.Volumenumber,
Class          = row.Class,
TileDescription = row.TileDescription,
TileImageUrl    = row.TileImageUrl,
Citation    = row.Citation,
Pubdate      = row.Pubdate,
FrontCoverImg = row.FrontCoverImg,
BcaAuthor    = row.BcaAuthor,
Description  = row.Description,
FullTitleTib    = row.FullTitleTib,
DescriptionTib  = row.DescriptionTib,
TileLabel      = row.TileLabel,
})
end
end
 
if #results == 0 then
return nil
end
return results
end
 
-- Helper function: Build the controls shown above the sections — a text
-- filter and, when there is more than one class, the class pills.
--
-- Same markup and same behaviour as the pills on topic pages: tws.js keys on
-- .topic-biblio-pill and matches data-filter-class against each row's
-- data-class, so nothing extra is needed on the JS side.
--
-- Only worth showing when there is enough to filter. Below the threshold the
-- reader can already see everything, and a row of pills would be more clutter
-- than help.
local function generateControls(frame, classTally)
local total = classTally.__total or 0
if total <= FILTER_THRESHOLD_ROWS then
return ''
end
 
-- Ordered, not alphabetical: Editorial Content first, matching topic pages,
-- then the rest in a stable reading order. Anything not listed follows,
-- sorted, so a new Class still appears without a code change.
local ORDER = {'Editorial Content', 'Books', 'Articles', 'Classical Works',
'Multimedia', 'Dissertations', 'Tibetan Publications', 'Sanskrit Publications',
'Periodical Issues'}
local seen, ordered = {}, {}
for _, cls in ipairs(ORDER) do
if classTally[cls] then
table.insert(ordered, cls)
seen[cls] = true
end
end
local rest = {}
for cls in pairs(classTally) do
if cls ~= '__total' and not seen[cls] then
table.insert(rest, cls)
end
end
table.sort(rest)
for _, cls in ipairs(rest) do
table.insert(ordered, cls)
end
 
-- Fewer than two classes means the pills would be pointless — "All" and the
-- one class filter to the same set — but the text field is still worth
-- having, so only the pill row is dropped, not the whole control bar.
local showPills = #ordered >= 2
 
-- No newline before the first pill. MediaWiki wraps a run that starts on its
-- own line in a <p>, which then becomes the only flex child and collapses
-- the pill spacing. Same trap as on the topic page template.
local out = {'<div class="topic-biblio-controls">'}
 
-- Text filter. The widget emits the same .filter-input the rest of the wiki
-- uses, and tws.js already handles it: it matches accent-insensitively over
-- each .filterable row's text, highlights the hits, and re-applies the class
-- filter afterwards so the two controls compose instead of fighting.
-- Plain wrapper, not a Bootstrap .row: the grid's negative x-margins have no
-- container here to absorb them and push out a horizontal scrollbar. Width
-- and centring are done in CSS instead.
table.insert(out, '<div class="topic-biblio-filter">')
table.insert(out, frame:callParserFunction{
name = '#widget',
args = {
'filterInput',
placeholder = 'Filter ' .. total .. ' related works...'
}
})
table.insert(out, '</div>')
 
if showPills then
table.insert(out, '<div class="topic-biblio-pills">')
table.insert(out, '<span class="topic-biblio-pill is-active" data-filter-class="">All <span class="rel-count">'
.. total .. '</span></span>')
for _, cls in ipairs(ordered) do
table.insert(out, '<span class="topic-biblio-pill" data-filter-class="' .. cls .. '">'
.. cls .. ' <span class="rel-count">' .. classTally[cls] .. '</span></span>')
end
table.insert(out, '</div>')
end
 
table.insert(out, '</div>')
return table.concat(out)
end
 
local function generateRelationshipSection(frame, rel, dir, pages, pageCache, classTally)
-- Determine display name
-- Determine display name
local relName = getRelationshipName(rel, dir == 'left' and 'inverse' or 'forward')
local relName = getRelationshipName(rel, dir == 'left' and 'inverse' or 'forward')
Line 490: Line 690:
-- Page exists on this wiki — render tile directly from cached data, no extra query
-- Page exists on this wiki — render tile directly from cached data, no extra query
table.insert(tiles, generateTile(frame, page, cachedData))
table.insert(tiles, generateTile(frame, page, cachedData))
else
-- Tally the class for the filter pills. Counted here, from what is
-- Page not found on this wiki — show red "PAGE MISSING" tile
-- actually rendered, so a pill can never claim more than is shown.
table.insert(tiles, generateNoExistTile(frame, page, cachedData))
-- A work can appear under several relationships, so this counts row
-- instances rather than distinct works — which is what the pills
-- filter over.
if classTally then
local cls = cachedData and cachedData.Class
if type(cls) == 'table' then cls = cls[1] end
if cls and cls ~= '' then
classTally[cls] = (classTally[cls] or 0) + 1
classTally.__total = (classTally.__total or 0) + 1
end
end
-- else
-- -- Page not found on this wiki — show red "PAGE MISSING" tile
-- table.insert(tiles, generateNoExistTile(frame, page, cachedData))
end
end
end
end
Line 501: Line 714:
end
end


local output = {'<div class="filterableContent clearfix">'}
-- Count comes from #tiles rather than #pagesList, so it reflects what is
table.insert(output, '<h4 class="mx-3">' .. relName .. '</h4><div class="row mx-0">')
-- actually rendered. Pages that do not exist on this wiki are filtered out
-- above, and a heading claiming more items than are shown would be wrong.
-- topic-biblio-section / -list, not the old Bootstrap grid: these are the
-- full-width rows used on topic pages, not two-column tiles. The grid's
-- own "row" class would also collide with .topic-biblio-row.
-- No .filterableContent here: one wrapper spans every section (see main),
-- so the text filter reaches all rows rather than only the section it sits in.
local output = {'<div class="clearfix topic-biblio-section">'}
table.insert(output, '<div class="h4 mx-3"><span class="rel-count">' .. #tiles ..
'</span> ' .. relName .. '</div><div class="topic-biblio-list">')
for _, tile in ipairs(tiles) do
for _, tile in ipairs(tiles) do
table.insert(output, tile)
table.insert(output, tile)
Line 513: Line 735:
-- Main function
-- Main function
function p.main(frame)
function p.main(frame)
-- Get current page name
-- Get current page name.
--
-- Two forms are needed, because the two property families on this wiki store
-- their targets differently. The relationship properties store the FULL page
-- title (Texts/Bodhipathapradīpa) while TextTopics stores the BARE subpage
-- name (Bodhipathapradīpa). Querying only the bare form, as this module used
-- to, made every relationship section come back empty on a subpage while
-- "A topic of" still worked — which read as the topic section crowding the
-- others out, when in fact the others were never being found.
local pageA = mw.title.getCurrentTitle().subpageText
local pageA = mw.title.getCurrentTitle().subpageText
local pageFull = mw.title.getCurrentTitle().prefixedText


-- Fetch relationships using local SMW queries
-- Fetch relationships using local SMW queries
local outgoingRels = fetchOutgoingRelationships(pageA)
local outgoingRels = fetchOutgoingRelationships(pageA)
local incomingRels = fetchIncomingRelationships(pageA)
local incomingRels = fetchIncomingRelationships(pageA, pageFull)
local topicOfRels  = fetchTopicOfRelationships(pageA)


-- Return empty if no relationships found
-- Return empty if nothing relates to this page at all
if not outgoingRels and not incomingRels then
if not outgoingRels and not incomingRels and not topicOfRels then
return ''
return ''
end
end
Line 537: Line 769:
end
end
end
end
-- One query for all outgoing targets: Categories, Classification, Display title, MediaSeries, Volumenumber
-- One query for all outgoing targets: Classification, Display title, MediaSeries, Volumenumber, tile fields
local pageCache = fetchOutgoingPageData(outgoingTargets)
local pageCache = fetchOutgoingPageData(outgoingTargets)


Line 544: Line 776:
processOutgoingRelationships(frame, outgoingRels, allRels)
processOutgoingRelationships(frame, outgoingRels, allRels)
processIncomingRelationships(frame, incomingRels, allRels, pageA, pageCache)
processIncomingRelationships(frame, incomingRels, allRels, pageA, pageCache)
-- TextTopics rows carry the same fields as incoming rows, so they go through
-- the same processor and land in allRels as one more relationship.
processIncomingRelationships(frame, topicOfRels, allRels, pageA, pageCache)


-- Build output
-- Build output
Line 564: Line 799:
end)
end)


-- Generate relationship sections in priority order (pageCache used for library checks)
-- Sections are built first so the pills can be counted from what was
-- actually rendered, then prepended. Counting up front would mean counting
-- pages that get filtered out as non-existent further down.
local sections = {}
local classTally = {}
for _, rel in ipairs(sortedRels) do
for _, rel in ipairs(sortedRels) do
local dirs = allRels[rel]
local dirs = allRels[rel]
for dir, pages in pairs(dirs) do
for dir, pages in pairs(dirs) do
table.insert(output, generateRelationshipSection(frame, rel, dir, pages, pageCache))
table.insert(sections, generateRelationshipSection(frame, rel, dir, pages, pageCache, classTally))
end
end
end
end
table.insert(output, generateControls(frame, classTally))
-- One .filterableContent around every section, so the single text field
-- filters the whole relationships area rather than one section of it.
table.insert(output, '<div class="filterableContent">')
for _, section in ipairs(sections) do
table.insert(output, section)
end
table.insert(output, '</div>')


return table.concat(output)
return table.concat(output)

Latest revision as of 13:31, 25 August 2026

Documentation for this module may be created at Module:GetRels/doc

local p = {}

-- Configuration
local RELATIONSHIP_LIMIT = 500
local FILTER_THRESHOLD_RELS = 5
local FILTER_THRESHOLD_PAGES = 10
-- Show the class pills only once there are more rows than this in total.
local FILTER_THRESHOLD_ROWS = 10

-- Relationship display name mappings
local REL_NAMES = {
	-- Not a RelatedTo relationship. Generated from the local TextTopics property
	-- by fetchTopicOfRelationships, and only ever in one direction, so both
	-- labels are the same.
	["Is a topic of"] = {forward = 'A topic of', inverse = 'A topic of'},
	["Contains a full translation of"] = {forward = 'Full translation of', inverse = 'Full translations'},
	["Contains a modern commentary of"] = {forward = 'Modern commentary of', inverse = 'Modern commentaries'},
	["Contains a partial translation of"] = {forward = 'Partial translation of', inverse = 'Partial translations'},
	["Contains a translated commentary of"] = {forward = 'Translated commentary of', inverse = 'Translated Commentaries'},
	["Contains a recension of"] = {forward = 'Recension of', inverse = 'Recensions'},
	["Contains a review of"] = {forward = 'Review of', inverse = 'Reviews'},
	["Contains a teaching based on"] = {forward = 'Teaching based on', inverse = 'Works containing teachings'},
	["Contains a translation of"] = {forward = 'Translation of', inverse = 'Translations'},
	["Contains polemical content related to"] = {forward = 'Polemical content on', inverse = 'Polemical content'},
	["Contains scholarship related to"] = {forward = 'Scholarship on', inverse = 'Scholarship'},
	["Has a title identical or similar to"] = {forward = 'Similar title', inverse = 'Similar title'},
	["Has other volumes"] = {forward = 'Other volumes', inverse = 'Other volumes'},
	["Is a book based on this dissertation"] = {forward = 'Based on dissertation', inverse = 'Based on dissertation'},
	["Is a chapter or a part of"] = {forward = 'Chapter or part of', inverse = 'Contains chapter or part'},
	["Is a commentary of"] = {forward = 'Classical commentary of', inverse = 'Classical commentaries'},
	["Is a member of"] = {forward = 'Member of', inverse = 'Contains members'},
	["Is a reading of"] = {forward = 'Reading of', inverse = 'Readings'},
	["Is a scholarly presentation about"] = {forward = 'Scholarly presentation about', inverse = 'Scholarly presentations'},
	["Is a teaching based on"] = {forward = 'Teaching on', inverse = 'Teachings'},
	["Is cited in"] = {forward = 'Cited in', inverse = 'Citations'},
	["Is an interview about"] = {forward = 'Interview about', inverse = 'Interviews'},
	["Is another edition of"] = {forward = 'Other editions', inverse = 'Other editions'},
	["Is related to"] = {forward = 'Related', inverse = 'Related'},
	["Shares intertextuality with"] = {forward = 'Shares intertextuality', inverse = 'Shares intertextuality'}
}

-- Relationship directions (horizontal relationships are symmetric/peer-level)
-- All others are vertical (hierarchical/directional)
local HORIZONTAL_RELS = {
	["Has other volumes"] = true,
	["Has a title identical or similar to"] = true,
	["Shares intertextuality with"] = true,
	["Is another edition of"] = true
}

-- Relationship display priority (lower number = higher priority)
local REL_PRIORITY = {
	["Contains a recension of"] = 1,
	["Contains a full translation of"] = 2,
	["Contains a translation of"] = 3,
	["Is a commentary of"] = 4,
	["Contains a modern commentary of"] = 5,
	["Contains a translated commentary of"] = 6,
	["Contains a partial translation of"] = 7,
	["Is another edition of"] = 8,
	["Has other volumes"] = 9,
	["Has a title identical or similar to"] = 10,
	["Is a reading of"] = 11,
	["Is a book based on this dissertation"] = 12,
	["Is a chapter or a part of"] = 13,
	["Is a member of"] = 14,
	["Is a teaching based on"] = 15,
	["Contains a teaching based on"] = 16,
	["Contains a review of"] = 17,
	["Is cited in"] = 18,
	["Contains scholarship related to"] = 19,
	["Is a scholarly presentation about"] = 20,
	["Is an interview about"] = 21,
	["Shares intertextuality with"] = 22,
	["Is related to"] = 23,
	["Contains polemical content related to"] = 24,
	-- TextTopics is not one of the RelatedTo relationships; it is a separate
	-- local property. It is listed last so it reads as broader context after the
	-- specific bibliographic relationships above.
	["Is a topic of"] = 25
}

-- Classification to namespace mapping
local CLASS_NAMESPACE = {
	['Articles'] = 'Articles',
	['Books'] = 'Books',
	['Classical Works'] = 'Texts',
	['Dissertations'] = 'Books',
	['Multimedia'] = 'Media',
	['Periodical Issues'] = 'Books',
	['Sanskrit Publications'] = 'Books',
	['Tibetan Publications'] = 'Books'
}

-- Helper function: Check if a relationship is horizontal (symmetric)
local function isHorizontalRelationship(rel)
	return HORIZONTAL_RELS[rel] == true
end

-- Helper function: Fetch outgoing relationships for a page (page has property pointing to other pages)
-- Single query fetches all relationship properties at once instead of one query per property.
-- limit=1 restricts to 1 subject row (pageA itself); multi-valued properties are returned in full
-- regardless of the row limit, so RELATIONSHIP_LIMIT no longer applies here.
local function fetchOutgoingRelationships(pageA)
	local queryArgs = { '[[' .. pageA .. ']]', limit = 1 }
	for relName, _ in pairs(REL_NAMES) do
		-- Use ?#- operator to get unlinked (plain text) values
		table.insert(queryArgs, '?' .. relName .. '#-')
	end

	local query = mw.smw.ask(queryArgs)
	if not query or not query[1] then return nil end

	local results = {}
	local row = query[1]
	for relName, _ in pairs(REL_NAMES) do
		local targets = row[relName]
		if targets then
			-- Handle both single value and table of values
			if type(targets) == 'table' then
				for _, target in ipairs(targets) do
					table.insert(results, {Relationship = relName, RelatedTo = target})
				end
			else
				table.insert(results, {Relationship = relName, RelatedTo = targets})
			end
		end
	end

	return #results > 0 and results or nil
end

-- Helper function: Fetch incoming relationships (other pages have property pointing to this page).
-- Also fetches Classification, Display title of, MediaSeries, Volumenumber, and tile fields for
-- each result row so that downstream checks (isLibraryItem, isMultiVolumeSeries, buildPageLink,
-- generateTile) can use the cached data instead of issuing additional per-page queries.
local function fetchIncomingRelationships(pageA, pageFull)
	local results = {}

	-- Match either the bare or the full title. Relationship properties store the
	-- full title, but accepting both keeps this working for pages that are not
	-- subpages, and for any entry recorded the other way round.
	local target = pageA
	if pageFull and pageFull ~= pageA then
		target = pageA .. '||' .. pageFull
	end

	for relName, _ in pairs(REL_NAMES) do
		local query = mw.smw.ask{
			'[[' .. relName .. '::' .. target .. ']]',
			'?#-=Page',
			'?Classification=Classification',
			'?Display title of=Title',
			'?MediaSeries=MediaSeries',
			'?Volumenumber=Volumenumber',
			'?Class=Class',
			'?TileDescription=TileDescription',
			'?TileImageUrl=TileImageUrl',
			'?Citation=Citation',
			'?Pubdate=Pubdate',
			'?FrontCoverImg#=FrontCoverImg',
			'?BcaAuthor#=BcaAuthor',
			'?Description=Description',
			'?FullTitleTib=FullTitleTib',
			'?DescriptionTib=DescriptionTib',
			'?TileLabel=TileLabel',
			limit = RELATIONSHIP_LIMIT
		}

		if query then
			for _, row in ipairs(query) do
				local sourcePage = row.Page
				if sourcePage then
					table.insert(results, {
						Page           = sourcePage,
						Relationship   = relName,
						RelatedTo      = pageA,
							Classification = row.Classification,
						Title          = row.Title,
						MediaSeries    = row.MediaSeries,
						Volumenumber   = row.Volumenumber,
						Class          = row.Class,
						TileDescription = row.TileDescription,
						TileImageUrl   = row.TileImageUrl,
						Citation     = row.Citation,
						Pubdate      = row.Pubdate,
						FrontCoverImg = row.FrontCoverImg,
						BcaAuthor    = row.BcaAuthor,
						Description  = row.Description,
						FullTitleTib   = row.FullTitleTib,
						DescriptionTib = row.DescriptionTib,
						TileLabel      = row.TileLabel,
					})
				end
			end
		end
	end

	return #results > 0 and results or nil
end

-- Helper function: Build a lookup table of page metadata from the outgoing targets.
-- Fetches all fields needed for tile rendering plus library/series checks,
-- for all outgoing target pages in a single query, keyed by plain page name.
local function fetchOutgoingPageData(targets)
	if not targets or #targets == 0 then return {} end

	-- Build OR condition: [[PageA]]OR[[PageB]]OR...
	local conditions = {}
	for _, page in ipairs(targets) do
		table.insert(conditions, '[[' .. page .. ']]')
	end

	local query = mw.smw.ask{
		table.concat(conditions, 'OR'),
		'?#-=Page',
		'?Classification=Classification',
		'?Display title of=Title',
		'?MediaSeries=MediaSeries',
		'?Volumenumber=Volumenumber',
		'?Class=Class',
		'?TileDescription=TileDescription',
		'?TileImageUrl=TileImageUrl',
		'?Citation=Citation',
		'?Pubdate=Pubdate',
		'?FrontCoverImg#=FrontCoverImg',
		'?BcaAuthor#=BcaAuthor',
		'?Description=Description',
		'?FullTitleTib=FullTitleTib',
		'?DescriptionTib=DescriptionTib',
		'?TileLabel=TileLabel',
		limit = #targets
	}

	local cache = {}
	if query then
		for _, row in ipairs(query) do
			if row.Page then
				cache[row.Page] = {
					Classification = row.Classification,
					Title          = row.Title,
					MediaSeries    = row.MediaSeries,
					Volumenumber   = row.Volumenumber,
					Class          = row.Class,
					TileDescription = row.TileDescription,
					TileImageUrl   = row.TileImageUrl,
					Citation     = row.Citation,
					Pubdate      = row.Pubdate,
					FrontCoverImg = row.FrontCoverImg,
					BcaAuthor    = row.BcaAuthor,
					Description  = row.Description,
					FullTitleTib   = row.FullTitleTib,
					DescriptionTib = row.DescriptionTib,
					TileLabel      = row.TileLabel,
				}
			end
		end
	end
	return cache
end

-- Helper function: Check if page is a Library Item using pre-fetched metadata.
-- A page with a Classification value is a Library Item on this wiki.
-- Falls back to a targeted SMW query only when Classification is absent, which handles
-- Classical Works stored under a Texts/ prefix or linked via the KeyTextPage property.
local function isLibraryItem(page, cachedData)
	local classification = cachedData and cachedData.Classification
	if type(classification) == 'table' then classification = classification[1] end
	if classification and classification ~= '' then
		return true
	end

	-- Fallback for pages not found in cache (Texts/ prefix or KeyTextPage variants)
	local fallback = mw.smw.ask{
		'[[Category:Library Items]][[Texts/' .. page .. ']]OR[[Category:Library Items]][[KeyTextPage::' .. page .. ']]',
		limit = 1
	}
	return fallback ~= nil and #fallback > 0
end

-- Helper function: Check if page is a multi-volume series using pre-fetched data.
local function isMultiVolumeSeries(cachedData)
	if not cachedData then return false end
	local hasSeries = cachedData.MediaSeries and cachedData.MediaSeries ~= ''
	local vol = cachedData.Volumenumber
	local volNum = type(vol) == 'table' and tonumber(vol[1]) or tonumber(vol)
	return hasSeries and volNum and volNum > 1
end

-- Helper function: Build page link with namespace using pre-fetched metadata.
local function buildPageLink(page, cachedData)
	local classification = cachedData and cachedData.Classification
	local title = cachedData and cachedData.Title

	-- Classification may be a table if multi-valued (take first)
	if type(classification) == 'table' then classification = classification[1] end
	if type(title) == 'table' then title = title[1] end

	local pagename = (title and title ~= '') and title or page
	-- Remove RKTS identifiers
	pagename = string.gsub(pagename, '%s?%(RKTS%w?%s?%d*%)', '')

	local namespace = CLASS_NAMESPACE[classification] or classification or ''
	if namespace ~= '' then
		return namespace .. '/' .. pagename
	end
	return pagename
end

-- Helper function: Remove duplicates from indexed table
local function makeTableUnique(tbl)
	local seen = {}
	local result = {}
	
	for _, value in ipairs(tbl) do
		if not seen[value] then
			seen[value] = true
			table.insert(result, value)
		end
	end
	
	return result
end

-- Helper function: Split string by delimiter
local function splitString(str, delimiter)
	local result = {}
	for match in string.gmatch(str, "([^" .. delimiter .. "]+)") do
		table.insert(result, match)
	end
	return result
end

-- Helper function: Get display name for relationship
local function getRelationshipName(rel, direction)
	if REL_NAMES[rel] then
		return direction == 'forward' and REL_NAMES[rel].forward or REL_NAMES[rel].inverse
	end
	return rel
end

-- Helper function: Process outgoing relationships
local function processOutgoingRelationships(frame, data, allRels)
	if not data then
		return
	end

	for _, item in ipairs(data) do
		local rel = item.Relationship

		if not allRels[rel] then
			allRels[rel] = {}
		end

		if allRels[rel].right then
			allRels[rel].right = allRels[rel].right .. '~' .. item.RelatedTo
		else
			allRels[rel].right = item.RelatedTo
		end
	end
end

-- Helper function: Process incoming relationships.
-- pageCache receives pre-fetched metadata keyed by plain page name.
-- Incoming page metadata (fetched inline) is written into pageCache here so that
-- generateRelationshipSection can look it up by page name later.
local function processIncomingRelationships(frame, data, allRels, pageA, pageCache)
	if not data then
		return
	end

	for _, item in ipairs(data) do
		local pageB = frame:preprocess('{{PAGENAME:' .. item.Page .. '}}')
		local rel = item.Relationship

		if not allRels[rel] then
			allRels[rel] = {}
		end

		-- Build cachedData from inline fields; fall back to pageCache entry if sparse
		local cachedData = {
			Classification  = item.Classification,
			Title           = item.Title,
			MediaSeries     = item.MediaSeries,
			Volumenumber    = item.Volumenumber,
			Class           = item.Class,
			TileDescription = item.TileDescription,
			TileImageUrl    = item.TileImageUrl,
			FullTitleTib    = item.FullTitleTib,
			DescriptionTib  = item.DescriptionTib,
			TileLabel       = item.TileLabel,
			-- Row fields. This table is rebuilt field by field rather than
			-- copied, so anything added to the queries must be listed here too
			-- or it is silently dropped before it reaches the row.
			Citation        = item.Citation,
			Pubdate         = item.Pubdate,
			FrontCoverImg   = item.FrontCoverImg,
			BcaAuthor       = item.BcaAuthor,
			Description     = item.Description,
		}
		if not cachedData.Classification and pageCache[pageB] then
			cachedData = pageCache[pageB]
		end

		-- Store in pageCache so generateRelationshipSection can find it by name
		if not pageCache[pageB] then
			pageCache[pageB] = cachedData
		end

		-- Skip if this is part of a multi-volume series (uses cached data, no extra query)
		if not isMultiVolumeSeries(cachedData) then
			if not isHorizontalRelationship(rel) then
				if allRels[rel].left then
					allRels[rel].left = allRels[rel].left .. '~' .. pageB
				else
					allRels[rel].left = pageB
				end
			elseif not allRels[rel].right then
				-- Horizontal relationships are symmetric. Only use the incoming pass as a fallback
				-- when the outgoing pass found nothing (i.e. the property is only set on page B side).
				allRels[rel].right = pageB
			end
		end
	end
end

-- Helper function: Check if filters are needed
local function shouldShowFilters(allRels)
	local relCount = 0
	for _ in pairs(allRels) do
		relCount = relCount + 1
	end
	
	if relCount > FILTER_THRESHOLD_RELS then
		return true
	end
	
	for _, dirs in pairs(allRels) do
		for _, pages in pairs(dirs) do
			local pageCount = 0
			for _ in string.gmatch(pages, "([^~]+)") do
				pageCount = pageCount + 1
			end
			if pageCount > FILTER_THRESHOLD_PAGES then
				return true
			end
		end
	end
	
	return false
end

-- Helper function: Generate filter widgets
local function generateFilters(frame)
	local filterRels = frame:callParserFunction{
		name = '#widget',
		args = {
			'FilterRelationships',
			id = 'Relationships',
			placeholder = 'Filter by relationships...'
		}
	}
	
	local filterPages = frame:callParserFunction{
		name = '#widget',
		args = {
			'FilterRelationships',
			id = 'Pages',
			placeholder = 'Filter by pages found...'
		}
	}
	
	return '<div class="row"><div class="col-sm-6">' .. filterRels .. '</div><div class="col-sm-6">' .. filterPages .. '</div></div>'
end

-- Helper function: Generate tile for existing page using pre-fetched cached data.
-- Calls RelationshipTile directly, bypassing the RelTileQuery #ask entirely.
local function generateTile(frame, page, cachedData)
	local function firstVal(v)
		if type(v) == 'table' then return v[1] or '' end
		return v or ''
	end

	-- Rendered with TopicBiblioRow, the same row used on topic pages and for the
	-- Explore section above, so a work looks the same wherever it is met.
	--
	-- The row falls back Citation -> Title -> page name, so entries with no
	-- citation still read correctly, and it does its own reverse
	-- AssociatedArticle lookup for narration. Nothing here needs to know about
	-- either.
	return frame:expandTemplate{
		title = 'TopicBiblioRow',
		args = {
			[1]             = page,
			Citation        = firstVal(cachedData and cachedData.Citation),
			BcaAuthor       = firstVal(cachedData and cachedData.BcaAuthor),
			Title           = firstVal(cachedData and cachedData.Title),
			Class           = firstVal(cachedData and cachedData.Class),
			Pubdate         = firstVal(cachedData and cachedData.Pubdate),
			FrontCoverImg   = firstVal(cachedData and cachedData.FrontCoverImg),
			TileDescription = firstVal(cachedData and cachedData.TileDescription),
			Description     = firstVal(cachedData and cachedData.Description),
		}
	}
end

-- Helper function: Generate tile for non-existent page
local function generateNoExistTile(frame, page, cachedData)
	local pageLink = buildPageLink(page, cachedData)
	
	local parts = {
		'<div class="col-md-6 mb-3 mb-md-4 filterable">',
		'<div class="tsdwiki-pop-off tsdwiki-tile tsdwiki-tile-library">',
		'<div class="tsdwiki-tile-library-content" style="flex: 0 0 100%; max-width: 100%;">',
		'<div class="h4 mb-2 p-0 clipped-2-lines border-0 text-140 line13 carnelian">',
		pageLink,
		'</div>',
		'<div class="text-muted clipped-3-lines unclamp-here carnelian">',
		'<b>PAGE MISSING!</b> Please click to create this library item or the redirect that is missing to direct it to the appropriate page.',
		'</div>',
		'<div class="mt-1 mb-0 clipped-1-line carnelian font-italic text-right">',
		'Click to create <i class="fa-sharp-duotone fa-solid fa-rectangle-history-circle-plus"></i>',
		'</div></div>',
		'{{NoExistTileLink|pageLink=' .. pageLink .. '}}',
		'</div></div>',
		'[[Category:Pages with red relationship tiles]]'
	}
	
	return frame:preprocess(table.concat(parts))
end

-- Helper function: Generate output for a relationship section.
-- pageCache is a lookup table of pre-fetched metadata keyed by plain page name.
-- Works that name this page as a topic, via the local TextTopics property.
--
-- This is not a RelatedTo relationship, so it is fetched separately, but the
-- rows carry the same printouts as fetchIncomingRelationships so they can be
-- rendered by the same tile code and appear as one more section.
local function fetchTopicOfRelationships(pageA)
	local query = mw.smw.ask{
		'[[TextTopics::' .. pageA .. ']]',
		'?#-=Page',
		'?Classification=Classification',
		'?Display title of=Title',
		'?MediaSeries=MediaSeries',
		'?Volumenumber=Volumenumber',
		'?Class=Class',
		'?TileDescription=TileDescription',
		'?TileImageUrl=TileImageUrl',
		'?Citation=Citation',
		'?Pubdate=Pubdate',
		'?FrontCoverImg#=FrontCoverImg',
		'?BcaAuthor#=BcaAuthor',
		'?Description=Description',
		'?FullTitleTib=FullTitleTib',
		'?DescriptionTib=DescriptionTib',
		'?TileLabel=TileLabel',
		limit = RELATIONSHIP_LIMIT
	}

	if not query then
		return nil
	end

	local results = {}
	for _, row in ipairs(query) do
		if row.Page then
			table.insert(results, {
				Page            = row.Page,
				Relationship    = 'Is a topic of',
				RelatedTo       = pageA,
				Classification  = row.Classification,
				Title           = row.Title,
				MediaSeries     = row.MediaSeries,
				Volumenumber    = row.Volumenumber,
				Class           = row.Class,
				TileDescription = row.TileDescription,
				TileImageUrl    = row.TileImageUrl,
				Citation     = row.Citation,
				Pubdate      = row.Pubdate,
				FrontCoverImg = row.FrontCoverImg,
				BcaAuthor    = row.BcaAuthor,
				Description  = row.Description,
				FullTitleTib    = row.FullTitleTib,
				DescriptionTib  = row.DescriptionTib,
				TileLabel       = row.TileLabel,
			})
		end
	end

	if #results == 0 then
		return nil
	end
	return results
end

-- Helper function: Build the controls shown above the sections — a text
-- filter and, when there is more than one class, the class pills.
--
-- Same markup and same behaviour as the pills on topic pages: tws.js keys on
-- .topic-biblio-pill and matches data-filter-class against each row's
-- data-class, so nothing extra is needed on the JS side.
--
-- Only worth showing when there is enough to filter. Below the threshold the
-- reader can already see everything, and a row of pills would be more clutter
-- than help.
local function generateControls(frame, classTally)
	local total = classTally.__total or 0
	if total <= FILTER_THRESHOLD_ROWS then
		return ''
	end

	-- Ordered, not alphabetical: Editorial Content first, matching topic pages,
	-- then the rest in a stable reading order. Anything not listed follows,
	-- sorted, so a new Class still appears without a code change.
	local ORDER = {'Editorial Content', 'Books', 'Articles', 'Classical Works',
		'Multimedia', 'Dissertations', 'Tibetan Publications', 'Sanskrit Publications',
		'Periodical Issues'}
	local seen, ordered = {}, {}
	for _, cls in ipairs(ORDER) do
		if classTally[cls] then
			table.insert(ordered, cls)
			seen[cls] = true
		end
	end
	local rest = {}
	for cls in pairs(classTally) do
		if cls ~= '__total' and not seen[cls] then
			table.insert(rest, cls)
		end
	end
	table.sort(rest)
	for _, cls in ipairs(rest) do
		table.insert(ordered, cls)
	end

	-- Fewer than two classes means the pills would be pointless — "All" and the
	-- one class filter to the same set — but the text field is still worth
	-- having, so only the pill row is dropped, not the whole control bar.
	local showPills = #ordered >= 2

	-- No newline before the first pill. MediaWiki wraps a run that starts on its
	-- own line in a <p>, which then becomes the only flex child and collapses
	-- the pill spacing. Same trap as on the topic page template.
	local out = {'<div class="topic-biblio-controls">'}

	-- Text filter. The widget emits the same .filter-input the rest of the wiki
	-- uses, and tws.js already handles it: it matches accent-insensitively over
	-- each .filterable row's text, highlights the hits, and re-applies the class
	-- filter afterwards so the two controls compose instead of fighting.
	-- Plain wrapper, not a Bootstrap .row: the grid's negative x-margins have no
	-- container here to absorb them and push out a horizontal scrollbar. Width
	-- and centring are done in CSS instead.
	table.insert(out, '<div class="topic-biblio-filter">')
	table.insert(out, frame:callParserFunction{
		name = '#widget',
		args = {
			'filterInput',
			placeholder = 'Filter ' .. total .. ' related works...'
		}
	})
	table.insert(out, '</div>')

	if showPills then
		table.insert(out, '<div class="topic-biblio-pills">')
		table.insert(out, '<span class="topic-biblio-pill is-active" data-filter-class="">All <span class="rel-count">'
			.. total .. '</span></span>')
		for _, cls in ipairs(ordered) do
			table.insert(out, '<span class="topic-biblio-pill" data-filter-class="' .. cls .. '">'
				.. cls .. ' <span class="rel-count">' .. classTally[cls] .. '</span></span>')
		end
		table.insert(out, '</div>')
	end

	table.insert(out, '</div>')
	return table.concat(out)
end

local function generateRelationshipSection(frame, rel, dir, pages, pageCache, classTally)
	-- Determine display name
	local relName = getRelationshipName(rel, dir == 'left' and 'inverse' or 'forward')

	-- Process pages, collecting only existing ones
	local pagesList = makeTableUnique(splitString(pages, '~'))
	local tiles = {}

	for _, page in ipairs(pagesList) do
		local cachedData = pageCache[page]
		if isLibraryItem(page, cachedData) then
			-- Page exists on this wiki — render tile directly from cached data, no extra query
			table.insert(tiles, generateTile(frame, page, cachedData))
			-- Tally the class for the filter pills. Counted here, from what is
			-- actually rendered, so a pill can never claim more than is shown.
			-- A work can appear under several relationships, so this counts row
			-- instances rather than distinct works — which is what the pills
			-- filter over.
			if classTally then
				local cls = cachedData and cachedData.Class
				if type(cls) == 'table' then cls = cls[1] end
				if cls and cls ~= '' then
					classTally[cls] = (classTally[cls] or 0) + 1
					classTally.__total = (classTally.__total or 0) + 1
				end
			end
		-- else
		-- 	-- Page not found on this wiki — show red "PAGE MISSING" tile
		-- 	table.insert(tiles, generateNoExistTile(frame, page, cachedData))
		end
	end

	-- Hide entire section if no related pages exist on this wiki
	if #tiles == 0 then
		return ''
	end

	-- Count comes from #tiles rather than #pagesList, so it reflects what is
	-- actually rendered. Pages that do not exist on this wiki are filtered out
	-- above, and a heading claiming more items than are shown would be wrong.
	-- topic-biblio-section / -list, not the old Bootstrap grid: these are the
	-- full-width rows used on topic pages, not two-column tiles. The grid's
	-- own "row" class would also collide with .topic-biblio-row.
	-- No .filterableContent here: one wrapper spans every section (see main),
	-- so the text filter reaches all rows rather than only the section it sits in.
	local output = {'<div class="clearfix topic-biblio-section">'}
	table.insert(output, '<div class="h4 mx-3"><span class="rel-count">' .. #tiles ..
		'</span> ' .. relName .. '</div><div class="topic-biblio-list">')
	for _, tile in ipairs(tiles) do
		table.insert(output, tile)
	end
	table.insert(output, '</div></div>')

	return table.concat(output)
end

-- Main function
function p.main(frame)
	-- Get current page name.
	--
	-- Two forms are needed, because the two property families on this wiki store
	-- their targets differently. The relationship properties store the FULL page
	-- title (Texts/Bodhipathapradīpa) while TextTopics stores the BARE subpage
	-- name (Bodhipathapradīpa). Querying only the bare form, as this module used
	-- to, made every relationship section come back empty on a subpage while
	-- "A topic of" still worked — which read as the topic section crowding the
	-- others out, when in fact the others were never being found.
	local pageA = mw.title.getCurrentTitle().subpageText
	local pageFull = mw.title.getCurrentTitle().prefixedText

	-- Fetch relationships using local SMW queries
	local outgoingRels = fetchOutgoingRelationships(pageA)
	local incomingRels = fetchIncomingRelationships(pageA, pageFull)
	local topicOfRels  = fetchTopicOfRelationships(pageA)

	-- Return empty if nothing relates to this page at all
	if not outgoingRels and not incomingRels and not topicOfRels then
		return ''
	end

	-- Collect all unique outgoing target page names so we can batch-fetch their metadata.
	-- Incoming results already carry inline metadata from fetchIncomingRelationships.
	local outgoingTargets = {}
	local seenTargets = {}
	if outgoingRels then
		for _, item in ipairs(outgoingRels) do
			if not seenTargets[item.RelatedTo] then
				seenTargets[item.RelatedTo] = true
				table.insert(outgoingTargets, item.RelatedTo)
			end
		end
	end
	-- One query for all outgoing targets: Classification, Display title, MediaSeries, Volumenumber, tile fields
	local pageCache = fetchOutgoingPageData(outgoingTargets)

	-- Organize relationships (incoming metadata comes inline; outgoing uses pageCache)
	local allRels = {}
	processOutgoingRelationships(frame, outgoingRels, allRels)
	processIncomingRelationships(frame, incomingRels, allRels, pageA, pageCache)
	-- TextTopics rows carry the same fields as incoming rows, so they go through
	-- the same processor and land in allRels as one more relationship.
	processIncomingRelationships(frame, topicOfRels, allRels, pageA, pageCache)

	-- Build output
	local output = {}

	-- -- Add filters if needed
	-- if shouldShowFilters(allRels) then
	-- 	table.insert(output, generateFilters(frame))
	-- end

	-- Sort relationships by priority
	local sortedRels = {}
	for rel, _ in pairs(allRels) do
		table.insert(sortedRels, rel)
	end
	table.sort(sortedRels, function(a, b)
		local prioA = REL_PRIORITY[a] or 999
		local prioB = REL_PRIORITY[b] or 999
		return prioA < prioB
	end)

	-- Sections are built first so the pills can be counted from what was
	-- actually rendered, then prepended. Counting up front would mean counting
	-- pages that get filtered out as non-existent further down.
	local sections = {}
	local classTally = {}
	for _, rel in ipairs(sortedRels) do
		local dirs = allRels[rel]
		for dir, pages in pairs(dirs) do
			table.insert(sections, generateRelationshipSection(frame, rel, dir, pages, pageCache, classTally))
		end
	end

	table.insert(output, generateControls(frame, classTally))
	-- One .filterableContent around every section, so the single text field
	-- filters the whole relationships area rather than one section of it.
	table.insert(output, '<div class="filterableContent">')
	for _, section in ipairs(sections) do
		table.insert(output, section)
	end
	table.insert(output, '</div>')

	return table.concat(output)
end

return p