mediawiki.jqueryMsg: Refactor handling of replacements/parameters in links
authorBartosz Dziewoński <matma.rex@gmail.com>
Mon, 5 Oct 2015 09:45:08 +0000 (11:45 +0200)
committerBartosz Dziewoński <matma.rex@gmail.com>
Mon, 5 Oct 2015 12:44:59 +0000 (12:44 +0000)
You should now be able to use replacements/parameters (such as '$1')
anywhere inside both link text and link target, in both external links
('[http://foo/ Bar]') and wikilinks ('[[Foo|Bar]]').

As a side effect of various cleanups, HTML in link text is now preserved
in all cases and is never wrapped in <span class="mediaWiki_htmlEmitter">.

Added test cases for it all.

Bug: T49395
Bug: T50064
Change-Id: I56d8f7ec03a70f5c2360d9c5099496ecb2f668ad

resources/src/mediawiki/mediawiki.jqueryMsg.js
tests/qunit/suites/resources/mediawiki/mediawiki.jqueryMsg.test.js

index c9c0455..8b04f09 100644 (file)
@@ -60,6 +60,9 @@
         * Wrapper around jQuery append that converts all non-objects to TextNode so append will not
         * convert what it detects as an htmlString to an element.
         *
+        * If our own htmlEmitter jQuery object is given, its children will be unwrapped and appended to
+        * new parent.
+        *
         * Object elements of children (jQuery, HTMLElement, TextNode, etc.) will be left as is.
         *
         * @private
@@ -78,6 +81,9 @@
                        if ( typeof children[ i ] !== 'object' ) {
                                children[ i ] = document.createTextNode( children[ i ] );
                        }
+                       if ( children[ i ] instanceof jQuery && children[ i ].hasClass( 'mediaWiki_htmlEmitter' ) ) {
+                               children[ i ] = children[ i ].contents();
+                       }
                }
 
                return $parent.append( children );
                        .replace( /&amp;/g, '&' );
        }
 
