wikimedia/mediawiki-extensions-Wikibase

View on GitHub
view/resources/wikibase/templates.js

Summary

Maintainability
A
1 hr
Test Coverage
/**
 * @license GPL-2.0-or-later
 * @author H. Snater < mediawiki@snater.com >
 */
( function () {
    'use strict';

    /**
     * Template cache that stores the parameter types templates have been generated with. These
     * templates do not need to be validated anymore allowing to skip the validation process.
     *
     * @type {Object}
     */
    var cache = {};

    /**
     * Returns the type of the specified parameter.
     *
     * @param {*} param
     * @return {string}
     */
    function getParameterType( param ) {
        return ( param instanceof $ ) ? 'jQuery' : typeof param;
    }

    /**
     * Checks whether a specific template has been initialized with the types of the specified
     * parameters before.
     *
     * @param {string} key Template id.
     * @param {*[]} params
     * @return {boolean}
     */
    function areCachedParameterTypes( key, params ) {
        if ( !cache[ key ] ) {
            return false;
        }

        for ( var i = 0; i < cache[ key ].length; i++ ) {
            if ( params.length !== cache[ key ][ i ].length ) {
                return false;
            }

            for ( var j = 0; j < params.length; j++ ) {
                if ( getParameterType( params[ j ] ) !== cache[ key ][ i ][ j ] ) {
                    return false;
                }
            }
        }

        return true;
    }

    /**
     * Strips HTML tags that may be generated automatically like <tbody> as well as all node
     * attributes.
     *
     * @param {string} string
     * @return {string}
     */
    function stripAutoGeneratedHtml( string ) {
        string = string.replace( /<\/?t(?:head|body|foot)\b[^>]*>/gi, '' );

        // strip white space between tags as well since it might cause interference
        string = string.replace( />\s+</g, '><' );

        // Strip all attributes since they are not necessary for validating the HTML and would cause
        // interference in Firefox which re-converts &lt; and &gt; back to < and > when parsing by
        // setting through $.html().
        // Additionally, rip off any XML notation since jQuery will parse to HTML.
        // eslint-disable-next-line security/detect-unsafe-regex -- TODO review
        return string.replace( /(<\w+)\b(?:[^"'>]+|"[^"]*"|'[^']*')*>/g, '$1>' );
    }

    /**
     * Checks whether the HTML to be created out of a jQuery wrapped element is actually valid.
     *
     * @param {string} template HTML
     * @param {jQuery} $wrappedTemplate
     * @return {boolean}
     */
    function isValidHtml( template, $wrappedTemplate ) {
        // HTML node automatically creates a body tag for certain elements that fit right into the
        // body - not for tags like <tr>:
        var parsedTemplate = ( $wrappedTemplate.children( 'body' ).length )
            ? $wrappedTemplate.children( 'body' ).html()
            : $wrappedTemplate.html();

        var strippedTemplate = stripAutoGeneratedHtml( template ),
            strippedParsedTemplate = stripAutoGeneratedHtml( parsedTemplate );

        // Unescape remaining quotes in our template since all attributes are gone
        // and jQuery does not escape quotes in text nodes
        var strippedTemplateWithUnescapedQuotes = strippedTemplate.replace(
            /&quot;|&#039;/g,
            function ( escapedQuote ) {
                return escapedQuote === '&quot;' ? '"' : "'";
            }
        );

        // Nodes or text got lost while being parsed which indicates that the generated HTML would
        // be invalid:
        return strippedTemplateWithUnescapedQuotes === strippedParsedTemplate;
    }

    /**
     * Adds a template to the cache.
     *
     * @param {string} key Template id.
     * @param {*[]} params Original template parameters.
     */
    function addToCache( key, params ) {
        var paramTypes = [];

        if ( !cache[ key ] ) {
            cache[ key ] = [];
        }

        for ( var i = 0; i < params.length; i++ ) {
            var parameterType = getParameterType( params[ i ] );
            if ( parameterType === 'object' ) {
                // Cannot handle some generic object.
                return;
            } else {
                paramTypes.push( parameterType );
            }
        }

        cache[ key ].push( paramTypes );
    }

    /**
     * Returns a template filled with the specified parameters, similar to wfTemplate().
     *
     * @param {string} key Key of the template to get.
     * @param {string|string[]|jQuery} [parameter1] First argument in a list of variadic arguments,
     *        each a parameter for $N replacement in templates. Instead of making use of variadic
     *        arguments, an array may be passed as first parameter.
     * @return {jQuery}
     *
     * @throws {Error} if the generated template's HTML is invalid.
     * @internal Wikibase JavaScript code is not considered a stable interface.
     */
    mw.wbTemplate = function ( key, parameter1 /* [, parameter2[, ...]] */ ) {
        var i,
            params = [],
            template,
            $wrappedTemplate,
            tempParams = [],
            delayedParams = [];

        if ( parameter1 !== undefined ) {
            if ( Array.isArray( parameter1 ) ) {
                params = parameter1;
            } else { // support variadic arguments
                params = Array.prototype.slice.call( arguments );
                params.shift();
            }
        }

        // Pre-parse the template inserting strings and placeholder nodes for jQuery objects jQuery
        // objects will be appended after the template has been parsed to not lose any references:
        for ( i = 0; i < params.length; i++ ) {
            if ( typeof params[ i ] === 'string' || params[ i ] instanceof String ) {
                // insert strings into the template directly
                tempParams.push( mw.html.escape( params[ i ] ) );
            } else if ( params[ i ] instanceof $ ) {
                // construct temporary placeholder nodes
                // (using an actual invalid class name to not interfere with any other node)
                var nodeName = params[ i ][ 0 ].nodeName.toLowerCase();
                tempParams.push( '<' + nodeName + ' class="--mwTemplate"></' + nodeName + '>' );
                delayedParams.push( params[ i ] );
            } else {
                throw new Error( 'mw.wbTemplate: Wrong parameter type. Pass either String or jQuery.' );
            }
        }

        template = mw.wbTemplates.store.get( key );
        var html;
        if ( !template ) {
            html = '⧼' + mw.html.escape( key ) + '⧽';
        } else {
            html = mw.format.apply( null, [ template ].concat( tempParams ) );
        }

        // Wrap template inside a html container to be able to easily access all temporary nodes and
        // insert any jQuery objects:
        $wrappedTemplate = $( '<html>' ).html( html );

        if ( !areCachedParameterTypes( key, params ) ) {
            if ( !isValidHtml( html, $wrappedTemplate ) ) {
                throw new Error( 'mw.wbTemplate: Tried to generate invalid HTML for template "'
                    + key + '"' );
            }
            addToCache( key, params );
        }

        // Replace temporary nodes with actual jQuery nodes:
        $wrappedTemplate.find( '.--mwTemplate' ).each( function ( j ) {
            $( this ).replaceWith( delayedParams[ j ] );
        } );

        return ( $wrappedTemplate.children( 'body' ).length )
            ? $wrappedTemplate.children( 'body' ).contents()
            : $wrappedTemplate.contents();
    };

    /**
     * Fetches a template and fills it with specified parameters. The template has to have a single
     * root DOM element. All of its child nodes will then be appended to the jQuery object's DOM
     * nodes.
     *
     * @param {string} template
     * @param {string|string[]|jQuery} parameter1 First argument in a list of variadic arguments,
     *        each a parameter for $N replacement in templates. Instead of making use of variadic
     *        arguments, an array may be passed as first parameter.
     * @return {jQuery}
     */
    $.fn.applyTemplate = function ( template, parameter1 /* [, parameter2[, ...]] */ ) {
        var $template = mw.wbTemplate.apply( null, arguments );

        if ( $template.length !== 1 ) {
            throw new Error( 'Can not apply a template with more or less than one root node.' );
        }

        var attribs = $template[ 0 ].attributes;

        for ( var i = 0; i < attribs.length; i++ ) {
            if ( attribs[ i ].name === 'class' ) {
                this.addClass( attribs[ i ].value );
            } else {
                this.attr( attribs[ i ].name, attribs[ i ].value );
            }
        }

        this.empty().append( $template.contents() );

        return this;
    };

}() );