Module:SscCitations: Difference between revisions

From Bodhicitta
(Open "Read the passage" in a new tab)
(Widen to every aligned translation, not just the Śikṣāsamuccaya)
Line 1: Line 1:
local p = {}
local p = {}


-- Citations of a text in the Śikṣāsamuccaya.
-- Where this text is quoted across the wiki's aligned translations.
--
--
-- The SSC is segmented and aligned in the Translation Memory namespace, one page
-- Several works have been segmented and aligned in the Translation Memory
-- per segment. A segment that quotes another work carries SegmentFormat=Quote
-- namespace. A segment that quotes another work carries a QuoteSource naming it,
-- and a QuoteSource naming the work, usually with its RKTS catalogue number:
-- usually with its RKTS catalogue number:
--
--
--  QuoteSource=Ratnameghasūtra (RKTSK 231)
--  QuoteSource=Ratnameghasūtra (RKTSK 231)
Line 11: Line 11:
-- Text pages hold the same identifier in DrlPageName ("RKTSK 231"), so that is
-- Text pages hold the same identifier in DrlPageName ("RKTSK 231"), so that is
-- the join: no title matching, unaffected by spelling, and it keeps Kangyur
-- the join: no title matching, unaffected by spelling, and it keeps Kangyur
-- (RKTSK) and Tengyur (RKTST) texts distinct. 60 of the 63 works the SSC quotes
-- (RKTSK) and Tengyur (RKTST) texts distinct.
-- have a page here, covering 92% of quote segments.
--
--
-- THE POINT OF THIS MODULE: one quotation is often split across several
-- Sixteen alignments carry quote data, 3,031 segments between them. 112 text
-- consecutive segments. Counting segments therefore overstates the citations
-- pages are cited by at least one, and 53 by more than one — the
-- badly — the Pitāputrasamāgamanasūtra has 62 quote segments but is quoted 14
-- Laṅkāvatārasūtra is quoted in six different works. Showing only one alignment,
-- times, and one run is 36 segments long. Across the SSC, 633 segments are 345
-- as this module first did for the Śikṣāsamuccaya, hid most of that.
-- quotations. SMW has no notion of "consecutive", so the grouping is done here.
--
-- COUNTS ARE QUOTATIONS, NOT SEGMENTS. One quotation is often split across
-- consecutive segments, so counting segments overstates badly: the
-- Pitāputrasamāgamanasūtra has 62 quote segments in the SSC but is quoted 14
-- times, and one of its runs is 36 segments long. SMW cannot express
-- "consecutive", so the grouping is done here.


-- The SSC page itself. Its own segments quote it 83 times through internal
-- cross-references, which is an artefact of the alignment rather than a
-- citation of one work by another, so the block is suppressed there.
local SSC_PAGE = 'Texts/Śikṣāsamuccaya'
-- The bilingual layout of the SSC translation, one page per chapter. The
-- chapter number is the last path element.
local BILINGUAL_BASE = 'Books/The Training Anthology of Śāntideva/Bilingual/'
local SSC_PREFIX = '~Translation Memory:016-Tsadra-SSC-Root/*'
-- Every quote segment in the SSC is fetched, not just one work's, because the
-- filtering happens in Lua. 633 at present.
local SEGMENT_LIMIT = 5000
local SEGMENT_LIMIT = 5000