+       /**
+        * Turn input into a string.
+        *
+        * @private
+        * @param {string|jQuery} input
+        * @return {string} Textual value of input
+        */
+       function textify( input ) {
+               if ( input instanceof jQuery ) {
+                       input = input.text();
+               }
+               return String( input );
+       }
+
        /**
         * Given parser options, return a function that parses a key and replacements, returning jQuery object
         *
 
                return function () {
                        var $target = this.empty();
-                       // TODO: Simply appendWithoutParsing( $target, failableParserFn( arguments ).contents() )
-                       // or Simply appendWithoutParsing( $target, failableParserFn( arguments ) )
-                       $.each( failableParserFn( arguments ).contents(), function ( i, node ) {
-                               appendWithoutParsing( $target, node );
-                       } );
+                       appendWithoutParsing( $target, failableParserFn( arguments ) );
                        return $target;
                };
        };
                                escapedOrLiteralWithoutSpace, escapedOrLiteralWithoutBar, escapedOrRegularLiteral,
                                whitespace, dollar, digits, htmlDoubleQuoteAttributeValue, htmlSingleQuoteAttributeValue,
                                htmlAttributeEquals, openHtmlStartTag, optionalForwardSlash, openHtmlEndTag, closeHtmlTag,
-                               openExtlink, closeExtlink, wikilinkPage, wikilinkContents, openWikilink, closeWikilink, templateName, pipe, colon,
+                               openExtlink, closeExtlink, wikilinkContents, openWikilink, closeWikilink, templateName, pipe, colon,
                                templateContents, openTemplate, closeTemplate,
                                nonWhitespaceExpression, paramExpression, expression, curlyBraceTransformExpression, result,
                                settings = this.settings,
                                return result === null ? null : result.join( '' );
                        }
 
-                       // Used for wikilink page names.  Like literalWithoutBar, but
-                       // without allowing escapes.
-                       function unescapedLiteralWithoutBar() {
-                               var result = nOrMore( 1, regularLiteralWithoutBar )();
-                               return result === null ? null : result.join( '' );
-                       }
-
                        function literal() {
                                var result = nOrMore( 1, escapedOrRegularLiteral )();
                                return result === null ? null : result.join( '' );
                        closeExtlink = makeStringParser( ']' );
                        // this extlink MUST have inner contents, e.g. [foo] not allowed; [foo bar] [foo <i>bar</i>], etc. are allowed
                        function extlink() {
-                               var result, parsedResult;
+                               var result, parsedResult, target;
                                result = null;
                                parsedResult = sequence( [
                                        openExtlink,
-                                       nonWhitespaceExpression,
+                                       nOrMore( 1, nonWhitespaceExpression ),
                                        whitespace,
                                        nOrMore( 1, expression ),
                                        closeExtlink
                                ] );
                                if ( parsedResult !== null ) {
-                                       result = [ 'EXTLINK', parsedResult[ 1 ] ];
-                                       // TODO (mattflaschen, 2013-03-22): Clean this up if possible.
-                                       // It's avoiding CONCAT for single nodes, so they at least doesn't get the htmlEmitter span.
-                                       if ( parsedResult[ 3 ].length === 1 ) {
-                                               result.push( parsedResult[ 3 ][ 0 ] );
-                                       } else {
-                                               result.push( [ 'CONCAT' ].concat( parsedResult[ 3 ] ) );
-                                       }
+                                       // When the entire link target is a single parameter, we can't use CONCAT, as we allow
+                                       // passing fancy parameters (like a whole jQuery object or a function) to use for the
+                                       // link. Check only if it's a single match, since we can either do CONCAT or not for
+                                       // singles with the same effect.
+                                       target = parsedResult[ 1 ].length === 1 ?
+                                               parsedResult[ 1 ][ 0 ] :
+                                               [ 'CONCAT' ].concat( parsedResult[ 1 ] );
+                                       result = [
+                                               'EXTLINK',
+                                               target,
+                                               [ 'CONCAT' ].concat( parsedResult[ 3 ] )
+                                       ];
                                }
                                return result;
                        }
-                       // this is the same as the above extlink, except that the url is being passed on as a parameter
-                       function extLinkParam() {
-                               var result = sequence( [
-                                       openExtlink,
-                                       dollar,
-                                       digits,
-                                       whitespace,
-                                       expression,
-                                       closeExtlink
-                               ] );
-                               if ( result === null ) {
-                                       return null;
-                               }
-                               return [ 'EXTLINKPARAM', parseInt( result[ 2 ], 10 ) - 1, result[ 4 ] ];
-                       }
                        openWikilink = makeStringParser( '[[' );
                        closeWikilink = makeStringParser( ']]' );
                        pipe = makeStringParser( '|' );
                                return result === null ? null : result[ 1 ];
                        }
 
-                       wikilinkPage = choice( [
-                               unescapedLiteralWithoutBar,
-                               template
-                       ] );
-
                        function pipedWikilink() {
                                var result = sequence( [
-                                       wikilinkPage,
+                                       nOrMore( 1, paramExpression ),
                                        pipe,
                                        nOrMore( 1, expression )
                                ] );
-                               return result === null ? null : [ result[ 0 ], [ 'CONCAT' ].concat( result[ 2 ] ) ];
+                               return result === null ? null : [
+                                       [ 'CONCAT' ].concat( result[ 0 ] ),
+                                       [ 'CONCAT' ].concat( result[ 2 ] )
+                               ];
+                       }
+
+                       function unpipedWikilink() {
+                               var result = sequence( [
+                                       nOrMore( 1, paramExpression )
+                               ] );
+                               return result === null ? null : [
+                                       [ 'CONCAT' ].concat( result[ 0 ] )
+                               ];
                        }
 
                        wikilinkContents = choice( [
                                pipedWikilink,
-                               wikilinkPage // unpiped link
+                               unpipedWikilink
                        ] );
 
                        function wikilink() {
                        nonWhitespaceExpression = choice( [
                                template,
                                wikilink,
-                               extLinkParam,
                                extlink,
                                replacement,
                                literalWithoutSpace
                        paramExpression = choice( [
                                template,
                                wikilink,
-                               extLinkParam,
                                extlink,
                                replacement,
                                literalWithoutBar
                        expression = choice( [
                                template,
                                wikilink,
-                               extLinkParam,
                                extlink,
                                replacement,
                                html,
                concat: function ( nodes ) {
                        var $span = $( '<span>' ).addClass( 'mediaWiki_htmlEmitter' );
                        $.each( nodes, function ( i, node ) {
-                               if ( node instanceof jQuery && node.hasClass( 'mediaWiki_htmlEmitter' ) ) {
-                                       $.each( node.contents(), function ( j, childNode ) {
-                                               appendWithoutParsing( $span, childNode );
-                                       } );
-                               } else {
-                                       // Let jQuery append nodes, arrays of nodes and jQuery objects
-                                       // other things (strings, numbers, ..) are appended as text nodes (not as HTML strings)
-                                       appendWithoutParsing( $span, node );
-                               }
+                               // Let jQuery append nodes, arrays of nodes and jQuery objects
+                               // other things (strings, numbers, ..) are appended as text nodes (not as HTML strings)
+                               appendWithoutParsing( $span, node );
                        } );
                        return $span;
                },
                 * @param {string[]} nodes
                 */
                wikilink: function ( nodes ) {
-                       var page, anchor, url;
+                       var page, anchor, url, $el;
 
-                       page = nodes[ 0 ];
+                       page = textify( nodes[ 0 ] );
                        url = mw.util.getUrl( page );
 
                        if ( nodes.length === 1 ) {
                                anchor = nodes[ 1 ];
                        }
 
-                       return $( '<a>' ).attr( {
+                       $el = $( '<a>' ).attr( {
                                title: page,
                                href: url
-                       } )
-                               // FIXME This means that you can't have anything with formatting inside a wikilink.
-                               .text( anchor.jquery ? anchor.text() : anchor );
+                       } );
+                       return appendWithoutParsing( $el, anchor );
                },
 
                /**
                },
 
                /**
-                * Transform parsed structure into external link
-                * If the href is a jQuery object, treat it as "enclosing" the link text.
+                * Transform parsed structure into external link.
                 *
-                * - ... function, treat it as the click handler.
-                * - ... string, treat it as a URI.
+                * The "href" can be:
+                * - a jQuery object, treat it as "enclosing" the link text.
+                * - a function, treat it as the click handler.
+                * - a string, or our htmlEmitter jQuery object, treat it as a URI after stringifying.
                 *
                 * TODO: throw an error if nodes.length > 2 ?
                 *
                        var $el,
                                arg = nodes[ 0 ],
                                contents = nodes[ 1 ];
-                       if ( arg instanceof jQuery ) {
+                       if ( arg instanceof jQuery && !arg.hasClass( 'mediaWiki_htmlEmitter' ) ) {
                                $el = arg;
                        } else {
                                $el = $( '<a>' );
                                        } )
                                        .click( arg );
                                } else {
-                                       $el.attr( 'href', arg.toString() );
+                                       $el.attr( 'href', textify( arg ) );
                                }
                        }
                        return appendWithoutParsing( $el, contents );
                },
 
-               /**
-                * This is basically use a combination of replace + external link (link with parameter
-                * as url), but we don't want to run the regular replace here-on: inserting a
-                * url as href-attribute of a link will automatically escape it already, so
-                * we don't want replace to (manually) escape it as well.
-                *
-                * TODO: throw error if nodes.length > 1 ?
-                *
-                * @param {Array} nodes List of one element, integer, n >= 0
-                * @param {Array} replacements List of at least n strings
-                * @return {string} replacement
-                */
-               extlinkparam: function ( nodes, replacements ) {
-                       var replacement,
-                               index = parseInt( nodes[ 0 ], 10 );
-                       if ( index < replacements.length ) {
-                               replacement = replacements[ index ];
-                       } else {
-                               replacement = '$' + ( index + 1 );
-                       }
-                       return this.extlink( [ replacement, nodes[ 1 ] ] );
-               },
-
                /**
                 * Transform parsed structure into pluralization
                 * n.b. The first node may be a non-integer (for instance, a string representing an Arabic number).
                        for ( formIndex = 0; formIndex < forms.length; formIndex++ ) {
                                form = forms[ formIndex ];
 
-                               if ( form.jquery && form.hasClass( 'mediaWiki_htmlEmitter' ) ) {
+                               if ( form instanceof jQuery && form.hasClass( 'mediaWiki_htmlEmitter' ) ) {
                                        // This is a nested node, may be an explicit plural form like 5=[$2 linktext]
                                        firstChild = form.contents().get( 0 );
                                        if ( firstChild && firstChild.nodeType === Node.TEXT_NODE ) {
index f287caa..9c9b23a 100644 (file)
                process( tasks, QUnit.start );
        } );
 
-       QUnit.test( 'Links', 7, function ( assert ) {
-               var expectedDisambiguationsText,
+       QUnit.test( 'Links', 11, function ( assert ) {
+               var testCases,
+                       expectedDisambiguationsText,
                        expectedMultipleBars,
                        expectedSpecialCharacters;
 
                        expectedListUsersSitename,
                        'Piped wikilink with parser function in the text'
                );
+
+               testCases = [
+                       [
+                               'extlink-html-full',
+                               'asd [http://example.org <strong>Example</strong>] asd',
+                               'asd <a href="http://example.org"><strong>Example</strong></a> asd'
+                       ],
+                       [
+                               'extlink-html-partial',
+                               'asd [http://example.org foo <strong>Example</strong> bar] asd',
+                               'asd <a href="http://example.org">foo <strong>Example</strong> bar</a> asd'
+                       ],
+                       [
+                               'wikilink-html-full',
+                               'asd [[Example|<strong>Example</strong>]] asd',
+                               'asd <a title="Example" href="/wiki/Example"><strong>Example</strong></a> asd'
+                       ],
+                       [
+                               'wikilink-html-partial',
+                               'asd [[Example|foo <strong>Example</strong> bar]] asd',
+                               'asd <a title="Example" href="/wiki/Example">foo <strong>Example</strong> bar</a> asd'
+                       ]
+               ];
+
+               $.each( testCases, function () {
+                       var
+                               key = this[ 0 ],
+                               input = this[ 1 ],
+                               output = this[ 2 ];
+                       mw.messages.set( key, input );
+                       assert.htmlEqual(
+                               formatParse( key ),
+                               output,
+                               'HTML in links: ' + key
+                       );
+               } );
+       } );
+
+       QUnit.test( 'Replacements in links', 14, function ( assert ) {
+               var testCases = [
+                       [
+                               'extlink-param-href-full',
+                               'asd [$1 Example] asd',
+                               'asd <a href="http://example.com">Example</a> asd'
+                       ],
+                       [
+                               'extlink-param-href-partial',
+                               'asd [$1/example Example] asd',
+                               'asd <a href="http://example.com/example">Example</a> asd'
+                       ],
+                       [
+                               'extlink-param-text-full',
+                               'asd [http://example.org $2] asd',
+                               'asd <a href="http://example.org">Text</a> asd'
+                       ],
+                       [
+                               'extlink-param-text-partial',
+                               'asd [http://example.org Example $2] asd',
+                               'asd <a href="http://example.org">Example Text</a> asd'
+                       ],
+                       [
+                               'extlink-param-both-full',
+                               'asd [$1 $2] asd',
+                               'asd <a href="http://example.com">Text</a> asd'
+                       ],
+                       [
+                               'extlink-param-both-partial',
+                               'asd [$1/example Example $2] asd',
+                               'asd <a href="http://example.com/example">Example Text</a> asd'
+                       ],
+                       [
+                               'wikilink-param-href-full',
+                               'asd [[$1|Example]] asd',
+                               'asd <a title="Example" href="/wiki/Example">Example</a> asd'
+                       ],
+                       [
+                               'wikilink-param-href-partial',
+                               'asd [[$1/Test|Example]] asd',
+                               'asd <a title="Example/Test" href="/wiki/Example/Test">Example</a> asd'
+                       ],
+                       [
+                               'wikilink-param-text-full',
+                               'asd [[Example|$2]] asd',
+                               'asd <a title="Example" href="/wiki/Example">Text</a> asd'
+                       ],
+                       [
+                               'wikilink-param-text-partial',
+                               'asd [[Example|Example $2]] asd',
+                               'asd <a title="Example" href="/wiki/Example">Example Text</a> asd'
+                       ],
+                       [
+                               'wikilink-param-both-full',
+                               'asd [[$1|$2]] asd',
+                               'asd <a title="Example" href="/wiki/Example">Text</a> asd'
+                       ],
+                       [
+                               'wikilink-param-both-partial',
+                               'asd [[$1/Test|Example $2]] asd',
+                               'asd <a title="Example/Test" href="/wiki/Example/Test">Example Text</a> asd'
+                       ],
+                       [
+                               'wikilink-param-unpiped-full',
+                               'asd [[$1]] asd',
+                               'asd <a title="Example" href="/wiki/Example">Example</a> asd'
+                       ],
+                       [
+                               'wikilink-param-unpiped-partial',
+                               'asd [[$1/Test]] asd',
+                               'asd <a title="Example/Test" href="/wiki/Example/Test">Example/Test</a> asd'
+                       ]
+               ];
+
+               $.each( testCases, function () {
+                       var
+                               key = this[ 0 ],
+                               input = this[ 1 ],
+                               output = this[ 2 ],
+                               paramHref = key.slice( 0, 8 ) === 'wikilink' ? 'Example' : 'http://example.com',
+                               paramText = 'Text';
+                       mw.messages.set( key, input );
+                       assert.htmlEqual(
+                               formatParse( key, paramHref, paramText ),
+                               output,
+                               'Replacements in links: ' + key
+                       );
+               } );
        } );
 
        // Tests that {{-transformation vs. general parsing are done as requested
                        'Mismatched HTML start and end tag treated as text'
                );
 
-               // TODO (mattflaschen, 2013-03-18): It's not a security issue, but there's no real
-               // reason the htmlEmitter span needs to be here. It's an artifact of how emitting works.
                mw.messages.set( 'jquerymsg-script-and-external-link', '<script>alert( "jquerymsg-script-and-external-link test" );</script> [http://example.com <i>Foo</i> bar]' );
                assert.htmlEqual(
                        formatParse( 'jquerymsg-script-and-external-link' ),
-                       '&lt;script&gt;alert( "jquerymsg-script-and-external-link test" );&lt;/script&gt; <a href="http://example.com"><span class="mediaWiki_htmlEmitter"><i>Foo</i> bar</span></a>',
+                       '&lt;script&gt;alert( "jquerymsg-script-and-external-link test" );&lt;/script&gt; <a href="http://example.com"><i>Foo</i> bar</a>',
                        'HTML tags in external links not interfering with escaping of other tags'
                );
 
                mw.messages.set( 'jquerymsg-link-script', '[http://example.com <script>alert( "jquerymsg-link-script test" );</script>]' );
                assert.htmlEqual(
                        formatParse( 'jquerymsg-link-script' ),
-                       '<a href="http://example.com"><span class="mediaWiki_htmlEmitter">&lt;script&gt;alert( "jquerymsg-link-script test" );&lt;/script&gt;</span></a>',
+                       '<a href="http://example.com">&lt;script&gt;alert( "jquerymsg-link-script test" );&lt;/script&gt;</a>',
                        'Non-whitelisted HTML tag in external link anchor treated as text'
                );
 
                mw.messages.set( 'jquerymsg-wikitext-contents-script', '<i><script>Script inside</script></i>' );
                assert.htmlEqual(
                        formatParse( 'jquerymsg-wikitext-contents-script' ),
-                       '<i><span class="mediaWiki_htmlEmitter">&lt;script&gt;Script inside&lt;/script&gt;</span></i>',
+                       '<i>&lt;script&gt;Script inside&lt;/script&gt;</i>',
                        'Contents of valid tag are treated as wikitext, so invalid HTML element is treated as text'
                );