diff --git a/Scribunto.php b/Scribunto.php index c7d420c7..5ead8af0 100644 --- a/Scribunto.php +++ b/Scribunto.php @@ -113,6 +113,7 @@ $wgAutoloadClasses['Scribunto_LuaLanguageLibrary'] = $dir.'engines/LuaCommon/Lan $wgAutoloadClasses['Scribunto_LuaMessageLibrary'] = $dir.'engines/LuaCommon/MessageLibrary.php'; $wgAutoloadClasses['Scribunto_LuaTitleLibrary'] = $dir.'engines/LuaCommon/TitleLibrary.php'; $wgAutoloadClasses['Scribunto_LuaTextLibrary'] = $dir.'engines/LuaCommon/TextLibrary.php'; +$wgAutoloadClasses['Scribunto_LuaHtmlLibrary'] = $dir.'engines/LuaCommon/HtmlLibrary.php'; /***** Configuration *****/ diff --git a/common/Hooks.php b/common/Hooks.php index 01864774..37e2839f 100644 --- a/common/Hooks.php +++ b/common/Hooks.php @@ -316,9 +316,10 @@ WIKI; 'engines/LuaCommon/MessageLibraryTest.php', 'engines/LuaCommon/TitleLibraryTest.php', 'engines/LuaCommon/TextLibraryTest.php', + 'engines/LuaCommon/HtmlLibraryTest.php', ); foreach ( $tests as $test ) { - $files[] = dirname( __FILE__ ) .'/../tests/' . $test; + $files[] = __DIR__ . '/../tests/' . $test; } return true; } diff --git a/engines/LuaCommon/HtmlLibrary.php b/engines/LuaCommon/HtmlLibrary.php new file mode 100644 index 00000000..50fe15e3 --- /dev/null +++ b/engines/LuaCommon/HtmlLibrary.php @@ -0,0 +1,7 @@ +getEngine()->registerInterface( 'mw.html.lua', array() ); + } +} diff --git a/engines/LuaCommon/LuaCommon.php b/engines/LuaCommon/LuaCommon.php index f79bd161..aa9f6d6b 100644 --- a/engines/LuaCommon/LuaCommon.php +++ b/engines/LuaCommon/LuaCommon.php @@ -13,6 +13,7 @@ abstract class Scribunto_LuaEngine extends ScribuntoEngineBase { 'mw.message' => 'Scribunto_LuaMessageLibrary', 'mw.title' => 'Scribunto_LuaTitleLibrary', 'mw.text' => 'Scribunto_LuaTextLibrary', + 'mw.html' => 'Scribunto_LuaHtmlLibrary', ); /** @@ -134,7 +135,7 @@ abstract class Scribunto_LuaEngine extends ScribuntoEngineBase { * @return string */ public function getLuaLibDir() { - return dirname( __FILE__ ) .'/lualib'; + return __DIR__ . '/lualib'; } /** diff --git a/engines/LuaCommon/lualib/mw.html.lua b/engines/LuaCommon/lualib/mw.html.lua new file mode 100644 index 00000000..33e8044d --- /dev/null +++ b/engines/LuaCommon/lualib/mw.html.lua @@ -0,0 +1,372 @@ +--[[ + A module for building complex HTML from Lua using a + fluent interface. + + Originally written on the English Wikipedia by + Toohool and Mr. Stradivarius. + + Code released under the GPL v2+ as per: + https://en.wikipedia.org/w/index.php?diff=next&oldid=581399786 + https://en.wikipedia.org/w/index.php?diff=next&oldid=581403025 + + @license GNU GPL v2+ + @author Marius Hoch < hoo@online.de > +]] + +local HtmlBuilder = {} + +local metatable = {} +local methodtable = {} + +local selfClosingTags = { + area = true, + base = true, + br = true, + col = true, + command = true, + embed = true, + hr = true, + img = true, + input = true, + keygen = true, + link = true, + meta = true, + param = true, + source = true, + track = true, + wbr = true, +} + +local htmlencodeMap = { + ['>'] = '>', + ['<'] = '<', + ['&'] = '&', + ['"'] = '"', +} + +metatable.__index = methodtable + +metatable.__tostring = function( t ) + local ret = {} + t:_build( ret ) + return table.concat( ret ) +end + +-- Get an attribute table (name, value) +-- +-- @param name +local function getAttr( t, name ) + for i, attr in ipairs( t.attributes ) do + if attr.name == name then + return attr + end + end +end + +-- Is this a valid attribute name? +-- +-- @param s +local function isValidAttributeName( s ) + -- Good estimate: http://www.w3.org/TR/2000/REC-xml-20001006#NT-Name + return s:match( '^[a-zA-Z_:][a-zA-Z0-9_.:-]*$' ) +end + +-- Is this a valid tag name? +-- +-- @param s +local function isValidTag( s ) + return s:match( '^[a-zA-Z]+$' ) +end + +-- Escape a value, for use in HTML +-- +-- @param s +local function htmlEncode( s ) + return string.gsub( s, '[<>&"]', htmlencodeMap ) +end + + local function cssEncode( s ) + -- XXX: I'm not sure this character set is complete. + return mw.ustring.gsub( s, '[;:%z\1-\31\127-\244\143\191\191]', function ( m ) + return string.format( '\\%X ', mw.ustring.codepoint( m ) ) + end ) +end + +methodtable._build = function( t, ret ) + if t.tagName then + table.insert( ret, '<' .. t.tagName ) + for i, attr in ipairs( t.attributes ) do + table.insert( + ret, + -- Note: Attribute names have already been validated + ' ' .. attr.name .. '="' .. htmlEncode( attr.val ) .. '"' + ) + end + if #t.styles > 0 then + table.insert( ret, ' style="' ) + for i, prop in ipairs( t.styles ) do + if type( prop ) == 'string' then -- added with cssText() + table.insert( ret, htmlEncode( prop ) .. ';' ) + else -- added with css() + table.insert( + ret, + htmlEncode( cssEncode( prop.name ) .. ':' .. cssEncode( prop.val ) ) .. ';' + ) + end + end + table.insert( ret, '"' ) + end + if t.selfClosing then + table.insert( ret, ' />' ) + return + end + table.insert( ret, '>' ) + end + for i, node in ipairs( t.nodes ) do + if node then + if type( node ) == 'table' then + node:_build( ret ) + else + table.insert( ret, tostring( node ) ) + end + end + end + if t.tagName then + table.insert( ret, '' ) + end +end + +-- Append a builder to the current node +-- +-- @param builder +methodtable.node = function( t, builder ) + if t.selfClosing then + error( "Self-closing tags can't have child nodes" ) + end + + if builder then + table.insert( t.nodes, builder ) + end + return t +end + +-- Appends some markup to the node. This will be treated as wikitext. +methodtable.wikitext = function( t, ... ) + local vals = {...} + for i = 1, #vals do + if type( vals[i] ) ~= 'string' and type( vals[i] ) ~= 'number' then + error( 'Invalid wikitext given: Must be either a string or a number' ) + end + + t:node( vals[i] ) + end + return t +end + +-- Appends a newline character to the node. +methodtable.newline = function( t ) + t:wikitext( '\n' ) + return t +end + +-- Appends a new child node to the builder, and returns an HtmlBuilder instance +-- representing that new node. +-- +-- @param tagName +-- @param args +methodtable.tag = function( t, tagName, args ) + args = args or {} + args.parent = t + local builder = HtmlBuilder.create( tagName, args ) + t:node( builder ) + return builder +end + +-- Get the value of an html attribute +-- +-- @param name +methodtable.getAttr = function( t, name ) + local attr = getAttr( t, name ) + if attr then + return attr.val + end + return nil +end + +-- Set an HTML attribute on the node. +-- +-- @param name Attribute to set, alternative table of name-value pairs +-- @param val Value of the attribute +methodtable.attr = function( t, name, val ) + if type( name ) == 'table' then + if val ~= nil then + error( 'If a key->value table is given as first parameter, value must be left empty' ) + end + + local callForTable = function() + for attrName, attrValue in pairs( name ) do + t:attr( attrName, attrValue ) + end + end + + if not pcall( callForTable ) then + error( 'Invalid table given: Must be name (string) value (string|number) pairs' ) + end + + return t + end + + if type( name ) ~= 'string' and type( name ) ~= 'number' then + error( 'Invalid name given: The name must be either a string or a number' ) + end + if type( val ) ~= 'string' and type( val ) ~= 'number' then + error( 'Invalid value given: The value must be either a string or a number' ) + end + + -- if caller sets the style attribute explicitly, then replace all styles + -- previously added with css() and cssText() + if name == 'style' then + t.styles = { val } + return t + end + + if not isValidAttributeName( name ) then + error( "Invalid attribute name: " .. name ) + end + + local attr = getAttr( t, name ) + if attr then + attr.val = val + else + table.insert( t.attributes, { name = name, val = val } ) + end + + return t +end + +-- Adds a class name to the node's class attribute. Spaces will be +-- automatically added to delimit each added class name. +-- +-- @param class +methodtable.addClass = function( t, class ) + if type( class ) ~= 'string' and type( class ) ~= 'number' then + error( 'Invalid class given: The name must be either a string or a number' ) + end + + local attr = getAttr( t, 'class' ) + if attr then + attr.val = attr.val .. ' ' .. class + else + t:attr( 'class', class ) + end + + return t +end + +-- Set a CSS property to be added to the node's style attribute. +-- +-- @param name CSS attribute to set, alternative table of name-value pairs +-- @param val +methodtable.css = function( t, name, val ) + if type( name ) == 'table' then + if val ~= nil then + error( 'If a key->value table is given as first parameter, value must be left empty' ) + end + + local callForTable = function() + for attrName, attrValue in pairs( name ) do + t:css( attrName, attrValue ) + end + end + + if not pcall( callForTable ) then + error( 'Invalid table given: Must be name (string|number) value (string|number) pairs' ) + end + + return t + end + + if type( name ) ~= 'string' and type( name ) ~= 'number' then + error( 'Invalid CSS given: The name must be either a string or a number' ) + end + if type( val ) ~= 'string' and type( val ) ~= 'number' then + error( 'Invalid CSS given: The value must be either a string or a number' ) + end + + for i, prop in ipairs( t.styles ) do + if prop.name == name then + prop.val = val + return t + end + end + + table.insert( t.styles, { name = name, val = val } ) + + return t +end + +-- Add some raw CSS to the node's style attribute. This is typically used +-- when a template allows some CSS to be passed in as a parameter +-- +-- @param css +methodtable.cssText = function( t, css ) + if type( css ) ~= 'string' and type( css ) ~= 'number' then + error( 'Invalid CSS given: Must be either a string or a number' ) + end + + if css then + table.insert( t.styles, css ) + end + return t +end + +-- Returns the parent node under which the current node was created. Like +-- jQuery.end, this is a convenience function to allow the construction of +-- several child nodes to be chained together into a single statement. +methodtable.done = function( t ) + return t.parent or t +end + +-- Like .done(), but traverses all the way to the root node of the tree and +-- returns it. +methodtable.allDone = function( t ) + while t.parent do + t = t.parent + end + return t +end + +-- Create a new instance +-- +-- @param tagName +-- @param args +function HtmlBuilder.create( tagName, args ) + if tagName ~= '' and not isValidTag( tagName ) then + error( "Invalid tag name: " .. tagName ) + end + + args = args or {} + local builder = {} + setmetatable( builder, metatable ) + builder.nodes = {} + builder.attributes = {} + builder.styles = {} + + if tagName ~= '' then + builder.tagName = tagName + end + + builder.parent = args.parent + builder.selfClosing = selfClosingTags[tagName] or args.selfClosing or false + return builder +end + +mw_interface = nil + +-- Register this library in the "mw" global +mw = mw or {} +mw.html = HtmlBuilder + +package.loaded['mw.html'] = HtmlBuilder + +return HtmlBuilder diff --git a/tests/engines/LuaCommon/HtmlLibraryTest.php b/tests/engines/LuaCommon/HtmlLibraryTest.php new file mode 100644 index 00000000..bb027e41 --- /dev/null +++ b/tests/engines/LuaCommon/HtmlLibraryTest.php @@ -0,0 +1,11 @@ + __DIR__ . '/HtmlLibraryTests.lua', + ); + } +} diff --git a/tests/engines/LuaCommon/HtmlLibraryTests.lua b/tests/engines/LuaCommon/HtmlLibraryTests.lua new file mode 100644 index 00000000..5af0f1cd --- /dev/null +++ b/tests/engines/LuaCommon/HtmlLibraryTests.lua @@ -0,0 +1,287 @@ +--[[ + Tests for the mw.html module + + @license GNU GPL v2+ + @author Marius Hoch < hoo@online.de > +]] + +local testframework = require 'Module:TestFramework' + +local function getEmptyTestDiv() + return mw.html.create( 'div' ) +end + +local function testHelper( obj, method, ... ) + return obj[method]( obj, ... ) +end + +-- Test attrbutes which will always be paired in the same order +local testAttrs = { foo = 'bar', ab = 'cd' } +setmetatable( testAttrs, { __pairs = function ( t ) + local keys = { 'ab', 'foo' } + local i = 0 + return function() + i = i + 1 + if i <= #keys then + return keys[i], t[keys[i]] + end + end +end } ) + + +-- More complex test functions + +local function testMultiAddClass() + return getEmptyTestDiv():addClass( 'foo' ):addClass( 'bar' ) +end + +local function testCssAndCssText() + return getEmptyTestDiv():css( 'foo', 'bar' ):cssText( 'abc:def' ):css( 'g', 'h' ) +end + +local function testTagDone() + return getEmptyTestDiv():tag( 'span' ):done() +end + +local function testNodeDone() + return getEmptyTestDiv():node( getEmptyTestDiv() ):done() +end + +local function testTagNodeAllDone() + return getEmptyTestDiv():tag( 'p' ):node( getEmptyTestDiv() ):allDone() +end + +local function testAttributeOverride() + return getEmptyTestDiv():attr( 'good', 'MediaWiki' ):attr( 'good', 'Wikibase' ) +end + +local function testGetAttribute() + return getEmptyTestDiv():attr( 'town', 'Berlin' ):getAttr( 'town' ) +end + +local function testGetAttributeEscaping() + return getEmptyTestDiv():attr( 'foo', '' ):getAttr( 'foo' ) +end + +local function testNodeSelfClosingDone() + return getEmptyTestDiv():node( mw.html.create( 'br' ) ):done() +end + +local function testNodeAppendToSelfClosing() + return mw.html.create( 'img' ):node( getEmptyTestDiv() ) +end + +local function testWikitextAppendToSelfClosing() + return mw.html.create( 'hr' ):wikitext( 'foo' ) +end + +local function testEmptyCreate() + return mw.html.create( '' ):wikitext( 'foo' ):tag( 'div' ):attr( 'a', 'b' ):allDone() +end + +local function testComplex() + local builder = getEmptyTestDiv() + + builder:addClass( 'firstClass' ):attr( 'what', 'ever' ) + + builder:tag( 'meh' ):attr( 'whynot', 'Русский' ):tag( 'hr' ):attr( 'a', 'b' ) + + builder:node( mw.html.create( 'hr' ) ) + + builder:node( getEmptyTestDiv():attr( 'abc', 'def' ):css( 'width', '-1px' ) ) + + return builder +end + +-- Tests +local tests = { + -- Simple (inline) tests + { name = 'mw.html.create', func = mw.html.create, type='ToString', + args = { 'table' }, + expect = { '
' } + }, + { name = 'mw.html.create (self closing)', func = mw.html.create, type='ToString', + args = { 'br' }, + expect = { '
' } + }, + { name = 'mw.html.create (self closing - forced)', func = mw.html.create, type='ToString', + args = { 'div', { selfClosing = true } }, + expect = { '
' } + }, + { name = 'mw.html.create (invalid tag)', func = mw.html.create, type='ToString', + args = { '$$$$' }, + expect = 'Invalid tag name: $$$$' + }, + { name = 'mw.html.wikitext', func = testHelper, type='ToString', + args = { getEmptyTestDiv(), 'wikitext', 'Plain text' }, + expect = { '
Plain text
' } + }, + { name = 'mw.html.wikitext (invalid input)', func = testHelper, type='ToString', + args = { getEmptyTestDiv(), 'wikitext', 'Plain text', {} }, + expect = 'Invalid wikitext given: Must be either a string or a number' + }, + { name = 'mw.html.newline', func = testHelper, type='ToString', + args = { getEmptyTestDiv(), 'newline' }, + expect = { '
\n
' } + }, + { name = 'mw.html.tag', func = testHelper, type='ToString', + args = { getEmptyTestDiv(), 'tag', 'span' }, + -- tag is only supposed to return the new (inner) node + expect = { '' } + }, + { name = 'mw.html.attr', func = testHelper, type='ToString', + args = { getEmptyTestDiv(), 'attr', 'foo', 'bar' }, + expect = { '
' } + }, + { name = 'mw.html.attr (table 1)', func = testHelper, type='ToString', + args = { getEmptyTestDiv(), 'attr', { foo = 'bar' } }, + expect = { '
' } + }, + { name = 'mw.html.attr (table 2)', func = testHelper, type='ToString', + args = { getEmptyTestDiv(), 'attr', testAttrs }, + expect = { '
' } + }, + { name = 'mw.html.attr (invalid name 1)', func = testHelper, type='ToString', + args = { getEmptyTestDiv(), 'attr', true, 'bar' }, + expect = 'Invalid name given: The name must be either a string or a number' + }, + { name = 'mw.html.attr (invalid name 2)', func = testHelper, + args = { getEmptyTestDiv(), 'attr', '§§§§', 'foo' }, + expect = 'Invalid attribute name: §§§§' + }, + { name = 'mw.html.attr (table no value)', func = testHelper, + args = { getEmptyTestDiv(), 'attr', { foo = 'bar' }, 'foo' }, + expect = 'If a key->value table is given as first parameter, value must be left empty' + }, + { name = 'mw.html.attr (invalid value)', func = testHelper, type='ToString', + args = { getEmptyTestDiv(), 'attr', 'foo', true }, + expect = 'Invalid value given: The value must be either a string or a number' + }, + { name = 'mw.html.attr (invalid table 1)', func = testHelper, type='ToString', + args = { getEmptyTestDiv(), 'attr', { foo = {} } }, + expect = 'Invalid table given: Must be name (string) value (string|number) pairs' + }, + { name = 'mw.html.attr (invalid table 2)', func = testHelper, type='ToString', + args = { getEmptyTestDiv(), 'attr', { 1, 2 ,3 } }, + expect = 'Invalid table given: Must be name (string) value (string|number) pairs' + }, + { name = 'mw.html.attr (invalid table 3)', func = testHelper, type='ToString', + args = { getEmptyTestDiv(), 'attr', { foo = 'bar', blah = true } }, + expect = 'Invalid table given: Must be name (string) value (string|number) pairs' + }, + { name = 'mw.html.attr (invalid table 4)', func = testHelper, type='ToString', + args = { getEmptyTestDiv(), 'attr', { [{}] = 'foo' } }, + expect = 'Invalid table given: Must be name (string) value (string|number) pairs' + }, + { name = 'mw.html.getAttr (nil)', func = testHelper, + args = { getEmptyTestDiv(), 'getAttr', 'foo' }, + expect = { nil } + }, + { name = 'mw.html.addClass', func = testHelper, type='ToString', + args = { getEmptyTestDiv(), 'addClass', 'foo' }, + expect = { '
' } + }, + { name = 'mw.html.addClass (invalid value)', func = testHelper, type='ToString', + args = { getEmptyTestDiv(), 'addClass', {} }, + expect = 'Invalid class given: The name must be either a string or a number' + }, + { name = 'mw.html.css', func = testHelper, type='ToString', + args = { getEmptyTestDiv(), 'css', 'foo', 'bar' }, + expect = { '
' } + }, + { name = 'mw.html.css (invalid name 1)', func = testHelper, type='ToString', + args = { getEmptyTestDiv(), 'css', function() end, 'bar' }, + expect = 'Invalid CSS given: The name must be either a string or a number' + }, + { name = 'mw.html.css (table no value)', func = testHelper, type='ToString', + args = { getEmptyTestDiv(), 'css', {}, 'bar' }, + expect = 'If a key->value table is given as first parameter, value must be left empty' + }, + { name = 'mw.html.css (invalid value)', func = testHelper, type='ToString', + args = { getEmptyTestDiv(), 'css', 'foo', {} }, + expect = 'Invalid CSS given: The value must be either a string or a number' + }, + { name = 'mw.html.css (table)', func = testHelper, type='ToString', + args = { getEmptyTestDiv(), 'css', testAttrs }, + expect = { '
' } + }, + { name = 'mw.html.css (invalid table)', func = testHelper, type='ToString', + args = { getEmptyTestDiv(), 'css', { foo = 'bar', ab = true } }, + expect = 'Invalid table given: Must be name (string|number) value (string|number) pairs' + }, + { name = 'mw.html.cssText', func = testHelper, type='ToString', + args = { getEmptyTestDiv(), 'cssText', 'Unit tests, ftw' }, + expect = { '
' } + }, + { name = 'mw.html.cssText (invalid value)', func = testHelper, type='ToString', + args = { getEmptyTestDiv(), 'cssText', {} }, + expect = 'Invalid CSS given: Must be either a string or a number' + }, + { name = 'mw.html attribute escaping (value with double quotes)', func = testHelper, type='ToString', + args = { getEmptyTestDiv(), 'attr', 'foo', 'ble"rgh' }, + expect = { '
' } + }, + { name = 'mw.html attribute escaping 1', func = testHelper, type='ToString', + args = { getEmptyTestDiv(), 'attr', 'foo', 'ble
' } + }, + { name = 'mw.html attribute escaping 2', func = testHelper, type='ToString', + args = { getEmptyTestDiv(), 'attr', 'foo', '' }, + expect = { '
' } + }, + { name = 'mw.html attribute escaping (CSS)', func = testHelper, type='ToString', + args = { getEmptyTestDiv(), 'css', 'mu"ha', 'ha"ha' }, + expect = { '
' } + }, + { name = 'mw.html attribute escaping (CSS raw)', func = testHelper, type='ToString', + args = { getEmptyTestDiv(), 'cssText', 'mu"ha:-ha"ha' }, + expect = { '
' } + }, + + -- Tests defined above + + { name = 'mw.html.addClass (twice) ', func = testMultiAddClass, type='ToString', + expect = { '
' } + }, + { name = 'mw.html.css.cssText.css', func = testCssAndCssText, type='ToString', + expect = { '
' } + }, + { name = 'mw.html.tag (using done)', func = testTagDone, type='ToString', + expect = { '
' } + }, + { name = 'mw.html.node (using done)', func = testNodeDone, type='ToString', + expect = { '
' } + }, + { name = 'mw.html.node (self closing, using done)', func = testNodeSelfClosingDone, type='ToString', + expect = { '

' } + }, + { name = 'mw.html.node (append to self closing)', func = testNodeAppendToSelfClosing, type='ToString', + expect = "Self-closing tags can't have child nodes" + }, + { name = 'mw.html.wikitext (append to self closing)', func = testWikitextAppendToSelfClosing, type='ToString', + expect = "Self-closing tags can't have child nodes" + }, + { name = 'mw.html.tag.node (using allDone)', func = testTagNodeAllDone, type='ToString', + expect = { '

' } + }, + { name = 'mw.html.attr (overrides)', func = testAttributeOverride, type='ToString', + expect = { '
' } + }, + { name = 'mw.html.getAttr', func = testGetAttribute, type='ToString', + expect = { 'Berlin' } + }, + { name = 'mw.html.getAttr (escaping)', func = testGetAttributeEscaping, type='ToString', + expect = { '' } + }, + { name = 'mw.html.create (empty)', func = testEmptyCreate, type='ToString', + expect = { 'foo
' } + }, + { name = 'mw.html complex test', func = testComplex, type='ToString', + expect = { + '

' .. + '
' + } + }, +} + +return testframework.getTestProvider( tests )