-- A quotation past this many words of translated text is flagged as long.
-- A quotation past this many words of translated text is flagged as long.
-- Word count, not segment count: segments vary wildly in size, and the single
-- Word count, not segment count: segments vary wildly in size, and the single
-- longest quotation in the corpus (Ratnolkādhāraṇī, 8,330 words) is ONE
-- longest quotation in the corpus (8,330 words) is ONE segment.
-- segment, so a segment count would have called it small. The median quotation
-- is 139 words and the 75th percentile 218, so 400 is clearly beyond normal
-- without being so rare it never shows.
local LONG_QUOTE_WORDS = 400
local LONG_QUOTE_WORDS = 400
-- Alignments whose bilingual layout is a single page with no chapter subpages.
-- The segments carry no TranslationChapter, so the link is the base page plus
-- the fragment.
local SINGLE_PAGE_BILINGUAL = {
['013-Tsadra-BCA-Com-Minyak-Ch9'] = true,
['011-Tsadra-BCA-Com-Pawo'] = true,
}
-- Alignments whose bilingual subpage is named for the work rather than a
-- chapter. Three Bhāvanākramas share one translation, one page each.
local NAMED_BILINGUAL_SUBPAGE = {
['020-Tsadra-BC-Root-Bhavanakrama-1'] = 'Bhavanakrama-1',
['020-Tsadra-BC-Root-Bhavanakrama-2'] = 'Bhavanakrama-2',
['020-Tsadra-BC-Root-Bhavanakrama-3'] = 'Bhavanakrama-3',
}


local function firstValue(v)
local function firstValue(v)
if type(v) == 'table' then return v[1] end
if type(v) == 'table' then return v[1] end
return v
return v
end
local function trim(s)
if type(s) ~= 'string' then return nil end
s = mw.text.trim(s)
if s == '' then return nil end
return s
end
end


Line 50: Line 62:
-- canonical text, which is what keeps the feature off every other page without
-- canonical text, which is what keeps the feature off every other page without
-- needing a flag.
-- needing a flag.
local function rktsId(frame)
local function rktsId()
local title = mw.title.getCurrentTitle().prefixedText
local title = mw.title.getCurrentTitle().prefixedText
if title == SSC_PAGE then return nil end
local query = mw.smw.ask{
local query = mw.smw.ask{
'[[' .. title .. ']]',
'[[' .. title .. ']]',
Line 58: Line 69:
limit = 1
limit = 1
}
}
if not query or not query[1] then return nil end
if not query or not query[1] then return nil, title end
local id = firstValue(query[1].DrlPageName)
local id = trim(firstValue(query[1].DrlPageName))
if type(id) ~= 'string' then return nil end
if id and id:match('^RKTS[KT]%s') then return id, title end
id = mw.text.trim(id)
return nil, title
if id:match('^RKTS[KT]%s') then return id end
end
return nil
 
-- Where a quotation links to in its alignment's bilingual layout.
local function bilingualLink(tm, base, chapter, order)
if not base then return nil end
local named = NAMED_BILINGUAL_SUBPAGE[tm]
if named then
return base .. '/Bilingual/' .. named .. '#seg-' .. order
end
if SINGLE_PAGE_BILINGUAL[tm] or not chapter or chapter == '' then
return base .. '/Bilingual#seg-' .. order
end
return base .. '/Bilingual/' .. chapter .. '#seg-' .. order
end
end


-- Every quote segment naming this id, grouped into quotations.
-- Every quote segment naming this id, grouped into quotations, per alignment.
local function fetchQuotations(id)
local function fetchByAlignment(id, selfTitle)
-- Every quote segment, filtered in Lua rather than by the query.
--
-- A wildcard on QuoteSource cannot do this correctly. "~*RKTSK 12*" also
-- matches RKTSK 127 and RKTSK 129, and anchoring on the closing parenthesis
-- to fix that then MISSES segments where the id is not last, because a
-- segment may name several sources at once:
--
--  Bodhisattvapratimokṣa… (RKTSK 248);Vinayaviniścayopāli… (RKTSK 68)
--
-- That combination silently halved the Vinayaviniścayopāliparipṛcchāsūtra
-- (13 quotations reported as 6) and zeroed the Pañcaviṃśatisāhasrikā.
-- Fetching the quote segments and matching "(id)" in Lua handles both.
local query = mw.smw.ask{
local query = mw.smw.ask{
'[[' .. SSC_PREFIX .. ']][[QuoteSource::+]]',
'[[QuoteSource::+]]',
'?#-=Page',
'?TransMemID#=TransMemID',
'?QuoteSource#=QuoteSource',
'?QuoteSource#=QuoteSource',
'?#-=Page',
'?SegmentOrder#=SegmentOrder',
'?SegmentOrder#=SegmentOrder',
'?TranslationChapter#=TranslationChapter',
'?TranslationChapter#=TranslationChapter',
Line 89: Line 100:
'?SourcePageNumber#=SourcePageNumber',
'?SourcePageNumber#=SourcePageNumber',
'?SegmentTranslation#=SegmentTranslation',
'?SegmentTranslation#=SegmentTranslation',
'?TranslationWikiPage#=TranslationWikiPage',
limit = SEGMENT_LIMIT
limit = SEGMENT_LIMIT
}
}
if not query then return {} end
if not query then return {} end


