Модуль:Arkitecture
Перейти к навигации
Перейти к поиску
Для документации этого модуля может быть создана страница Модуль:Arkitecture/doc
---
--- Arkitecture
--- =====
---
--- This module performs infobox rendering on the ARK Wiki, validating data and managing Cargo tables. This is a
--- replacement for the old wikitext-based Arkitexure model.
---
--- In this model, there are three primary types of units:
---
--- - Components: Small, stateless, self-contained objects that generate HTML (and virtual Arkitecture units) from
--- information in an instance.
---
--- - Infoboxes: TODO
---
--- @author [[User:alex4401]] (https://github.com/alex4401)
---
local Utility = require( 'Module:Utility' )
-- Forward declarations
local Html
local Cargo
-- TODO: move to Utility
local function appendTable( target, values )
for index = 1, #values do
target[#target + 1] = values[index]
end
end
-- #region Class abstraction
local function Class( constructor, parent )
local methods
if parent ~= nil then
methods = Utility.deepcopy( parent.methods )
else
methods = {}
end
if constructor == nil and parent ~= nil then
constructor = parent.constructor
end
local class = {
constructor = constructor,
parent = parent,
methods = methods,
new = function ( self, ... )
local inst = { mt = {
class = self,
__index = self.methods,
__tostring = self.methods.toString
} }
setmetatable( inst, inst.mt )
if self.constructor ~= nil then
self.constructor( inst, unpack( {...} ) )
end
return inst
end
}
setmetatable( class, { __call = class.new } )
return class
end
-- #endregion
-- #region HTML/wikitext building
-- @param table spec Consists of:
-- - name: string
-- - fallback: string, optional
-- - width: number
local function File( spec )
if spec.name == nil then
return ''
end
if spec.fallback then
if not spec.name or not Utility.doesFileExist( spec.name ) then
spec.name = spec.fallback
end
end
local attrs = {}
if spec.link ~= nil then
attrs[#attrs + 1] = 'link=' .. ( spec.link or '' )
end
if spec.altText ~= nil then
attrs[#attrs + 1] = 'alt=' .. ( spec.altText or '' )
end
if spec.name == '' then
return ''
end
-- Normalise file name. This is rather slow.
spec.name = spec.name:gsub( '/sandbox', '' ):gsub( '[:/]', '_' ):gsub( '__', '_' )
-- Choose a specialised format template and build the final element
if #attrs == 0 then
return string.format( '[[File:%s|%spx]]', spec.name, spec.width )
else
return string.format( '[[File:%s|%spx|%s]]', spec.name, spec.width, table.concat( attrs, '|' ) )
end
end
local function Link( spec )
if type( spec ) == 'string' then
spec = { target = spec, label = spec }
end
if spec == nil or spec.target == nil then
return ''
end
return string.format( '[[%s|%s]]', spec.target, spec.label )
end
local function Pluralised( template, count )
if type( count ) == 'table' then
count = #count
end
return mw.getCurrentFrame():preprocess( template:gsub( '{{PLURAL:|', '{{PLURAL:' .. count .. '|' ) )
end
local function WrapText( tag, text )
if not text or text == '' then
return nil
end
return string.format( '<%s>%s</%s>', tag, text, tag )
end
local function JoinCategory( spec )
if type( spec ) == 'string' then
spec = { target = spec }
end
if spec == nil or spec.target == nil then
return ''
end
if spec.sortKey then
return string.format( '[[Category:%s|%s]]', spec.target, spec.sortKey )
end
return string.format( '[[Category:%s]]', spec.target )
end
local LOCAL_TRANSLATABLE_KEY = string.upper( mw.language.getContentLanguage():getCode() )
local function Translatable( variants )
return variants[LOCAL_TRANSLATABLE_KEY] or variants[1]
end
local function Date( date )
if not date or date == '' then
return ''
end
return mw.language.getContentLanguage():formatDate( 'j xg Y', date )
end
Html = {
---
--- @class HtmlElementOptions: string[]
--- HTML element options.
--- @see Html.Element
--- @field tag string Tag name.
--- @field classes? string|string[] CSS classes.
--- @field attributes? { [string]: string|number } Element attributes.
---
---
--- Constructs an HTML element string.
---
--- @param spec HtmlElementOptions
Element = function ( spec )
if not spec.tag then
error( 'HtmlElement must have a tag specified' )
end
-- Combine all children into a single string
local inner = table.concat( spec, '' )
return string.format( '%s%s</%s>', Html.StartElement( spec ), inner, spec.tag )
end,
StartElement = function ( spec )
if not spec.tag then
error( 'HtmlElement must have a tag specified' )
end
-- Combine all classes into a single string if table
if type( spec.classes ) == 'table' then
spec.classes = table.concat( spec.classes, ' ' )
end
-- Build an attributes string
local attrs
if spec.attributes then
attrs = {}
for name, value in pairs( spec.attributes ) do
attrs[#attrs + 1] = string.format( '%s="%s"', name, tostring( value ) )
end
attrs = table.concat( attrs, ' ' )
end
-- Choose a specialised format template and build the final element
if spec.attributes and spec.classes then
return string.format( '<%s class="%s" %s>', spec.tag, spec.classes, attrs )
elseif spec.attributes then
return string.format( '<%s %s>', spec.tag, attrs )
elseif spec.classes then
return string.format( '<%s class="%s">', spec.tag, spec.classes )
end
return string.format( '<%s>', spec.tag )
end,
--- HTML new line (br element) as a string.
NewLine = '<br/>',
Space = ' ',
NonBreakingSpace = ' ',
--- Faster, but not extensible <small> tag generator.
---
--- @see Html.Element
--- @param inner string
Small = function ( inner )
return string.format( '<small>%s</small>', inner )
end,
}
--- @deprecated
local function HtmlElement( spec )
return Html.Element( spec )
end
-- #endregion
-- #region Components
local Component = function ( implementation )
-- TODO: don't really need a class here at the moment as we don't inject methods
if not implementation.render then
error( 'Method render not implemented on a component.' )
end
return implementation
end
local RendererContext = Class( function ( self, renderer )
self.renderer = renderer
end )
function RendererContext.methods.getParameter( self, name )
return self.renderer:getParameter( name )
end
function RendererContext.methods.hasParameterValueUnchecked( self, name )
return self.renderer:hasParameterValueUnchecked( name )
end
function RendererContext.methods.expandComponent( self, instance )
return self.renderer:expandComponent( instance )
end
function RendererContext.methods.getGameId( self, instance )
return self.renderer:getGameId( instance )
end
function RendererContext.methods.getCargoTablePrefix( self, instance )
return self.renderer:getCargoTablePrefix( instance )
end
function RendererContext.methods.setOpenGraphImage( self, name )
return self.renderer:setOpenGraphImage( name )
end
local ComponentContext = Class( function ( self, renderer, instance )
self._renderer = renderer
self.instance = instance
end )
function ComponentContext.methods.expandComponent( self, instance )
return self._renderer:expandComponent( instance )
end
function ComponentContext.methods.callParserFunction( self, name, args )
return self._renderer.parentFrame:callParserFunction( name, args )
end
function ComponentContext.methods.getCargoTablePrefix( self )
return self._renderer:getCargoTablePrefix()
end
function ComponentContext.methods.getGameId( self, instance )
return self._renderer:getGameId( instance )
end
function ComponentContext.methods.setOpenGraphImage( self, name )
return self._renderer:setOpenGraphImage( name )
end
-- #endregion
-- #region Parameter handling
local ParameterTypes = {
STRING = '_M_PT_STRING',
FILE = '_M_PT_STRING',
FILE_GALLERY = '_M_PT_STRING',
CLASS_PATH = '_M_PT_CLASS_PATH',
BOOL = '_M_PT_BOOL',
NUMBER = '_M_PT_NUMBER',
INTEGER = '_M_PT_NUMBER_INT',
GAME_VERSION = '_M_PT_GVER',
DATE = '_M_PT_DATE',
LIST = '_M_PT_LIST',
GAME = {
'_M_PT_STRING',
AllowedValues = {
'ARK: Survival Evolved',
'ARK: Survival Ascended',
'ARK 2',
},
_CargoTablePrefixes = {
['ARK: Survival Evolved'] = 'ASE',
['ARK: Survival Ascended'] = 'ASA',
['ARK 2'] = 'A2',
}
},
}
local ParameterConstraints = {
ONLY_ONE = ( function ( params )
-- TODO: implement
return true
end ),
}
-- #endregion
-- #region Renderer
local DEFAULT_COMPONENTS = {}
local Renderer = Class( function ( self )
self.frame = mw.getCurrentFrame()
self.parentFrame = self.frame:getParent()
self.componentRegistry = {}
self._parameterCacheKeySet = {}
self.template = self.mt.class.template or nil
for name, interfaceImplementation in pairs( DEFAULT_COMPONENTS ) do
self:registerComponent( name, interfaceImplementation )
end
if self.template.RequiredLibraries then
for index = 1, #self.template.RequiredLibraries do
for name, interfaceImplementation in pairs( require( self.template.RequiredLibraries[index] ) ) do
self:registerComponent( name, interfaceImplementation )
end
end
self.template.RequiredLibraries = nil
end
if self.template.PrivateComponents then
for name, interfaceImplementation in pairs( self.template.PrivateComponents ) do
self:registerComponent( name, interfaceImplementation )
end
self.template.PrivateComponents = nil
end
end )
function Renderer.methods._normaliseParameter( self, paramSpec, value, paramName )
if value == nil then
return paramSpec.Default
end
if type( paramSpec ) == 'string' then
paramSpec = { paramSpec }
end
value = mw.text.trim( value )
if paramSpec[1] == ParameterTypes.BOOL then
value = mw.ustring.lower( value )
-- TODO: move the LUT to avoid making a new one each time we reach this condition
return ( {
yes = true,
['1'] = true,
no = false,
['0'] = false,
} )[value]
elseif paramSpec[1] == ParameterTypes.NUMBER or paramSpec[1] == ParameterTypes.INTEGER then
value = tonumber( value )
elseif paramSpec[1] == ParameterTypes.STRING then
if value == '' then
value = nil
end
elseif paramSpec[1] == ParameterTypes.LIST then
value = mw.text.split( value, ', ', true )
local virtSpec = {
paramSpec[2],
AllowedValues = paramSpec.AllowedValues,
}
for index = 1, #value do
value[index] = self:_normaliseParameter( virtSpec, value[index], paramName )
end
end
if paramSpec[1] ~= ParameterTypes.LIST and paramSpec.AllowedValues ~= nil then
local found = false
for _, aValue in ipairs( paramSpec.AllowedValues ) do
if value == aValue then
found = true
break
end
end
if not found then
error( string.format(
'Parameter "%s" was given a value outside of the allowed value set. Check template documentation.',
paramName or '<unknown>'
) )
end
end
return value
end
function Renderer.methods.hasParameterValueUnchecked( self, name )
-- Still expensive as we're accessing the argument value via frame, but skip normalisation, and unlike
-- getParameter this is NOT cached. Therefore this does not care about any mutations later on.
local value = self._parameterCache[name] or self.frame.args[name] or self.parentFrame.args[name]
if value ~= nil then
value = mw.text.trim( value )
value = value ~= ''
else
value = false
end
return value
end
function Renderer.methods._injectParameters( self, tbl )
if tbl ~= nil then
for name, value in pairs( tbl ) do
if name ~= '__NEXT' then
self._parameterCache[name] = value
end
end
if tbl.__NEXT ~= nil then
self:_injectParameters( tbl.__NEXT() )
end
end
end
function Renderer.methods.getParameter( self, name )
if not self._parameterCacheKeySet[name] then
if not self._parameterCache then
self._parameterCache = {}
if self.template.injectParameters then
self:_injectParameters( self.template:injectParameters( RendererContext( self ) ) )
end
end
-- Retrieve the parameter value from our parameter cache (this will only succeed on injected parameters),
-- module call frame, or template frame (in that order).
local value = self._parameterCache[name] or self.frame.args[name] or self.parentFrame.args[name]
if value == nil then
local lowerCaseName = name:lower()
if lowerCaseName ~= name then
value = self.frame.args[lowerCaseName] or self.parentFrame.args[lowerCaseName]
end
end
local config = self.template.Parameters[name]
if config == nil then
error( 'Attempted to access an undefined parameter: ' .. name )
end
self._parameterCache[name] = self:_normaliseParameter( config, value, name )
self._parameterCacheKeySet[name] = true
end
return self._parameterCache[name]
end
function Renderer.methods.getParameterDetached( self, config, name )
local value = self._parameterCache[name] or self.frame.args[name] or self.parentFrame.args[name]
return self:_normaliseParameter( config, value )
end
function Renderer.methods.registerComponent( self, name, interfaceImplementation )
self.componentRegistry[name] = interfaceImplementation
end
function Renderer.methods.render( self )
local html = {}
local units = self.template.getSetup( self.template, RendererContext( self ) )
for index = 1, #units do
local unit = units[index]
if unit.SideEffect then
-- Render these nodes directly into output
self:_processNodeSet( html, unit )
elseif unit ~= nil then
-- Render the nodes into a separate HTML list
local unitHtml = {}
self:_processNodeSet( unitHtml, unit )
if #unitHtml > 0 then
self:_wrapUnit( unit, unitHtml, html )
end
end
end
return self:_wrapResult( table.concat( html, '' ) )
end
function Renderer.methods._wrapResult( self, inHtml )
if self.template.wrapRendered then
return self.template:wrapRendered( inHtml )
end
return inHtml
end
function Renderer.methods._wrapUnit( self, unit, inHtml, outHtml )
appendTable( outHtml, inHtml )
end
function Renderer.methods._processNodeSet( self, html, unit )
if not unit then
return
elseif type( unit ) == 'string' then
html[#html + 1] = unit
return
elseif unit.__VIRTUAL then
return
elseif unit.Component then
html[#html + 1] = self:expandComponent( unit )
return
end
for index = 1, #unit do
self:_processNodeSet( html, unit[index] )
end
end
function Renderer.methods.expandComponent( self, instance, customRegistry )
local componentSingleton = ( customRegistry and customRegistry.componentRegistry[instance.Component] )
or self.componentRegistry[instance.Component]
if componentSingleton == nil then
error( 'Infobox requires component but component not registered: ' .. instance.Component )
end
local rendered = componentSingleton:render( ComponentContext( self, instance ), instance )
if rendered == '' then
return nil
elseif type( rendered ) == 'table' then
rendered = table.concat( rendered, '' )
end
return rendered
end
function Renderer.methods.getGameId( self )
local game = self:getParameterDetached( ParameterTypes.GAME, 'game' )
local out = ''
if game ~= nil then
out = ParameterTypes.GAME._CargoTablePrefixes[game]
end
return out
end
function Renderer.methods.setOpenGraphImage( self, name )
self.frame:callParserFunction( '#setmainimage', { name } )
return name
end
function Renderer.methods.getCargoTablePrefix( self, skip )
if skip then
return ''
end
if self._cargoTablePrefix == nil then
local out = self:getParameterDetached( { ParameterTypes.STRING, Optional = true }, 'tablePrefix' )
if out == nil and self.template.Parameters.game == ParameterTypes.GAME then
local game = self:getParameterDetached( ParameterTypes.GAME, 'game' )
if game ~= nil then
out = ParameterTypes.GAME._CargoTablePrefixes[game]
end
end
if out ~= nil then
out = out .. '_'
else
out = ''
end
self._cargoTablePrefix = out
end
return self._cargoTablePrefix
end
function Renderer.methods.makeCargoTables( self )
local out = {}
for tableName, tableSpec in pairs( self.template.CargoSetup ) do
local params = {
'_table=' .. self:getCargoTablePrefix( tableSpec.Unprefixed ) .. tableName,
}
for index = 1, #tableSpec do
local columnSpec = tableSpec[index]
local columnName = columnSpec[1]
local columnType = columnSpec[2]
if columnType == Cargo.ColumnTypes.STRING_LIST then
columnType = 'String'
end
local flags = {}
if not columnSpec.Optional then
flags[#flags + 1] = 'mandatory'
end
params[#params + 1] = string.format( '%s = %s (%s)', columnName, columnType,
table.concat( flags, '; ' ) )
end
out[#out + 1] = self.frame:callParserFunction( '#cargo_declare', params )
end
return table.concat( out )
end
local InfoboxRenderer = Class( nil, Renderer )
function InfoboxRenderer.methods._wrapResult( self, inHtml )
return Html.Element{
tag = 'div',
classes = 'arkitect noexcerpt',
attributes = {
role = 'region',
},
self.frame:extensionTag( 'templatestyles', nil, { src = 'Module:Arkitecture/styles.css' } ),
inHtml,
}
end
function InfoboxRenderer.methods._wrapUnit( self, unit, inHtml, outHtml )
-- Determine if this unit should be made collapsible by the user. At least four components are
-- required inside. JavaScript is needed for collapsibles to work.
local isCollapsible = unit.Caption and ( unit.Collapsible == true or ( unit.Collapsible ~= false and #inHtml > 3 ) )
-- Render a container for the unit and concatenate unit's HTML list into the main one. This should
-- be fairly cheap as strings are passed by reference in Lua.
local unitTagSpec = {
tag = 'div',
classes = {
'arkitect-unit',
},
}
if isCollapsible then
unitTagSpec.classes[2] = unit.CollapsedByDefault and 'arkitect-is-collapsed' or nil
unitTagSpec.attributes = {
['data-arkitecture-collapsible'] = true,
}
end
outHtml[#outHtml + 1] = Html.StartElement( unitTagSpec )
if unit.Caption then
outHtml[#outHtml + 1] = HtmlElement{
tag = 'div',
classes = 'arkitect-unit-caption',
unit.Caption,
}
end
appendTable( outHtml, inHtml )
outHtml[#outHtml + 1] = '</div>'
end
local function makeRenderer( template )
local __templateBoundRendererImpl = Class( nil, Renderer )
__templateBoundRendererImpl.template = template
function __templateBoundRendererImpl.render()
return __templateBoundRendererImpl():render()
end
function __templateBoundRendererImpl.makeCargoTables()
return __templateBoundRendererImpl():makeCargoTables()
end
return __templateBoundRendererImpl
end
local function makeInfoboxRenderer( template )
local __templateBoundRendererImpl = Class( nil, InfoboxRenderer )
__templateBoundRendererImpl.template = template
function __templateBoundRendererImpl.render()
return __templateBoundRendererImpl():render()
end
function __templateBoundRendererImpl.makeCargoTables()
return __templateBoundRendererImpl():makeCargoTables()
end
return __templateBoundRendererImpl
end
-- #endregion
-- #region Cargo
Cargo = {
ColumnTypes = {
INTEGER = 'Integer',
FLOAT = 'Float',
STRING = 'String',
STRING_LIST = 4,
TEXT = 'Text',
BOOL = 'Boolean',
DATE = 'Date',
},
GameColumn = {
'ObjectGame',
'String',
Optional = false,
AllowedValues = {
'ARK: Survival Evolved',
'ARK: Survival Ascended',
'ARK 2',
},
},
Query = function ( options )
local results = mw.ext.cargo.query(
options.table,
table.concat( options.fields, ', ' ),
options
)
return results
end,
}
-- TODO: extract as much logic as possible
DEFAULT_COMPONENTS.NewCargoRow = Component{
render = function ( self, ctx )
local params = {
'_table = ' .. ctx:getCargoTablePrefix() .. ctx.instance.Table,
}
-- TODO: private field access, shouldn't really have this dependency here
local tableSpec = ctx._renderer.template.CargoSetup[ctx.instance.Table]
if tableSpec == nil then
error( 'Attempted to add a row to an unknown Cargo table: ' .. ctx.instance.Table )
end
for index = 1, #tableSpec do
local columnSpec = tableSpec[index]
local columnName = columnSpec[1]
local columnType = columnSpec[2]
local value = ctx.instance[columnName]
if value ~= nil then
if columnType == Cargo.ColumnTypes.BOOL then
value = value == true and '1' or value == false and '0' or nil
elseif columnType == Cargo.ColumnTypes.STRING_LIST then
value = table.concat( value, ' :: ' )
end
-- TODO: implement more conversions
end
if value == nil and columnSpec.Default ~= nil then
value = columnSpec.Default
end
if value == nil and not columnSpec.Optional then
error( string.format( 'Found validation errors when inserting a row into Cargo table %s: column %s' ..
'required but value not given.', ctx.instance.Table, columnName ) )
end
if not ( value == nil and columnSpec.Optional ) then
if type( value ) ~= 'string' then
value = tostring( value )
end
params[#params + 1] = string.format( '%s = %s', columnName, value )
end
end
return ctx:callParserFunction( '#cargo_store', params )
end
}
-- #endregion
return {
Class = Class,
File = File,
Link = Link,
Pluralised = Pluralised,
WrapText = WrapText,
JoinCategory = JoinCategory,
Date = Date,
HtmlElement = HtmlElement,
Translatable = Translatable,
Html = Html,
ParameterTypes = ParameterTypes,
ParameterConstraints = ParameterConstraints,
Component = Component,
RendererContext = RendererContext,
ComponentContext = ComponentContext,
makeRenderer = makeRenderer,
makeInfoboxRenderer = makeInfoboxRenderer,
Cargo = Cargo,
}