Merge "LESS embeddable(): Use lessc::toBool"
[lhc/web/wiklou.git] / resources / mediawiki / mediawiki.Title.js
index 33cca58..98277fc 100644 (file)
-/**
- * mediaWiki.Title
- *
+/*!
  * @author Neil Kandalgaonkar, 2010
- * @author Timo Tijhof, 2011
+ * @author Timo Tijhof, 2011-2013
  * @since 1.18
- *
- * Relies on: mw.config (wgFormattedNamespaces, wgNamespaceIds, wgCaseSensitiveNamespaces), mw.util.wikiGetlink
  */
 ( function ( mw, $ ) {
 
-       /* Local space */
-
        /**
-        * Title
-        * @constructor
+        * @class mw.Title
         *
-        * @param title {String} Title of the page. If no second argument given,
-        * this will be searched for a namespace.
-        * @param namespace {Number} (optional) Namespace id. If given, title will be taken as-is.
-        * @return {Title} this
+        * Parse titles into an object struture. Note that when using the constructor
+        * directly, passing invalid titles will result in an exception. Use
+        * #newFromText to use the logic directly and get null for invalid titles
+        * which is easier to work with.
+        *
+        * @constructor
+        * @param {string} title Title of the page. If no second argument given,
+        *  this will be searched for a namespace
+        * @param {number} [namespace=NS_MAIN] If given, will used as default namespace for the given title
+        * @throws {Error} When the title is invalid
         */
        function Title( title, namespace ) {
-               this.ns = 0; // integer namespace id
-               this.name = null; // name in canonical 'database' form
-               this.ext = null; // extension
-
-               if ( arguments.length === 2 ) {
-                       setNameAndExtension( this, title );
-                       this.ns = fixNsId( namespace );
-               } else if ( arguments.length === 1 ) {
-                       setAll( this, title );
+               var parsed = parse( title, namespace );
+               if ( !parsed ) {
+                       throw new Error( 'Unable to parse title' );
                }
+
+               this.namespace = parsed.namespace;
+               this.title = parsed.title;
+               this.ext = parsed.ext;
+               this.fragment = parsed.fragment;
+
                return this;
        }
 
-var
-       /**
-        * Public methods (defined later)
-        */
-       fn,
+       /* Private members */
 
-       /**
-        * Strip some illegal chars: control chars, colon, less than, greater than,
-        * brackets, braces, pipe, whitespace and normal spaces. This still leaves some insanity
-        * intact, like unicode bidi chars, but it's a good start..
-        * @param s {String}
-        * @return {String}
-        */
-       clean = function ( s ) {
-               if ( s !== undefined ) {
-                       return s.replace( /[\x00-\x1f\x23\x3c\x3e\x5b\x5d\x7b\x7c\x7d\x7f\s]+/g, '_' );
-               }
-       },
+       var
 
        /**
-        * Convert db-key to readable text.
-        * @param s {String}
-        * @return {String}
+        * @private
+        * @static
+        * @property NS_MAIN
         */
-       text = function ( s ) {
-               if ( s !== null && s !== undefined ) {
-                       return s.replace( /_/g, ' ' );
-               } else {
-                       return '';
-               }
-       },
+       NS_MAIN = 0,
 
        /**
-        * Sanitize name.
+        * @private
+        * @static
+        * @property NS_TALK
         */
-       fixName = function ( s ) {
-               return clean( $.trim( s ) );
-       },
+       NS_TALK = 1,
 
        /**
-        * Sanitize name.
+        * @private
+        * @static
+        * @property NS_SPECIAL
         */
-       fixExt = function ( s ) {
-               return clean( s );
-       },
+       NS_SPECIAL = -1,
 
        /**
-        * Sanitize namespace id.
-        * @param id {Number} Namespace id.
-        * @return {Number|Boolean} The id as-is or boolean false if invalid.
+        * Get the namespace id from a namespace name (either from the localized, canonical or alias
+        * name).
+        *
+        * Example: On a German wiki this would return 6 for any of 'File', 'Datei', 'Image' or
+        * even 'Bild'.
+        *
+        * @private
+        * @static
+        * @method getNsIdByName
+        * @param {string} ns Namespace name (case insensitive, leading/trailing space ignored)
+        * @return {number|boolean} Namespace id or boolean false
         */
-       fixNsId = function ( id ) {
-               // wgFormattedNamespaces is an object of *string* key-vals (ie. arr["0"] not arr[0] )
-               var ns = mw.config.get( 'wgFormattedNamespaces' )[id.toString()];
+       getNsIdByName = function ( ns ) {
+               var id;
 
-               // Check only undefined (may be false-y, such as '' (main namespace) ).
-               if ( ns === undefined ) {
+               // Don't cast non-strings to strings, because null or undefined should not result in
+               // returning the id of a potential namespace called "Null:" (e.g. on null.example.org/wiki)
+               // Also, toLowerCase throws exception on null/undefined, because it is a String method.
+               if ( typeof ns !== 'string' ) {
+                       return false;
+               }
+               ns = ns.toLowerCase();
+               id = mw.config.get( 'wgNamespaceIds' )[ns];
+               if ( id === undefined ) {
                        return false;
-               } else {
-                       return Number( id );
                }
+               return id;
        },
 
+       rUnderscoreTrim = /^_+|_+$/g,
+
+       rSplit = /^(.+?)_*:_*(.*)$/,
+
+       // See Title.php#getTitleInvalidRegex
+       rInvalid = new RegExp(
+               '[^' + mw.config.get( 'wgLegalTitleChars' ) + ']' +
+               // URL percent encoding sequences interfere with the ability
+               // to round-trip titles -- you can't link to them consistently.
+               '|%[0-9A-Fa-f]{2}' +
+               // XML/HTML character references produce similar issues.
+               '|&[A-Za-z0-9\u0080-\uFFFF]+;' +
+               '|&#[0-9]+;' +
+               '|&#x[0-9A-Fa-f]+;'
+       ),
+
        /**
-        * Get namespace id from namespace name by any known namespace/id pair (localized, canonical or alias).
+        * Internal helper for #constructor and #newFromtext.
         *
-        * @example On a German wiki this would return 6 for any of 'File', 'Datei', 'Image' or even 'Bild'.
-        * @param ns {String} Namespace name (case insensitive, leading/trailing space ignored).
-        * @return {Number|Boolean} Namespace id or boolean false if unrecognized.
+        * Based on Title.php#secureAndSplit
+        *
+        * @private
+        * @static
+        * @method parse
+        * @param {string} title
+        * @param {number} [defaultNamespace=NS_MAIN]
+        * @return {Object|boolean}
         */
-       getNsIdByName = function ( ns ) {
-               // Don't cast non-strings to strings, because null or undefined
-               // should not result in returning the id of a potential namespace
-               // called "Null:" (e.g. on nullwiki.example.org)
-               // Also, toLowerCase throws exception on null/undefined, because
-               // it is a String.prototype method.
-               if ( typeof ns !== 'string' ) {
+       parse = function ( title, defaultNamespace ) {
+               var namespace, m, id, i, fragment, ext;
+
+               namespace = defaultNamespace === undefined ? NS_MAIN : defaultNamespace;
+
+               title = title
+                       // Normalise whitespace to underscores and remove duplicates
+                       .replace( /[ _\s]+/g, '_' )
+                       // Trim underscores
+                       .replace( rUnderscoreTrim, '' );
+
+               if ( title === '' ) {
                        return false;
                }
-               ns = clean( $.trim( ns.toLowerCase() ) ); // Normalize
-               var id = mw.config.get( 'wgNamespaceIds' )[ns];
-               if ( id === undefined ) {
-                       mw.log( 'mw.Title: Unrecognized namespace: ' + ns );
+
+               // Process initial colon
+               if ( title.charAt( 0 ) === ':' ) {
+                       // Initial colon means main namespace instead of specified default
+                       namespace = NS_MAIN;
+                       title = title
+                               // Strip colon
+                               .substr( 1 )
+                               // Trim underscores
+                               .replace( rUnderscoreTrim, '' );
+               }
+
+               // Process namespace prefix (if any)
+               m = title.match( rSplit );
+               if ( m ) {
+                       id = getNsIdByName( m[1] );
+                       if ( id !== false ) {
+                               // Ordinary namespace
+                               namespace = id;
+                               title = m[2];
+
+                               // For Talk:X pages, make sure X has no "namespace" prefix
+                               if ( namespace === NS_TALK && ( m = title.match( rSplit ) ) ) {
+                                       // Disallow titles like Talk:File:x (subject should roundtrip: talk:file:x -> file:x -> file_talk:x)
+                                       if ( getNsIdByName( m[1] ) !== false ) {
+                                               return false;
+                                       }
+                               }
+                       }
+               }
+
+               // Process fragment
+               i = title.indexOf( '#' );
+               if ( i === -1 ) {
+                       fragment = null;
+               } else {
+                       fragment = title
+                               // Get segment starting after the hash
+                               .substr( i + 1 )
+                               // Convert to text
+                               // NB: Must not be trimmed ("Example#_foo" is not the same as "Example#foo")
+                               .replace( /_/g, ' ' );
+
+                       title = title
+                               // Strip hash
+                               .substr( 0, i )
+                               // Trim underscores, again (strips "_" from "bar" in "Foo_bar_#quux")
+                               .replace( rUnderscoreTrim, '' );
+               }
+
+
+               // Reject illegal characters
+               if ( title.match( rInvalid ) ) {
+                       return false;
+               }
+
+               // Disallow titles that browsers or servers might resolve as directory navigation
+               if (
+                       title.indexOf( '.' ) !== -1 && (
+                               title === '.' || title === '..' ||
+                               title.indexOf( './' ) === 0 ||
+                               title.indexOf( '../' ) === 0 ||
+                               title.indexOf( '/./' ) !== -1 ||
+                               title.indexOf( '/../' ) !== -1 ||
+                               title.substr( -2 ) === '/.' ||
+                               title.substr( -3 ) === '/..'
+                       )
+               ) {
                        return false;
                }
-               return fixNsId( id );
+
+               // Disallow magic tilde sequence
+               if ( title.indexOf( '~~~' ) !== -1 ) {
+                       return false;
+               }
+
+               // Disallow titles exceeding the 255 byte size limit (size of underlying database field)
+               // Except for special pages, e.g. [[Special:Block/Long name]]
+               // Note: The PHP implementation also asserts that even in NS_SPECIAL, the title should
+               // be less than 512 bytes.
+               if ( namespace !== NS_SPECIAL && $.byteLength( title ) > 255 ) {
+                       return false;
+               }
+
+               // Can't make a link to a namespace alone.
+               if ( title === '' && namespace !== NS_MAIN ) {
+                       return false;
+               }
+
+               // Any remaining initial :s are illegal.
+               if ( title.charAt( 0 ) === ':' ) {
+                       return false;
+               }
+
+               // For backwards-compatibility with old mw.Title, we separate the extension from the
+               // rest of the title.
+               i = title.lastIndexOf( '.' );
+               if ( i === -1 || title.length <= i + 1 ) {
+                       // Extensions are the non-empty segment after the last dot
+                       ext = null;
+               } else {
+                       ext = title.substr( i + 1 );
+                       title = title.substr( 0, i );
+               }
+
+               return {
+                       namespace: namespace,
+                       title: title,
+                       ext: ext,
+                       fragment: fragment
+               };
        },
 
        /**
-        * Helper to extract namespace, name and extension from a string.
+        * Convert db-key to readable text.
         *
-        * @param title {mw.Title}
-        * @param raw {String}
-        * @return {mw.Title}
+        * @private
+        * @static
+        * @method text
+        * @param {string} s
+        * @return {string}
         */
-       setAll = function ( title, s ) {
-               // In normal browsers the match-array contains null/undefined if there's no match,
-               // IE returns an empty string.
-               var matches = s.match( /^(?:([^:]+):)?(.*?)(?:\.(\w+))?$/ ),
-                       ns_match = getNsIdByName( matches[1] );
-
-               // Namespace must be valid, and title must be a non-empty string.
-               if ( ns_match && typeof matches[2] === 'string' && matches[2] !== '' ) {
-                       title.ns = ns_match;
-                       title.name = fixName( matches[2] );
-                       if ( typeof matches[3] === 'string' && matches[3] !== '' ) {
-                               title.ext = fixExt( matches[3] );
-                       }
+       text = function ( s ) {
+               if ( s !== null && s !== undefined ) {
+                       return s.replace( /_/g, ' ' );
                } else {
-                       // Consistency with MediaWiki PHP: Unknown namespace -> fallback to main namespace.
-                       title.ns = 0;
-                       setNameAndExtension( title, s );
+                       return '';
                }
-               return title;
        },
 
+       // Polyfill for ES5 Object.create
+       createObject = Object.create || ( function () {
+               return function ( o ) {
+                       function Title() {}
+                       if ( o !== Object( o ) ) {
+                               throw new Error( 'Cannot inherit from a non-object' );
+                       }
+                       Title.prototype = o;
+                       return new Title();
+               };
+       }() );
+
+
+       /* Static members */
+
        /**
-        * Helper to extract name and extension from a string.
+        * Constructor for Title objects with a null return instead of an exception for invalid titles.
         *
-        * @param title {mw.Title}
-        * @param raw {String}
-        * @return {mw.Title}
+        * @static
+        * @method
+        * @param {string} title
+        * @param {number} [namespace=NS_MAIN] Default namespace
+        * @return {mw.Title|null} A valid Title object or null if the title is invalid
         */
-       setNameAndExtension = function ( title, raw ) {
-               // In normal browsers the match-array contains null/undefined if there's no match,
-               // IE returns an empty string.
-               var matches = raw.match( /^(?:)?(.*?)(?:\.(\w+))?$/ );
-
-               // Title must be a non-empty string.
-               if ( typeof matches[1] === 'string' && matches[1] !== '' ) {
-                       title.name = fixName( matches[1] );
-                       if ( typeof matches[2] === 'string' && matches[2] !== '' ) {
-                               title.ext = fixExt( matches[2] );
-                       }
-               } else {
-                       throw new Error( 'mw.Title: Could not parse title "' + raw + '"' );
+       Title.newFromText = function ( title, namespace ) {
+               var t, parsed = parse( title, namespace );
+               if ( !parsed ) {
+                       return null;
                }
-               return title;
-       };
 
+               t = createObject( Title.prototype );
+               t.namespace = parsed.namespace;
+               t.title = parsed.title;
+               t.ext = parsed.ext;
+               t.fragment = parsed.fragment;
 
-       /* Static space */
+               return t;
+       };
 
        /**
         * Whether this title exists on the wiki.
-        * @param title {mixed} prefixed db-key name (string) or instance of Title
-        * @return {mixed} Boolean true/false if the information is available. Otherwise null.
+        *
+        * @static
+        * @param {string|mw.Title} title prefixed db-key name (string) or instance of Title
+        * @return {boolean|null} Boolean if the information is available, otherwise null
         */
        Title.exists = function ( title ) {
-               var type = $.type( title ), obj = Title.exist.pages, match;
+               var match,
+                       type = $.type( title ),
+                       obj = Title.exist.pages;
+
                if ( type === 'string' ) {
                        match = obj[title];
                } else if ( type === 'object' && title instanceof Title ) {
@@ -191,27 +318,34 @@ var
                } else {
                        throw new Error( 'mw.Title.exists: title must be a string or an instance of Title' );
                }
+
                if ( typeof match === 'boolean' ) {
                        return match;
                }
+
                return null;
        };
 
-       /**
-        * @var Title.exist {Object}
-        */
        Title.exist = {
                /**
-                * @var Title.exist.pages {Object} Keyed by PrefixedDb title.
                 * Boolean true value indicates page does exist.
+                *
+                * @static
+                * @property {Object} exist.pages Keyed by PrefixedDb title.
                 */
                pages: {},
+
                /**
-                * @example Declare existing titles: Title.exist.set(['User:John_Doe', ...]);
-                * @example Declare titles nonexistent: Title.exist.set(['File:Foo_bar.jpg', ...], false);
-                * @param titles {String|Array} Title(s) in strict prefixedDb title form.
-                * @param state {Boolean} (optional) State of the given titles. Defaults to true.
-                * @return {Boolean}
+                * Example to declare existing titles:
+                *     Title.exist.set(['User:John_Doe', ...]);
+                * Eample to declare titles nonexistent:
+                *     Title.exist.set(['File:Foo_bar.jpg', ...], false);
+                *
+                * @static
+                * @property exist.set
+                * @param {string|Array} titles Title(s) in strict prefixedDb title form
+                * @param {boolean} [state=true] State of the given titles
+                * @return {boolean}
                 */
                set: function ( titles, state ) {
                        titles = $.isArray( titles ) ? titles : [titles];
@@ -224,99 +358,148 @@ var
                }
        };
 
-       /* Public methods */
+       /* Public members */
 
-       fn = {
+       Title.prototype = {
                constructor: Title,
 
                /**
-                * Get the namespace number.
-                * @return {Number}
+                * Get the namespace number
+                *
+                * Example: 6 for "File:Example_image.svg".
+                *
+                * @return {number}
                 */
-               getNamespaceId: function (){
-                       return this.ns;
+               getNamespaceId: function () {
+                       return this.namespace;
                },
 
                /**
-                * Get the namespace prefix (in the content-language).
-                * In NS_MAIN this is '', otherwise namespace name plus ':'
-                * @return {String}
+                * Get the namespace prefix (in the content language)
+                *
+                * Example: "File:" for "File:Example_image.svg".
+                * In #NS_MAIN this is '', otherwise namespace name plus ':'
+                *
+                * @return {string}
                 */
-               getNamespacePrefix: function (){
-                       return mw.config.get( 'wgFormattedNamespaces' )[this.ns].replace( / /g, '_' ) + (this.ns === 0 ? '' : ':');
+               getNamespacePrefix: function () {
+                       return this.namespace === NS_MAIN ?
+                               '' :
+                               ( mw.config.get( 'wgFormattedNamespaces' )[ this.namespace ].replace( / /g, '_' ) + ':' );
                },
 
                /**
-                * The name, like "Foo_bar"
-                * @return {String}
+                * Get the page name without extension or namespace prefix
+                *
+                * Example: "Example_image" for "File:Example_image.svg".
+                *
+                * For the page title (full page name without namespace prefix), see #getMain.
+                *
+                * @return {string}
                 */
                getName: function () {
-                       if ( $.inArray( this.ns, mw.config.get( 'wgCaseSensitiveNamespaces' ) ) !== -1 ) {
-                               return this.name;
+                       if ( $.inArray( this.namespace, mw.config.get( 'wgCaseSensitiveNamespaces' ) ) !== -1 ) {
+                               return this.title;
                        } else {
-                               return $.ucFirst( this.name );
+                               return $.ucFirst( this.title );
                        }
                },
 
                /**
-                * The name, like "Foo bar"
-                * @return {String}
+                * Get the page name (transformed by #text)
+                *
+                * Example: "Example image" for "File:Example_image.svg".
+                *
+                * For the page title (full page name without namespace prefix), see #getMainText.
+                *
+                * @return {string}
                 */
                getNameText: function () {
                        return text( this.getName() );
                },
 
                /**
-                * Get full name in prefixed DB form, like File:Foo_bar.jpg,
-                * most useful for API calls, anything that must identify the "title".
+                * Get the extension of the page name (if any)
+                *
+                * @return {string|null} Name extension or null if there is none
                 */
-               getPrefixedDb: function () {
-                       return this.getNamespacePrefix() + this.getMain();
+               getExtension: function () {
+                       return this.ext;
                },
 
                /**
-                * Get full name in text form, like "File:Foo bar.jpg".
-                * @return {String}
+                * Shortcut for appendable string to form the main page name.
+                *
+                * Returns a string like ".json", or "" if no extension.
+                *
+                * @return {string}
                 */
-               getPrefixedText: function () {
-                       return text( this.getPrefixedDb() );
+               getDotExtension: function () {
+                       return this.ext === null ? '' : '.' + this.ext;
                },
 
                /**
-                * The main title (without namespace), like "Foo_bar.jpg"
-                * @return {String}
+                * Get the main page name (transformed by #text)
+                *
+                * Example: "Example_image.svg" for "File:Example_image.svg".
+                *
+                * @return {string}
                 */
                getMain: function () {
                        return this.getName() + this.getDotExtension();
                },
 
                /**
-                * The "text" form, like "Foo bar.jpg"
-                * @return {String}
+                * Get the main page name (transformed by #text)
+                *
+                * Example: "Example image.svg" for "File:Example_image.svg".
+                *
+                * @return {string}
                 */
                getMainText: function () {
                        return text( this.getMain() );
                },
 
                /**
-                * Get the extension (returns null if there was none)
-                * @return {String|null} extension
+                * Get the full page name
+                *
+                * Eaxample: "File:Example_image.svg".
+                * Most useful for API calls, anything that must identify the "title".
+                *
+                * @return {string}
                 */
-               getExtension: function () {
-                       return this.ext;
+               getPrefixedDb: function () {
+                       return this.getNamespacePrefix() + this.getMain();
                },
 
                /**
-                * Convenience method: return string like ".jpg", or "" if no extension
-                * @return {String}
+                * Get the full page name (transformed by #text)
+                *
+                * Example: "File:Example image.svg" for "File:Example_image.svg".
+                *
+                * @return {string}
                 */
-               getDotExtension: function () {
-                       return this.ext === null ? '' : '.' + this.ext;
+               getPrefixedText: function () {
+                       return text( this.getPrefixedDb() );
+               },
+
+               /**
+                * Get the fragment (if any).
+                *
+                * Note that this method (by design) does not include the hash character and
+                * the value is not url encoded.
+                *
+                * @return {string|null}
+                */
+               getFragment: function () {
+                       return this.fragment;
                },
 
                /**
-                * Return the URL to this title
-                * @return {String}
+                * Get the URL to this title
+                *
+                * @see mw.util#wikiGetlink
+                * @return {string}
                 */
                getUrl: function () {
                        return mw.util.wikiGetlink( this.toString() );
@@ -324,19 +507,27 @@ var
 
                /**
                 * Whether this title exists on the wiki.
-                * @return {mixed} Boolean true/false if the information is available. Otherwise null.
+                *
+                * @see #static-method-exists
+                * @return {boolean|null} Boolean if the information is available, otherwise null
                 */
                exists: function () {
                        return Title.exists( this );
                }
        };
 
-       // Alias
-       fn.toString = fn.getPrefixedDb;
-       fn.toText = fn.getPrefixedText;
+       /**
+        * @alias #getPrefixedDb
+        * @method
+        */
+       Title.prototype.toString = Title.prototype.getPrefixedDb;
 
-       // Assign
-       Title.prototype = fn;
+
+       /**
+        * @alias #getPrefixedText
+        * @method
+        */
+       Title.prototype.toText = Title.prototype.getPrefixedText;
 
        // Expose
        mw.Title = Title;