-- Matched in Lua, not by the query. A wildcard on QuoteSource cannot do this
-- correctly: "~*RKTSK 12*" also matches RKTSK 127 and 129, and anchoring on
-- the closing bracket to fix that then misses segments where the id is not
-- last — a segment may name several sources at once:
--
--  Bodhisattvapratimokṣa… (RKTSK 248);Vinayaviniścayopāli… (RKTSK 68)
local needle = '(' .. id .. ')'
local needle = '(' .. id .. ')'
local segments = {}
 
local byTm = {}
for _, row in ipairs(query) do
for _, row in ipairs(query) do
local source = firstValue(row.QuoteSource)
local order = tonumber(firstValue(row.SegmentOrder))
local order = tonumber(firstValue(row.SegmentOrder))
local source = firstValue(row.QuoteSource)
if order and type(source) == 'string' and source:find(needle, 1, true) then
if order and type(source) == 'string'
local tm = trim(firstValue(row.TransMemID)) or '(unknown)'
and source:find(needle, 1, true) then
local text = firstValue(row.SegmentTranslation)
local text = firstValue(row.SegmentTranslation)
local words = 0
local words = 0
Line 105: Line 124:
for _ in text:gmatch('%S+') do words = words + 1 end
for _ in text:gmatch('%S+') do words = words + 1 end
end
end
table.insert(segments, {
byTm[tm] = byTm[tm] or { segments = {} }
byTm[tm].base = byTm[tm].base or trim(firstValue(row.TranslationWikiPage))
table.insert(byTm[tm].segments, {
order  = order,
order  = order,
page    = firstValue(row.Page),
chapter = trim(firstValue(row.TranslationChapter)),
chapter = firstValue(row.TranslationChapter),
folio  = trim(firstValue(row.SourcePageNumber)),
folio  = firstValue(row.SourcePageNumber),
pageNum = trim(firstValue(row.TranslationPageNumber)),
pageNum = firstValue(row.TranslationPageNumber),
words  = words,
words  = words,
})
})
Line 116: Line 136:
end
end


