|
|
| (One intermediate revision by the same user not shown) |
| Line 1: |
Line 1: |
| -- ==================================================================== | | -- Module:Blessings — the Blessings category module. |
| -- LIBRARY MODULE: base_render
| |
| -- Skeleton for a per-category Module:Blessings.lua. Reads the Phase
| |
| -- 4.5 flattened data shape (see 4.5-flatten/README.md) where every
| |
| -- record arrives wiki-ready:
| |
| -- * locale keys already resolved (no tr() at render time)
| |
| -- * effect templates already substituted (no format() at render time)
| |
| -- * res:// paths already turned into wiki File: names (no Data:images
| |
| -- cross-walk)
| |
| -- * slug already on the record (no Slugs.lookup at render time)
| |
| -- * forward AND reverse cross-references already inlined as
| |
| -- {slug, name, icon} summaries (no module-load reverse-index build)
| |
| -- | | -- |
| -- This module is therefore thin formatting glue. It does NOT call tr(), | | -- WHAT IT DOES |
| -- format(), Slugs.lookup, mw.ext.data.get, or any other resolver.
| | -- Renders Blessings infoboxes, body sections, cross-references and the |
| --
| | -- category index. This module is intentionally tiny: it only binds |
| -- F-classes prevented:
| | -- the category name and forwards to Module:Core, which computes |
| -- F1 — link targets come from rec.cross_refs[*].slug, never | | -- everything from the source Data:Blessings.json at render time (see |
| -- recomputed; the only string ops on display names happen in
| | -- that Data page's own `description` for the record fields, and |
| -- Phase 4.5 (Python). No gsub/lower/replace lives here.
| | -- Help:Wiki Editing for the architecture guide). To change HOW |
| -- F2 — every return that contains {{Template:...}} (which the
| | -- Blessings pages render, edit Module:Core; to change the DATA, edit |
| -- infobox call does) is piped through
| | -- Data:Blessings.json. |
| -- mw.getCurrentFrame():preprocess(...). #invoke does NOT
| | local Core = require('Module:Core') |
| -- re-parse returned text for templates by default.
| | local p = {} |
| -- F4-adjacent — no template-arg substitution at render time; effect
| | local CAT = 'Blessings' |
| -- strings arrive pre-substituted.
| |
| -- F9 — row decisions live in Template:Blessings_infobox (Pattern
| |
| -- B from base_infobox.wiki). This Lua never emits `|-` or `|`
| |
| -- inside a `#if` body — it always passes named params to the
| |
| -- template, which handles the empty-cell case uniformly.
| |
| -- loadJson-frozen-table — every JSON read goes through `loadJson`,
| |
| -- which canonicalises mw.loadJsonData's frozen output once
| |
| -- (recursive copy with stringified-integer-key aliases) so
| |
| -- `#`, ipairs, t[id], and table.concat behave normally.
| |
| --
| |
| -- Consumes plan.json fields (via wiki-builder substitutions below):
| |
| -- categories[].name → Blessings
| |
| -- categories[].entity_source → not used in Lua anymore; flatten
| |
| -- writes Data:Blessings.json keyed
| |
| -- by the category name, not the
| |
| -- entity-source stem.
| |
| -- categories[].infobox_fields[].field → INFOBOX_FIELD_ORDER
| |
| -- categories[].body_sections[].name → BODY_SECTION_ORDER
| |
| -- categories[].cross_references[] + reverse-references derived | |
| -- from every other category pointing at this one
| |
| -- → CROSS_REF_LABEL_ORDER
| |
| -- categories[].index_page → INDEX_OPTS
| |
| --
| |
| -- Substitution slots the wiki-builder fills:
| |
| -- Blessings reader-facing category, e.g. "Items" | |
| -- Blessings_infobox name of the infobox template, e.g.
| |
| -- "Items_infobox"
| |
| -- INFOBOX_FIELD_ORDER Lua list: {'Tier','Value','Effects'} —
| |
| -- the order in which to pipe rec.infobox
| |
| -- values as named template params
| |
| -- BODY_SECTION_ORDER Lua list: {'Description'} — body section
| |
| -- render order
| |
| -- CROSS_REF_LABEL_ORDER Lua list: {'Found on items', | |
| -- 'Referenced by characters'} — exactly the
| |
| -- label strings flatten.py writes into
| |
| -- rec.cross_refs
| |
| -- INDEX_OPTS Lua dict literal for Module:Index.render
| |
| -- ====================================================================
| |
| | |
| local CrossRef = require('Module:CrossRef')
| |
| local p = {}
| |
| | |
| -- F-prevention (SC-01): canonicalise the frozen JSON table once at
| |
| -- load time. After this, `#`, ipairs, t[id], and table.concat work as
| |
| -- if the data came from a regular Lua table.
| |
| local function loadJson(name)
| |
| local function fix(t)
| |
| if type(t) ~= 'table' then return t end
| |
| local out = {}
| |
| for k, v in pairs(t) do
| |
| out[k] = fix(v)
| |
| if type(k) == 'number' then out[tostring(k)] = out[k] end
| |
| end
| |
| return out
| |
| end
| |
| return fix(mw.loadJsonData(name))
| |
| end
| |
| | |
| -- Module-load cache: pull the flattened category file once. Shape:
| |
| -- { category = "Blessings", aggregation_mode = "none|by_field|by_table",
| |
| -- record_count = N, records = { {id, slug, name, icon, infobox,
| |
| -- body, cross_refs, ...}, ... } }
| |
| local _data = nil
| |
| local function dataset()
| |
| if _data == nil then
| |
| _data = loadJson('Data:Blessings.json') or {records = {}}
| |
| end
| |
| return _data
| |
| end
| |
| | |
| -- Reverse index for O(1) record lookup. The flatten output is a list
| |
| -- (ordered for index-page determinism), but render(id) is keyed. We
| |
| -- accept id, slug, OR family_key as the lookup key — all three are
| |
| -- emitted as a 3-line stub by 5-build-pages/code/emit_<cat>_stubs.py.
| |
| local _byId = nil
| |
| local function byId()
| |
| if _byId == nil then
| |
| _byId = {}
| |
| for _, r in ipairs(dataset().records or {}) do
| |
| if r.id then _byId[r.id] = r end
| |
| if r.slug then _byId[r.slug] = r end
| |
| if r.family_key then _byId[r.family_key] = r end
| |
| end
| |
| end
| |
| return _byId
| |
| end
| |
| | |
| -- The wiki-builder pastes the per-category order lists here at
| |
| -- gen-time. They MUST exactly match the labels flatten emits.
| |
| local INFOBOX_FIELD_ORDER = {'Tier', 'Price', 'Availability', 'Stats'} -- {'Tier', 'Value', ...}
| |
| local BODY_SECTION_ORDER = {'Description', 'Tags'} -- {'Description', ...}
| |
| local CROSS_REF_LABEL_ORDER = {'Base variant', 'Upgraded variant'} -- {'Found on items', ...}
| |
| -- INDEX_OPTS lives at module scope so both p.index AND p.filter / p.subtable
| |
| -- read the SAME column list + thumbnail_size. Before F1, INDEX_OPTS was only
| |
| -- inlined inside p.index()'s body literal, which meant p.filter's flat-path
| |
| -- fallback (`local cols = (INDEX_OPTS and INDEX_OPTS.columns) or {}`) always
| |
| -- saw nil and returned ''. Hoisting it here makes the flat-path code actually
| |
| -- work for non-subtable cross_view targets (Quests → Merchants, etc.).
| |
| local INDEX_OPTS = {columns = {{name = 'Thumbnail', source = 'thumbnail'}, {name = 'Name', source = 'link'}, {name = 'Tier', source = 'Tier'}, {name = 'Price', source = 'Price'}, {name = 'Effects', source = 'Effects'}}, thumbnail_size = 48} -- {columns = {...}, thumbnail_size = N, sort_default = '...'}
| |
| | |
| -- Coerce a flattened value to a template-param string. Lists become
| |
| -- comma-separated; numbers become their textual form; nil/empty
| |
| -- become empty string (the template's Pattern B turns empty into '—').
| |
| local function asParam(v)
| |
| if v == nil then return '' end
| |
| if type(v) == 'table' then
| |
| -- Lists of strings (effects, tags) → ', '.join. Lists of dicts
| |
| -- (rare; e.g. nested xref summaries) → render names joined.
| |
| local parts = {}
| |
| for i = 1, #v do
| |
| local item = v[i]
| |
| if type(item) == 'table' and item.name then
| |
| table.insert(parts, tostring(item.name))
| |
| else
| |
| table.insert(parts, tostring(item))
| |
| end
| |
| end
| |
| return table.concat(parts, ', ')
| |
| end
| |
| return tostring(v)
| |
| end
| |
| | |
| -- Build a {{Blessings_infobox | ...}} call from rec.infobox in | |
| -- plan-declared order. Always emits `name`, `id`, `icon` slots so the
| |
| -- template can render the header/icon row uniformly; then one
| |
| -- `| <field> = <value>` per INFOBOX_FIELD_ORDER entry. Empty values
| |
| -- pass through; Pattern B in the template renders the em-dash.
| |
| local function renderInfobox(rec)
| |
| local out = {'{{Blessings_infobox'}
| |
| table.insert(out, '| name = ' .. asParam(rec.name))
| |
| table.insert(out, '| id = ' .. asParam(rec.id))
| |
| table.insert(out, '| icon = ' .. asParam(rec.icon))
| |
| local infobox = rec.infobox or {}
| |
| for _, field in ipairs(INFOBOX_FIELD_ORDER) do | |
| table.insert(out, '| ' .. field .. ' = ' .. asParam(infobox[field]))
| |
| end
| |
| table.insert(out, '}}')
| |
| return table.concat(out, '\n')
| |
| end
| |
| | |
| -- Aggregated categories carry rec.tier_table = {columns = {...}, | |
| -- rows = {{...}, {...}, ...}}; render as a sortable wikitable.
| |
| -- Non-aggregated records have rec.tier_table == nil → returns ''.
| |
| local function renderTierTable(rec)
| |
| local tt = rec.tier_table
| |
| if not tt or not tt.columns or #tt.columns == 0 then return '' end
| |
| local out = {'', '{| class="wikitable sortable" style="background:var(--table-row-odd, #1b1c20);color:var(--table-text, #e6e6e6);border-color:var(--table-border, #3a3c44);"', '|-'}
| |
| for _, col in ipairs(tt.columns) do | |
| table.insert(out, '! style="background:var(--table-header-bg, #26272e);color:var(--infobox-header-fg, #f1e9d2);" | ' .. tostring(col))
| |
| end
| |
| for _, row in ipairs(tt.rows or {}) do
| |
| table.insert(out, '|-')
| |
| local cells = {}
| |
| for _, cell in ipairs(row) do
| |
| table.insert(cells, tostring(cell))
| |
| end
| |
| table.insert(out, '| ' .. table.concat(cells, ' || '))
| |
| end
| |
| table.insert(out, '|}')
| |
| return table.concat(out, '\n')
| |
| end
| |
| | |
| -- Render one section's body content (no heading). Returns '' when the
| |
| -- content is nil / empty string / empty list (callers skip the section
| |
| -- entirely when this returns '').
| |
| --
| |
| -- List items that are dicts carrying {slug, name} (Phase 4.5 cross-ref
| |
| -- summary shape) render as [[slug|name]] wikilinks rather than the Lua
| |
| -- tostring() of a table, which would print literal "table".
| |
| --
| |
| -- Shared by p.body (per-section invoke) and renderBody (full-body in
| |
| -- p.render). Splitting it out replaces the prior approach where p.body
| |
| -- mutated BODY_SECTION_ORDER + regex-stripped the heading from
| |
| -- renderBody's output — that coupled p.body to renderBody's whitespace
| |
| -- shape. The shared helper has no string parsing.
| |
| local function _renderSectionBody(content)
| |
| if content == nil or content == '' then return '' end
| |
| if type(content) == 'table' and #content == 0 then return '' end
| |
| local lines = {}
| |
| if type(content) == 'table' then
| |
| for _, line in ipairs(content) do
| |
| if type(line) == 'table' and line.slug and line.name then
| |
| table.insert(lines, '* [[' .. tostring(line.slug) .. '|' .. tostring(line.name) .. ']]')
| |
| elseif type(line) == 'table' and line.name then
| |
| table.insert(lines, '* ' .. tostring(line.name))
| |
| else
| |
| table.insert(lines, '* ' .. tostring(line))
| |
| end
| |
| end
| |
| else
| |
| table.insert(lines, tostring(content))
| |
| end
| |
| return table.concat(lines, '\n')
| |
| end
| |
| | |
| -- Body sections, in plan-declared order. Skips sections whose source
| |
| -- value is empty. Emits the heading + the section body (via
| |
| -- _renderSectionBody) per section.
| |
| local function renderBody(rec)
| |
| local body = rec.body or {}
| |
| local out = {}
| |
| for _, section in ipairs(BODY_SECTION_ORDER) do
| |
| local body_text = _renderSectionBody(body[section])
| |
| if body_text ~= '' then
| |
| table.insert(out, '')
| |
| table.insert(out, '== ' .. section .. ' ==')
| |
| table.insert(out, body_text)
| |
| end
| |
| end
| |
| return table.concat(out, '\n')
| |
| end
| |
| | |
| -- Cross-references: pre-populated by Phase 4.5 in both directions.
| |
| -- Just iterate the label order and hand each list to CrossRef.
| |
| local function renderCrossRefs(rec)
| |
| return CrossRef.renderAll(rec, CROSS_REF_LABEL_ORDER)
| |
| end
| |
| | |
| -- ----- entry points --------------------------------------------------
| |
| --
| |
| -- DETAIL PAGE COMPOSITION (since 2026-05).
| |
| --
| |
| -- Phase 5 emits per-instance detail pages as composed wikitext that
| |
| -- interleaves inline_prose / inline_template / delegated_template
| |
| -- sections per body_sections[].render_mode in plan.json. Each
| |
| -- delegated_template section is wrapped in a small per-section
| |
| -- template (Template:Blessings_<section>) that calls one of the
| |
| -- entry points below — `p.infobox`, `p.body`, `p.crossRefs`,
| |
| -- `p.tierTable`. So one detail page typically issues 3-5 invokes
| |
| -- of THIS module on Scribunto's per-page Lua context.
| |
| --
| |
| -- This works at default 50 MiB Scribunto cap only for small
| |
| -- Data:<Cat>.json (<500 KiB). For larger categories, bump the host's
| |
| -- Scribunto memory limit (see 6-publish/SETUP.md "Scribunto memory")
| |
| -- or fall back to one combined invoke via `p.render` below.
| |
| --
| |
| -- `p.render` remains for back-compat and as the single-invoke
| |
| -- combined fallback. New plans use the composition pattern.
| |
| | |
| -- Per-section entry points. Each takes `frame.args.id` and returns
| |
| -- only that section's rendered wikitext, preprocessed for templates.
| |
| -- Called by per-section templates emitted by Phase 5.
| |
| | |
| local function _resolveRec(frame)
| |
| local id = frame and frame.args and frame.args.id
| |
| if not id or id == '' then return nil, '' end
| |
| local rec = byId()[id]
| |
| if not rec then
| |
| return nil, mw.getCurrentFrame():preprocess(
| |
| "<i>Unknown Blessings record: " .. mw.text.nowiki(id) .. "</i>")
| |
| end
| |
| return rec, nil
| |
| end
| |
|
| |
|
| -- {{#invoke:Blessings|infobox|id=<slug-or-id>}} | | -- {{#invoke:Blessings|infobox|id=<slug-or-id>}} |
| function p.infobox(frame) | | function p.infobox(frame) return Core.infoboxEntry(CAT, frame) end |
| local rec, missing = _resolveRec(frame)
| |
| if missing then return missing end
| |
| if not rec then return '' end
| |
| return mw.getCurrentFrame():preprocess(renderInfobox(rec))
| |
| end | |
|
| |
|
| -- {{#invoke:Blessings|body|id=<slug-or-id>[|section=<section_name>]}} | | -- {{#invoke:Blessings|body|id=<slug-or-id>[|section=<name>]}} |
| -- Without `section`: renders every BODY_SECTION_ORDER entry with headings.
| | function p.body(frame) return Core.bodyEntry(CAT, frame) end |
| -- With `section`: renders only that one section's body, NO `== heading ==`
| |
| -- (because Phase 5 emitted the heading in the per-instance page wikitext
| |
| -- itself so editors can see + reorder it).
| |
| --
| |
| -- Both branches share `_renderSectionBody` — no regex parsing, no mutation
| |
| -- of BODY_SECTION_ORDER. The per-section path looks up rec.body[section]
| |
| -- directly; the full path delegates to renderBody.
| |
| function p.body(frame) | |
| local rec, missing = _resolveRec(frame)
| |
| if missing then return missing end
| |
| if not rec then return '' end
| |
| local section = frame and frame.args and frame.args.section
| |
| if section and section ~= '' then
| |
| local body_text = _renderSectionBody((rec.body or {})[section])
| |
| if body_text == '' then return '' end
| |
| return mw.getCurrentFrame():preprocess(body_text)
| |
| end
| |
| return mw.getCurrentFrame():preprocess(renderBody(rec))
| |
| end | |
|
| |
|
| -- {{#invoke:Blessings|crossRefs|id=<slug-or-id>}} | | -- {{#invoke:Blessings|crossRefs|id=<slug-or-id>}} |
| function p.crossRefs(frame) | | function p.crossRefs(frame) |
| local rec, missing = _resolveRec(frame) | | return require('Module:CrossRef').entry(CAT, frame) |
| if missing then return missing end
| |
| if not rec then return '' end
| |
| return mw.getCurrentFrame():preprocess(renderCrossRefs(rec))
| |
| end
| |
| | |
| -- {{#invoke:Blessings|render|id=<slug-or-id>}} (combined / back-compat).
| |
| -- The per-instance detail page composition pattern (Phase 5 emits one
| |
| -- {{#invoke}} per delegated_template body section) is the new default.
| |
| -- This combined entry point stays for:
| |
| -- * back-compat with detail pages still emitted as 1-line stubs
| |
| -- before the composition migration
| |
| -- * single-invoke fallback when categories[].detail_page_invoke_budget
| |
| -- in a future plan extension forces one combined call to stay under
| |
| -- a tight Scribunto memory cap.
| |
| function p.render(frame)
| |
| local rec, missing = _resolveRec(frame)
| |
| if missing then return missing end
| |
| if not rec then return '' end
| |
| local parts = {
| |
| renderInfobox(rec),
| |
| renderTierTable(rec),
| |
| renderBody(rec),
| |
| renderCrossRefs(rec),
| |
| }
| |
| -- F2 (SC-03): the {{Blessings_infobox ...}} call above renders as literal
| |
| -- text without this wrap. Helper functions use mw.getCurrentFrame()
| |
| -- rather than the `frame` arg (SC-02).
| |
| return mw.getCurrentFrame():preprocess(table.concat(parts, '\n'))
| |
| end
| |
| | |
| -- Index page entry point. Called from Blessings.wiki:
| |
| -- {{#invoke:Blessings|index}}
| |
| function p.index(frame)
| |
| local Index = require('Module:Index')
| |
| return Index.render(dataset().records or {}, INDEX_OPTS or {})
| |
| end
| |
| | |
| -- ----- subtable entry point (multi-subtable index categories) ----------
| |
| --
| |
| -- Some categories (Weapons / Armor / Accessories / Monsters under D&D's
| |
| -- plan) render their index page as one wikitable per logical grouping —
| |
| -- weapon class, armor slot, monster class_type — rather than one flat
| |
| -- table. The flatten layer pre-buckets records into ``dataset().subtables``
| |
| -- with per-subtable column selection (≥50% stat-presence rule), so the
| |
| -- index page is just a sequence of:
| |
| -- {{#invoke:Blessings|subtable|key=Sword}}
| |
| -- {{#invoke:Blessings|subtable|key=Axe}}
| |
| -- ...
| |
| -- The flatten output's ``display_subtype`` field provides the reader-
| |
| -- friendly heading text (e.g. 'MagicStuff' → 'Magic Foci & Spellbooks');
| |
| -- when absent we fall back to the raw subtype.
| |
| --
| |
| -- The Lua-driven path replaces a Python-pre-rendered wikitext path that
| |
| -- baked rows into the index page at emit time. Reader-visible output is
| |
| -- identical; the win is that any change to ``Data:Blessings.json``
| |
| -- propagates on a Module/Template purge — no re-emit-then-republish.
| |
| local function _renderCell(cell, col_name, thumbnail_size)
| |
| if col_name == 'Thumbnail' then
| |
| if cell == nil or cell == '' then return '—' end
| |
| return string.format('[[File:%s|%dpx|link=]]',
| |
| tostring(cell), thumbnail_size or 48)
| |
| end
| |
| if type(cell) == 'table' and cell.slug and cell.name then
| |
| if cell.slug ~= '' then
| |
| return '[[' .. tostring(cell.slug) .. '|' .. tostring(cell.name) .. ']]'
| |
| end
| |
| return tostring(cell.name)
| |
| end
| |
| if cell == nil or cell == '' then return '—' end
| |
| return tostring(cell)
| |
| end
| |
| | |
| local function _renderSubtable(st, thumbnail_size)
| |
| local cols = st.columns or {}
| |
| local rows = st.rows or {}
| |
| local display = st.display_subtype or st.subtype or ''
| |
| local out = {}
| |
| table.insert(out, '=== ' .. tostring(display) .. ' (' .. tostring(#rows) .. ') ===')
| |
| table.insert(out, '')
| |
| table.insert(out, '{| class="wikitable sortable" style="font-size:90%;background:var(--table-row-odd, #1b1c20);color:var(--table-text, #e6e6e6);border-color:var(--table-border, #3a3c44);"')
| |
| table.insert(out, '|-')
| |
| local hdr = {}
| |
| for i = 1, #cols do hdr[i] = tostring(cols[i]) end
| |
| table.insert(out, '! style="background:var(--table-header-bg, #26272e);color:var(--infobox-header-fg, #f1e9d2);" | ' .. table.concat(hdr, ' !! style="background:var(--table-header-bg, #26272e);color:var(--infobox-header-fg, #f1e9d2);" | '))
| |
| for _, row in ipairs(rows) do
| |
| table.insert(out, '|-')
| |
| local cells = {}
| |
| for i = 1, #cols do
| |
| cells[i] = _renderCell(row[i], cols[i], thumbnail_size)
| |
| end
| |
| table.insert(out, '| ' .. table.concat(cells, ' || '))
| |
| end
| |
| table.insert(out, '|}')
| |
| return table.concat(out, '\n')
| |
| end
| |
| | |
| -- Multi-subtable index entry point. Called from Blessings.wiki:
| |
| -- {{#invoke:Blessings|subtable|key=Sword}}
| |
| -- Iterates dataset().subtables in declared order, returns the first whose
| |
| -- ``subtype`` matches ``key``. Returns an italic "not found" stub rather
| |
| -- than raising so a one-off mismatch on the index page doesn't break the
| |
| -- whole render.
| |
| function p.subtable(frame)
| |
| local key = frame.args and frame.args.key
| |
| if not key or key == '' then return '' end
| |
| local thumb = (INDEX_OPTS and INDEX_OPTS.thumbnail_size) or 48
| |
| for _, st in ipairs(dataset().subtables or {}) do
| |
| if st.subtype == key then
| |
| return mw.getCurrentFrame():preprocess(_renderSubtable(st, thumb))
| |
| end
| |
| end
| |
| return mw.getCurrentFrame():preprocess(
| |
| "<i>Unknown Blessings subtable key: " .. mw.text.nowiki(key) .. "</i>")
| |
| end
| |
| | |
| -- ----- filter entry point (F1: cross-context filtered embedding) --------
| |
| --
| |
| -- Render this category's records filtered by `by=<field>` / `value=<v>`.
| |
| -- Used to syndicate one category's table onto another category's detail
| |
| -- pages — e.g. on the Barbarian Class page:
| |
| -- {{#invoke:Weapons|filter|by=class_requirements|value=Barbarian}}
| |
| -- emits the standard Weapons sortable wikitable, restricted to weapons
| |
| -- whose `class_requirements` field includes "Barbarian".
| |
| --
| |
| -- Predicate semantics (in `_matchesPredicate`):
| |
| -- record[by] is a string → equality.
| |
| -- record[by] is a list of strings → membership.
| |
| -- record[by] is a list of dicts with `name` → membership on names.
| |
| -- `by` contains a `.` → walked as a dotted path (record.a.b),
| |
| -- then the leaf is tested as above.
| |
| --
| |
| -- Rendering:
| |
| -- When dataset().subtables exists, the matched slugs are looked up in
| |
| -- each subtable's pre-computed rows so the column shape and cell
| |
| -- formatting are IDENTICAL to the index page's per-class subtable.
| |
| -- Subtable groupings are preserved (one ===<subtype>=== heading per
| |
| -- non-empty group).
| |
| -- Otherwise we fall back to INDEX_OPTS.columns and render a single
| |
| -- flat wikitable using the same _renderCell helper.
| |
| --
| |
| -- Optional `limit=N` caps the total rendered row count (0 / omitted /
| |
| -- non-positive = no cap). When `limit` truncates, a trailing italic note
| |
| -- announces it.
| |
| -- Empty match set returns '' (suppressed; the caller's section header
| |
| -- still emits, so this is intentionally quiet).
| |
| local function _walkPath(record, path)
| |
| if record == nil or path == nil or path == '' then return nil end
| |
| if string.find(path, '.', 1, true) == nil then
| |
| return record[path]
| |
| end
| |
| local cur = record
| |
| for part in string.gmatch(path, '[^.]+') do
| |
| if type(cur) ~= 'table' then return nil end
| |
| cur = cur[part]
| |
| if cur == nil then return nil end
| |
| end
| |
| return cur
| |
| end
| |
| | |
| local function _matchesPredicate(record, by, value)
| |
| local v = _walkPath(record, by)
| |
| if v == nil then return false end
| |
| if type(v) == 'table' then
| |
| -- list-of-strings or list-of-dicts-with-name
| |
| for i = 1, #v do
| |
| local item = v[i]
| |
| if type(item) == 'table' then
| |
| if item.name ~= nil and tostring(item.name) == value then
| |
| return true
| |
| end
| |
| if item.slug ~= nil and tostring(item.slug) == value then
| |
| return true
| |
| end
| |
| elseif tostring(item) == value then
| |
| return true
| |
| end
| |
| end
| |
| return false
| |
| end
| |
| return tostring(v) == value
| |
| end
| |
| | |
| local function _renderFlatTable(records, cols, thumb)
| |
| -- One header row + N data rows. `cols` is a list of {name, source} dicts
| |
| -- (the INDEX_OPTS shape). Resolves each cell with _renderCell so the
| |
| -- Thumbnail / cross-ref-dict / nil-as-em-dash conventions all match the
| |
| -- subtable renderer.
| |
| if #cols == 0 then return '' end
| |
| local out = {
| |
| '{| class="wikitable sortable" style="font-size:90%;background:var(--table-row-odd, #1b1c20);color:var(--table-text, #e6e6e6);border-color:var(--table-border, #3a3c44);"',
| |
| '|-',
| |
| }
| |
| local hdr = {}
| |
| for i = 1, #cols do hdr[i] = tostring(cols[i].name or cols[i]) end
| |
| table.insert(out, '! style="background:var(--table-header-bg, #26272e);color:var(--infobox-header-fg, #f1e9d2);" | ' .. table.concat(hdr, ' !! style="background:var(--table-header-bg, #26272e);color:var(--infobox-header-fg, #f1e9d2);" | '))
| |
| for _, rec in ipairs(records) do
| |
| table.insert(out, '|-')
| |
| local cells = {}
| |
| for i = 1, #cols do
| |
| local col = cols[i]
| |
| local col_name = tostring(col.name or col)
| |
| local source = col.source or col_name
| |
| local raw
| |
| if source == 'thumbnail' or col_name == 'Thumbnail' then
| |
| raw = rec.icon
| |
| elseif source == 'name' or source == 'link' then
| |
| raw = {slug = rec.slug, name = rec.name}
| |
| else
| |
| raw = rec[source]
| |
| if raw == nil and rec.infobox ~= nil then
| |
| raw = rec.infobox[source]
| |
| end
| |
| end
| |
| cells[i] = _renderCell(raw, col_name, thumb)
| |
| end
| |
| table.insert(out, '| ' .. table.concat(cells, ' || '))
| |
| end
| |
| table.insert(out, '|}')
| |
| return table.concat(out, '\n')
| |
| end
| |
| | |
| function p.filter(frame)
| |
| local args = (frame and frame.args) or {}
| |
| local by = args.by
| |
| local value = args.value
| |
| if not by or by == '' or not value or value == '' then return '' end
| |
| local limit = tonumber(args.limit or 0) or 0
| |
| | |
| local d = dataset()
| |
| local thumb = (INDEX_OPTS and INDEX_OPTS.thumbnail_size) or 48
| |
| | |
| -- 1) Filter the records list by the predicate. Collect matched slugs +
| |
| -- matched record list for the no-subtable fallback path.
| |
| local matched_slugs = {}
| |
| local matched_recs = {}
| |
| for _, rec in ipairs(d.records or {}) do
| |
| if _matchesPredicate(rec, by, value) then
| |
| if rec.slug ~= nil then matched_slugs[tostring(rec.slug)] = true end
| |
| table.insert(matched_recs, rec)
| |
| end
| |
| end
| |
| | |
| local total_matches = #matched_recs
| |
| if total_matches == 0 then return '' end
| |
| | |
| local rendered_count = 0
| |
| local function _capped(n) -- limit==0 means no cap
| |
| if limit <= 0 then return false end
| |
| return n >= limit
| |
| end
| |
| | |
| -- 2) Subtable path — preserves the index page's column shape per group.
| |
| local subtables = d.subtables or {}
| |
| if #subtables > 0 then
| |
| local sections = {}
| |
| for _, st in ipairs(subtables) do
| |
| local cols = st.columns or {}
| |
| local rows = st.rows or {}
| |
| -- Find Name column index (column whose cell is the slug+name dict).
| |
| local name_col_idx = nil
| |
| for i = 1, #cols do
| |
| if tostring(cols[i]) == 'Name' then name_col_idx = i; break end
| |
| end
| |
| local kept = {}
| |
| for _, row in ipairs(rows) do
| |
| if _capped(rendered_count) then break end
| |
| local name_cell = name_col_idx and row[name_col_idx] or nil
| |
| local slug
| |
| if type(name_cell) == 'table' then
| |
| slug = name_cell.slug
| |
| end
| |
| if slug ~= nil and matched_slugs[tostring(slug)] then
| |
| table.insert(kept, row)
| |
| rendered_count = rendered_count + 1
| |
| end
| |
| end
| |
| if #kept > 0 then
| |
| local synth = {
| |
| subtype = st.subtype,
| |
| display_subtype = st.display_subtype or st.subtype,
| |
| columns = cols,
| |
| rows = kept,
| |
| }
| |
| table.insert(sections, _renderSubtable(synth, thumb))
| |
| end
| |
| if _capped(rendered_count) then break end
| |
| end
| |
| if #sections == 0 then return '' end
| |
| local body = table.concat(sections, '\n\n')
| |
| if limit > 0 and total_matches > rendered_count then
| |
| body = body .. '\n\n<i>(showing first ' .. tostring(rendered_count)
| |
| .. ' of ' .. tostring(total_matches) .. ' matches)</i>'
| |
| end
| |
| return mw.getCurrentFrame():preprocess(body)
| |
| end
| |
| | |
| -- 3) Fallback — flat wikitable from INDEX_OPTS.columns.
| |
| local cols = (INDEX_OPTS and INDEX_OPTS.columns) or {}
| |
| local to_render = matched_recs
| |
| if limit > 0 and #to_render > limit then
| |
| local capped = {}
| |
| for i = 1, limit do capped[i] = to_render[i] end
| |
| to_render = capped
| |
| rendered_count = limit
| |
| else
| |
| rendered_count = #to_render
| |
| end
| |
| local body = _renderFlatTable(to_render, cols, thumb)
| |
| if body == '' then return '' end
| |
| if limit > 0 and total_matches > rendered_count then
| |
| body = body .. '\n\n<i>(showing first ' .. tostring(rendered_count)
| |
| .. ' of ' .. tostring(total_matches) .. ' matches)</i>'
| |
| end
| |
| return mw.getCurrentFrame():preprocess(body)
| |
| end
| |
| | |
| -- ----- reverseRef entry point (F2: reverse cross-reference embedding) ---
| |
| --
| |
| -- Render this category's records that point back at a target value via a
| |
| -- declared field. The wiki-side intent: "what records in THIS category
| |
| -- reference THAT entity?" — surfaced on the target entity's page.
| |
| --
| |
| -- Example: on the Goblin detail page (Goblin is a Monster), emit
| |
| -- {{#invoke:Items|reverseRef|field=loot_drops|target=Goblin}}
| |
| -- to render the standard Items table restricted to items whose
| |
| -- `loot_drops` field contains "Goblin".
| |
| --
| |
| -- Semantics are IDENTICAL to p.filter; only the argument names differ:
| |
| -- p.filter : by=<field> value=<v> (filter from the source side)
| |
| -- p.reverseRef: field=<field> target=<v> (lookup from the target side)
| |
| --
| |
| -- Two arg-names exist because the wikitext call reads differently from
| |
| -- each direction. A cross_view (declared on the source category) reads
| |
| -- "filter Weapons by class_requirements = Barbarian"; a reverse_view
| |
| -- (declared on the target category) reads "find Items whose loot_drops
| |
| -- field references Goblin". Same Lua machinery; renamed args make the
| |
| -- designer's intent self-documenting in plan.json + the rendered
| |
| -- {{#invoke}} call.
| |
| --
| |
| -- Optional `limit=N` caps the rendered row count (0 / omitted = no cap),
| |
| -- same as p.filter.
| |
| function p.reverseRef(frame)
| |
| local args = (frame and frame.args) or {}
| |
| -- Translate the F2 parameter names to F1's predicate machinery.
| |
| local proxy = {
| |
| args = {
| |
| by = args.field,
| |
| value = args.target,
| |
| limit = args.limit,
| |
| },
| |
| }
| |
| return p.filter(proxy)
| |
| end
| |
| | |
| -- ----- tierTable entry point (F4: standalone per-tier comparison embed) ----
| |
| --
| |
| -- Render the per-tier comparison table for a single record. Used to surface
| |
| -- the tier_table block on detail pages (which p.render already inlines) OR
| |
| -- on cross-context pages (e.g. a `Rarity` explainer page can syndicate one
| |
| -- item-family's tier ladder via a single {{#invoke}}).
| |
| --
| |
| -- {{#invoke:Items|tierTable|id=Crystal_Sword}}
| |
| -- {{#invoke:Monsters|tierTable|id=Goblin}}
| |
| -- {{#invoke:Religions|tierTable|id=Blythar}}
| |
| --
| |
| -- Reads `dataset().byId(id).tier_table`. The flatten layer guarantees this
| |
| -- block (when present) carries `{columns = {...}, rows = {{...}, ...}}` with
| |
| -- per-tier presence already applied (no all-empty rows / no all-em-dash
| |
| -- columns) — see flatten.py for the per-category ≥50%-presence rule.
| |
| --
| |
| -- Empty / missing tier_table → returns ''. Unknown id → italic "unknown"
| |
| -- stub (same convention as p.render). Wrap in mw.getCurrentFrame():
| |
| -- preprocess(...) per SC-03 in case the cell contents carry templates
| |
| -- (consistent with renderInfobox / renderTierTable being preprocessed by
| |
| -- p.render's outer wrap).
| |
| --
| |
| -- SCRIBUNTO MEMORY CAVEAT: calling multiple {{#invoke}}s of the SAME
| |
| -- module on one page may hit Scribunto's 50 MiB memory limit when the
| |
| -- underlying Data:<Cat>.json is large. Verified failure mode: any 2nd
| |
| -- {{#invoke:Items|...}} on a single page (Items.json is ~2.5 MiB, 769
| |
| -- records) returns
| |
| -- <strong class="error">Lua error: Internal error: The interpreter
| |
| -- exited with status 1.</strong>
| |
| -- regardless of which entry point is called (tierTable, summary, filter,
| |
| -- render — they all bump into the same per-page Lua context). Smaller
| |
| -- modules (Classes, Weapons) survive 30+ invokes on one page. Mitigation:
| |
| -- (a) spread invokes across DIFFERENT modules on one page (the
| |
| -- Sandbox/F4_Summary comparison-grid pattern), (b) split the dataset into
| |
| -- smaller Data:<Cat>/<shard>.json pages so any one module loads less,
| |
| -- or (c) use a single {{#invoke|index}} / {{#invoke|filter}} call that
| |
| -- renders many records in one Lua pass.
| |
| function p.tierTable(frame)
| |
| local id = frame and frame.args and frame.args.id
| |
| if not id or id == '' then return '' end
| |
| local rec = byId()[id]
| |
| if not rec then
| |
| return mw.getCurrentFrame():preprocess(
| |
| "<i>Unknown Blessings record: " .. mw.text.nowiki(id) .. "</i>")
| |
| end
| |
| local body = renderTierTable(rec)
| |
| if body == '' then return '' end
| |
| return mw.getCurrentFrame():preprocess(body)
| |
| end
| |
| | |
| -- ----- summary entry point (F4: compact icon + name + stats cell) ----------
| |
| --
| |
| -- Render a single record as a compact, self-contained block — icon thumbnail
| |
| -- over a bolded wikilink-name over an optional inline list of key stats.
| |
| -- Designed for embedding in dense layouts (subtables, comparison grids,
| |
| -- cross-view sections that want denser-than-row presentation).
| |
| --
| |
| -- {{#invoke:Items|summary|id=Crystal_Sword}}
| |
| -- {{#invoke:Items|summary|id=Crystal_Sword|stats=Item Type,Slot}}
| |
| -- {{#invoke:Items|summary|id=Crystal_Sword|stats=Item_Type|size=32}}
| |
| -- {{#invoke:Items|summary|id=Crystal_Sword|link=false}}
| |
| --
| |
| -- Parameters (all read from frame.args):
| |
| -- id (required) — record id / slug / family_key (any byId key).
| |
| -- stats (optional) — comma-separated field names to render inline below
| |
| -- the name. Both top-level rec fields and rec.infobox
| |
| -- fields are searched; underscores in arg-names are
| |
| -- interchangeable with spaces (so the wikitext call
| |
| -- can use 'Item_Type' even when the field is
| |
| -- 'Item Type' — MediaWiki strips raw spaces from
| |
| -- URL-style args). Empty/nil values are skipped.
| |
| -- size (optional, default 48) — icon size in px.
| |
| -- link (optional, default true) — when 'false' / '0', name renders
| |
| -- as plain text, not wikilinked.
| |
| --
| |
| -- Output (one block, no trailing newline):
| |
| -- [[File:<icon>|48px|link=]]<br/>'''[[<slug>|<name>]]'''<br/><small>
| |
| -- <field>: <val> · <field>: <val></small>
| |
| --
| |
| -- Empty input (no id, or id not found) → returns '' (no italic stub —
| |
| -- summary is intended for table cells where a missing record should
| |
| -- silently render nothing, not punch a hole in the layout).
| |
| --
| |
| -- SCRIBUNTO MEMORY CAVEAT: same per-page Lua-context limit as p.tierTable
| |
| -- — calling 2+ {{#invoke:<largeModule>|summary|...}} on one page can
| |
| -- exhaust the 50 MiB budget when the underlying Data:<Cat>.json is large.
| |
| -- For comparison grids prefer SPREADING invokes across different modules
| |
| -- (e.g. {{#invoke:Weapons|summary}} + {{#invoke:Classes|summary}} +
| |
| -- {{#invoke:Monsters|summary}} renders 3 cells fine; 3× Items|summary
| |
| -- fails on the 2nd). See p.tierTable docstring above for full details.
| |
| local function _lookupField(rec, key)
| |
| -- Normalize argument key: replace underscores with spaces (and vice versa)
| |
| -- when the literal hit misses. Tries top-level, then rec.infobox.
| |
| if rec[key] ~= nil then return rec[key] end
| |
| local infobox = rec.infobox or {}
| |
| if infobox[key] ~= nil then return infobox[key] end
| |
| -- swap _ ↔ space
| |
| local alt = key:gsub('_', ' ')
| |
| if alt ~= key then
| |
| if rec[alt] ~= nil then return rec[alt] end
| |
| if infobox[alt] ~= nil then return infobox[alt] end
| |
| end
| |
| local alt2 = key:gsub(' ', '_')
| |
| if alt2 ~= key then
| |
| if rec[alt2] ~= nil then return rec[alt2] end
| |
| if infobox[alt2] ~= nil then return infobox[alt2] end
| |
| end
| |
| return nil
| |
| end
| |
| | |
| local function _isFalsy(v)
| |
| if v == nil then return true end
| |
| local s = tostring(v):lower()
| |
| return s == 'false' or s == '0' or s == 'no'
| |
| end | | end |
|
| |
|
| function p.summary(frame)
| | -- {{#invoke:Blessings|index}} — the category index table |
| local args = (frame and frame.args) or {}
| | function p.index(frame) return Core.indexEntry(CAT, frame) end |
| local id = args.id
| |
| if not id or id == '' then return '' end
| |
| local rec = byId()[id]
| |
| if not rec then return '' end
| |
| | |
| local size = tonumber(args.size or 48) or 48
| |
| local link_enabled = args.link == nil or not _isFalsy(args.link)
| |
| | |
| local lines = {}
| |
| | |
| -- Row 1: icon thumbnail (suppressed if no icon).
| |
| local icon = rec.icon
| |
| if icon ~= nil and icon ~= '' then
| |
| table.insert(lines, string.format('[[File:%s|%dpx|link=]]', tostring(icon), size))
| |
| end
| |
| | |
| -- Row 2: bolded name, optionally wikilinked.
| |
| local name = rec.name or rec.id or tostring(id)
| |
| local slug = rec.slug
| |
| if link_enabled and slug ~= nil and slug ~= '' then
| |
| table.insert(lines, "'''[[" .. tostring(slug) .. "|" .. tostring(name) .. "]]'''")
| |
| else
| |
| table.insert(lines, "'''" .. tostring(name) .. "'''")
| |
| end
| |
|
| |
|
| -- Row 3: inline stats (skipped when `stats` arg absent / empty / all values nil).
| | -- {{#invoke:Blessings|render|id=<slug-or-id>}} — combined fallback |
| local stats_arg = args.stats
| | function p.render(frame) return Core.renderEntry(CAT, frame) end |
| if stats_arg ~= nil and stats_arg ~= '' then
| |
| local parts = {}
| |
| for key in string.gmatch(tostring(stats_arg), '[^,]+') do
| |
| -- Trim surrounding whitespace
| |
| local k = key:gsub('^%s+', ''):gsub('%s+$', '')
| |
| if k ~= '' then
| |
| local v = _lookupField(rec, k)
| |
| if v ~= nil and v ~= '' then
| |
| -- Display the key as the caller wrote it (with underscores → spaces
| |
| -- so 'Move_Speed' renders as 'Move Speed'). Value goes through
| |
| -- asParam so list-of-dicts collapses to a sane string.
| |
| local label = k:gsub('_', ' ')
| |
| table.insert(parts, label .. ': ' .. asParam(v))
| |
| end
| |
| end
| |
| end
| |
| if #parts > 0 then
| |
| table.insert(lines, '<small>' .. table.concat(parts, ' · ') .. '</small>')
| |
| end
| |
| end
| |
| | |
| if #lines == 0 then return '' end
| |
| local body = table.concat(lines, '<br/>')
| |
| return mw.getCurrentFrame():preprocess(body)
| |
| end | |
|
| |
|
| return p | | return p |