table.sort(segments, function(a, b) return a.order < b.order end)
-- Collapse runs of consecutive SegmentOrder into one quotation, anchored to
-- the lowest order so a link lands at the start of the passage. A chapter
-- change breaks a run: two adjacent quotes in different chapters are two
-- quotations.
local alignments = {}
for tm, data in pairs(byTm) do
table.sort(data.segments, function(a, b) return a.order < b.order end)
local quotations = {}
for _, seg in ipairs(data.segments) do
local last = quotations[#quotations]
if last and seg.order == last.lastOrder + 1 and seg.chapter == last.chapter then
last.lastOrder = seg.order
last.words = last.words + seg.words
else
table.insert(quotations, {
order    = seg.order,
lastOrder = seg.order,
chapter  = seg.chapter,
folio    = seg.folio,
pageNum  = seg.pageNum,
words    = seg.words,
})
end
end


-- Collapse runs of consecutive SegmentOrder into one quotation, anchored to
-- A work does not cite itself: an alignment OF this text quoting this
-- the lowest order so a link lands at the start of the passage rather than
-- text is the alignment cross-referencing itself, not a citation.
-- somewhere in its middle.
local isSelf = data.base and (data.base == selfTitle)
--
if #quotations > 0 and not isSelf then
-- A chapter change breaks a run: two adjacent quotes from the same work in
table.insert(alignments, {
-- different chapters of the SSC are two quotations. No run in the current
tm        = tm,
-- data actually crosses a chapter boundary, but the rule is correct and
base      = data.base,
-- costs nothing.
quotations = quotations,
local quotations = {}
count      = #quotations,
for _, seg in ipairs(segments) do
isRoot     = tm:find('%-Root%-') ~= nil or tm:match('%-Root$') ~= nil,
local last = quotations[#quotations]
if last and seg.order == last.lastOrder + 1 and seg.chapter == last.chapter then
last.lastOrder = seg.order
last.words = last.words + seg.words
else
table.insert(quotations, {
page      = seg.page,
order    = seg.order,
lastOrder = seg.order,
chapter  = seg.chapter,
folio     = seg.folio,
pageNum  = seg.pageNum,
words    = seg.words,
})
})
end
end
end
end


return quotations
-- Root texts before commentaries, then most-quoted first. A root text
-- quoting this work is a stronger fact than a commentary doing so.
table.sort(alignments, function(a, b)
if a.isRoot ~= b.isRoot then return a.isRoot end
if a.count ~= b.count then return a.count > b.count end
return a.tm < b.tm
end)
 
return alignments
end
end


-- Number of distinct quotations, for the button label and for deciding whether
-- Cached: the template asks for the count to decide whether to render, then for
-- to render anything at all.
-- the body. One query per page rather than two.
-- The template asks for the count to decide whether to render, then for the
-- list. Cached so that is one query per page rather than three.
local cache = nil
local cache = nil
local function quotations(frame)
local function alignments()
if cache == nil then
if cache == nil then
local id = rktsId(frame)
local id, title = rktsId()
cache = id and fetchQuotations(id) or {}
cache = id and fetchByAlignment(id, title) or {}
end
end
return cache
return cache
end
end


function p.count(frame)
-- Total quotations across every alignment, for the heading.
local list = quotations(frame)
function p.count()
local list = alignments()
local total = 0
for _, a in ipairs(list) do total = total + a.count end
if total == 0 then return '' end
return tostring(total)
end
 
-- How many works cite this one, for the heading.
function p.works()
local list = alignments()
if #list == 0 then return '' end
if #list == 0 then return '' end
return tostring(#list)
return tostring(#list)
end
end


-- The list itself.
local function renderQuotation(out, q, a, index)
table.insert(out, '<div class="ssc-citation">')
table.insert(out, '<span class="ssc-citation-index">' .. index .. '</span>')
table.insert(out, '<span class="ssc-citation-body">')
if q.chapter and q.chapter ~= '' then
table.insert(out, '<span class="ssc-citation-chapter">Chapter ' .. q.chapter .. '</span>')
end
-- Folio first: SourcePageNumber is the block-print folio ("114a"), which is
-- what a scholar cites. It can hold several values when a passage spans
-- folios, so only the first is shown.
if q.folio then
table.insert(out, '<span class="ssc-citation-page">folio&nbsp;' ..
(q.folio:gsub(';.*$', '')) .. '</span>')
elseif q.pageNum then
table.insert(out, '<span class="ssc-citation-page">p.&nbsp;' .. q.pageNum .. '</span>')
end
if q.words > LONG_QUOTE_WORDS then
table.insert(out, '<span class="ssc-citation-long">long quotation</span>')
end
table.insert(out, '</span>')
 
-- External-link syntax on a fullurl, not a [[wikilink]]: wiki links cannot
-- carry target, and these pages take seconds to load — some over twenty — so
-- following one and coming back is expensive. $wgExternalLinkTarget supplies
-- target=_blank and the rel attributes.
local link = bilingualLink(a.tm, a.base, q.chapter, q.order)
if link then
local page, fragment = link:match('^(.-)(#.*)$')
table.insert(out, '<span class="ssc-citation-link ssc-citation-external">[' ..
'{{fullurl:' .. page .. '}}' .. fragment .. ' Read the passage]</span>')
end
table.insert(out, '</div>')
end
 
-- One panel, with a section per citing work.
function p.list(frame)
function p.list(frame)
local list = quotations(frame)
local list = alignments()
if #list == 0 then return '' end
if #list == 0 then return '' end


local out = {'<div class="ssc-citations">'}
local out = {'<div class="ssc-citations">'}
for i, q in ipairs(list) do
for _, a in ipairs(list) do
table.insert(out, '<div class="ssc-citation">')
local label = a.base and a.base:gsub('^[^/]+/', '') or a.tm
table.insert(out, '<span class="ssc-citation-index">' .. i .. '</span>')
table.insert(out, '<div class="ssc-work">')
table.insert(out, '<span class="ssc-citation-body">')
table.insert(out, '<div class="ssc-work-head">')
if q.chapter and q.chapter ~= '' then
table.insert(out, '<span class="ssc-work-title">' ..
table.insert(out, '<span class="ssc-citation-chapter">Chapter ' ..
(a.base and ('[[' .. a.base .. '|' .. label .. ']]') or label) .. '</span>')
tostring(q.chapter) .. '</span>')
if a.isRoot then
end
table.insert(out, '<span class="ssc-work-kind">root text</span>')
-- Folio first: SourcePageNumber is the Tibetan block-print folio ("114a"),
-- which is what a scholar cites. It can hold several values when a
-- passage spans folios, so only the first — the start of the quotation —
-- is shown. TranslationPageNumber is the printed English page, kept as a
-- fallback for the handful of segments with no folio.
if q.folio and q.folio ~= '' then
local folio = tostring(q.folio):gsub(';.*$', '')
table.insert(out, '<span class="ssc-citation-page">folio&nbsp;' ..
folio .. '</span>')
elseif q.pageNum and q.pageNum ~= '' then
table.insert(out, '<span class="ssc-citation-page">p.&nbsp;' ..
tostring(q.pageNum) .. '</span>')
end
if q.words > LONG_QUOTE_WORDS then
table.insert(out, '<span class="ssc-citation-long">long quotation</span>')
end
end
table.insert(out, '</span>')
table.insert(out, '<span class="ssc-work-count">' .. a.count ..
-- Link into the bilingual layout rather than the bare TM segment page:
(a.count == 1 and ' quotation' or ' quotations') .. '</span>')
-- that is where the passage is readable side by side. The chapter is the
table.insert(out, '</div>')
-- last path element, and the anchor is the segment number, which
for i, q in ipairs(a.quotations) do
-- Template:DisplaySegmentRow already emits as the row's id.
renderQuotation(out, q, a, i)
--
-- Falls back to the segment page when the quotation has no chapter,
-- since there would be nothing to build the path from.
if q.chapter and q.chapter ~= '' then
-- External-link syntax on a fullurl, not a [[wikilink]]. Wiki links
-- cannot carry target, and these pages take seconds to load — some
-- chapters over twenty — so following one from the citation list
-- and coming back is expensive. This opens a tab instead;
-- $wgExternalLinkTarget supplies target=_blank and the rel
-- attributes that go with it.
--
-- seg- prefix, not the bare number: "#913" is not a valid CSS
-- selector, and any script that hands an anchor's href to
-- querySelector throws on it. DisplaySegmentRow emits both ids.
table.insert(out, '<span class="ssc-citation-link ssc-citation-external">[' ..
'{{fullurl:' .. BILINGUAL_BASE .. tostring(q.chapter) .. '}}' ..
'#seg-' .. tostring(q.order) ..
' Read the passage]</span>')
elseif q.page then
table.insert(out, '<span class="ssc-citation-link">[[' .. q.page ..
'|Read the passage]]</span>')
end
end
table.insert(out, '</div>')
table.insert(out, '</div>')

Revision as of 14:30, 28 August 2026

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

local p = {}

-- Where this text is quoted across the wiki's aligned translations.
--
-- Several works have been segmented and aligned in the Translation Memory
-- namespace. A segment that quotes another work carries a QuoteSource naming it,
-- usually with its RKTS catalogue number:
--
--   QuoteSource=Ratnameghasūtra (RKTSK 231)
--
-- Text pages hold the same identifier in DrlPageName ("RKTSK 231"), so that is
-- the join: no title matching, unaffected by spelling, and it keeps Kangyur
-- (RKTSK) and Tengyur (RKTST) texts distinct.
--
-- Sixteen alignments carry quote data, 3,031 segments between them. 112 text
-- pages are cited by at least one, and 53 by more than one — the
-- Laṅkāvatārasūtra is quoted in six different works. Showing only one alignment,
-- as this module first did for the Śikṣāsamuccaya, hid most of that.
--
-- COUNTS ARE QUOTATIONS, NOT SEGMENTS. One quotation is often split across
-- consecutive segments, so counting segments overstates badly: the
-- Pitāputrasamāgamanasūtra has 62 quote segments in the SSC but is quoted 14
-- times, and one of its runs is 36 segments long. SMW cannot express
-- "consecutive", so the grouping is done here.

local SEGMENT_LIMIT = 5000

-- A quotation past this many words of translated text is flagged as long.
-- Word count, not segment count: segments vary wildly in size, and the single
-- longest quotation in the corpus (8,330 words) is ONE segment.
local LONG_QUOTE_WORDS = 400

-- Alignments whose bilingual layout is a single page with no chapter subpages.
-- The segments carry no TranslationChapter, so the link is the base page plus
-- the fragment.
local SINGLE_PAGE_BILINGUAL = {
	['013-Tsadra-BCA-Com-Minyak-Ch9'] = true,
	['011-Tsadra-BCA-Com-Pawo'] = true,
}

-- Alignments whose bilingual subpage is named for the work rather than a
-- chapter. Three Bhāvanākramas share one translation, one page each.
local NAMED_BILINGUAL_SUBPAGE = {
	['020-Tsadra-BC-Root-Bhavanakrama-1'] = 'Bhavanakrama-1',
	['020-Tsadra-BC-Root-Bhavanakrama-2'] = 'Bhavanakrama-2',
	['020-Tsadra-BC-Root-Bhavanakrama-3'] = 'Bhavanakrama-3',
}

local function firstValue(v)
	if type(v) == 'table' then return v[1] end
	return v
end

local function trim(s)
	if type(s) ~= 'string' then return nil end
	s = mw.text.trim(s)
	if s == '' then return nil end
	return s
end

-- The RKTS id for this page, e.g. "RKTSK 231". Absent on anything that is not a
-- canonical text, which is what keeps the feature off every other page without
-- needing a flag.
local function rktsId()
	local title = mw.title.getCurrentTitle().prefixedText
	local query = mw.smw.ask{
		'[[' .. title .. ']]',
		'?DrlPageName#=DrlPageName',
		limit = 1
	}
	if not query or not query[1] then return nil, title end
	local id = trim(firstValue(query[1].DrlPageName))
	if id and id:match('^RKTS[KT]%s') then return id, title end
	return nil, title
end

-- Where a quotation links to in its alignment's bilingual layout.
local function bilingualLink(tm, base, chapter, order)
	if not base then return nil end
	local named = NAMED_BILINGUAL_SUBPAGE[tm]
	if named then
		return base .. '/Bilingual/' .. named .. '#seg-' .. order
	end
	if SINGLE_PAGE_BILINGUAL[tm] or not chapter or chapter == '' then
		return base .. '/Bilingual#seg-' .. order
	end
	return base .. '/Bilingual/' .. chapter .. '#seg-' .. order
end

-- Every quote segment naming this id, grouped into quotations, per alignment.
local function fetchByAlignment(id, selfTitle)
	local query = mw.smw.ask{
		'[[QuoteSource::+]]',
		'?#-=Page',
		'?TransMemID#=TransMemID',
		'?QuoteSource#=QuoteSource',
		'?SegmentOrder#=SegmentOrder',
		'?TranslationChapter#=TranslationChapter',
		'?TranslationPageNumber#=TranslationPageNumber',
		'?SourcePageNumber#=SourcePageNumber',
		'?SegmentTranslation#=SegmentTranslation',
		'?TranslationWikiPage#=TranslationWikiPage',
		limit = SEGMENT_LIMIT
	}
	if not query then return {} end

	-- Matched in Lua, not by the query. A wildcard on QuoteSource cannot do this
	-- correctly: "~*RKTSK 12*" also matches RKTSK 127 and 129, and anchoring on
	-- the closing bracket to fix that then misses segments where the id is not
	-- last — a segment may name several sources at once:
	--
	--   Bodhisattvapratimokṣa… (RKTSK 248);Vinayaviniścayopāli… (RKTSK 68)
	local needle = '(' .. id .. ')'

	local byTm = {}
	for _, row in ipairs(query) do
		local source = firstValue(row.QuoteSource)
		local order = tonumber(firstValue(row.SegmentOrder))
		if order and type(source) == 'string' and source:find(needle, 1, true) then
			local tm = trim(firstValue(row.TransMemID)) or '(unknown)'
			local text = firstValue(row.SegmentTranslation)
			local words = 0
			if type(text) == 'string' then
				for _ in text:gmatch('%S+') do words = words + 1 end
			end
			byTm[tm] = byTm[tm] or { segments = {} }
			byTm[tm].base = byTm[tm].base or trim(firstValue(row.TranslationWikiPage))
			table.insert(byTm[tm].segments, {
				order   = order,
				chapter = trim(firstValue(row.TranslationChapter)),
				folio   = trim(firstValue(row.SourcePageNumber)),
				pageNum = trim(firstValue(row.TranslationPageNumber)),
				words   = words,
			})
		end
	end

	-- Collapse runs of consecutive SegmentOrder into one quotation, anchored to
	-- the lowest order so a link lands at the start of the passage. A chapter
	-- change breaks a run: two adjacent quotes in different chapters are two
	-- quotations.
	local alignments = {}
	for tm, data in pairs(byTm) do
		table.sort(data.segments, function(a, b) return a.order < b.order end)
		local quotations = {}
		for _, seg in ipairs(data.segments) do
			local last = quotations[#quotations]
			if last and seg.order == last.lastOrder + 1 and seg.chapter == last.chapter then
				last.lastOrder = seg.order
				last.words = last.words + seg.words
			else
				table.insert(quotations, {
					order     = seg.order,
					lastOrder = seg.order,
					chapter   = seg.chapter,
					folio     = seg.folio,
					pageNum   = seg.pageNum,
					words     = seg.words,
				})
			end
		end

		-- A work does not cite itself: an alignment OF this text quoting this
		-- text is the alignment cross-referencing itself, not a citation.
		local isSelf = data.base and (data.base == selfTitle)
		if #quotations > 0 and not isSelf then
			table.insert(alignments, {
				tm         = tm,
				base       = data.base,
				quotations = quotations,
				count      = #quotations,
				isRoot     = tm:find('%-Root%-') ~= nil or tm:match('%-Root$') ~= nil,
			})
		end
	end

	-- Root texts before commentaries, then most-quoted first. A root text
	-- quoting this work is a stronger fact than a commentary doing so.
	table.sort(alignments, function(a, b)
		if a.isRoot ~= b.isRoot then return a.isRoot end
		if a.count ~= b.count then return a.count > b.count end
		return a.tm < b.tm
	end)

	return alignments
end

-- Cached: the template asks for the count to decide whether to render, then for
-- the body. One query per page rather than two.
local cache = nil
local function alignments()
	if cache == nil then
		local id, title = rktsId()
		cache = id and fetchByAlignment(id, title) or {}
	end
	return cache
end

-- Total quotations across every alignment, for the heading.
function p.count()
	local list = alignments()
	local total = 0
	for _, a in ipairs(list) do total = total + a.count end
	if total == 0 then return '' end
	return tostring(total)
end

-- How many works cite this one, for the heading.
function p.works()
	local list = alignments()
	if #list == 0 then return '' end
	return tostring(#list)
end

local function renderQuotation(out, q, a, index)
	table.insert(out, '<div class="ssc-citation">')
	table.insert(out, '<span class="ssc-citation-index">' .. index .. '</span>')
	table.insert(out, '<span class="ssc-citation-body">')
	if q.chapter and q.chapter ~= '' then
		table.insert(out, '<span class="ssc-citation-chapter">Chapter ' .. q.chapter .. '</span>')
	end
	-- Folio first: SourcePageNumber is the block-print folio ("114a"), which is
	-- what a scholar cites. It can hold several values when a passage spans
	-- folios, so only the first is shown.
	if q.folio then
		table.insert(out, '<span class="ssc-citation-page">folio&nbsp;' ..
			(q.folio:gsub(';.*$', '')) .. '</span>')
	elseif q.pageNum then
		table.insert(out, '<span class="ssc-citation-page">p.&nbsp;' .. q.pageNum .. '</span>')
	end
	if q.words > LONG_QUOTE_WORDS then
		table.insert(out, '<span class="ssc-citation-long">long quotation</span>')
	end
	table.insert(out, '</span>')

	-- External-link syntax on a fullurl, not a [[wikilink]]: wiki links cannot
	-- carry target, and these pages take seconds to load — some over twenty — so
	-- following one and coming back is expensive. $wgExternalLinkTarget supplies
	-- target=_blank and the rel attributes.
	local link = bilingualLink(a.tm, a.base, q.chapter, q.order)
	if link then
		local page, fragment = link:match('^(.-)(#.*)$')
		table.insert(out, '<span class="ssc-citation-link ssc-citation-external">[' ..
			'{{fullurl:' .. page .. '}}' .. fragment .. ' Read the passage]</span>')
	end
	table.insert(out, '</div>')
end

-- One panel, with a section per citing work.
function p.list(frame)
	local list = alignments()
	if #list == 0 then return '' end

	local out = {'<div class="ssc-citations">'}
	for _, a in ipairs(list) do
		local label = a.base and a.base:gsub('^[^/]+/', '') or a.tm
		table.insert(out, '<div class="ssc-work">')
		table.insert(out, '<div class="ssc-work-head">')
		table.insert(out, '<span class="ssc-work-title">' ..
			(a.base and ('[[' .. a.base .. '|' .. label .. ']]') or label) .. '</span>')
		if a.isRoot then
			table.insert(out, '<span class="ssc-work-kind">root text</span>')
		end
		table.insert(out, '<span class="ssc-work-count">' .. a.count ..
			(a.count == 1 and ' quotation' or ' quotations') .. '</span>')
		table.insert(out, '</div>')
		for i, q in ipairs(a.quotations) do
			renderQuotation(out, q, a, i)
		end
		table.insert(out, '</div>')
	end
	table.insert(out, '</div>')

	return frame:preprocess(table.concat(out))
end

return p