mediawiki-extensions-CodeMi.../resources/codemirror.mediawiki.js
bhsd f8a89ccf32 CodeMirror: fix toggle-related issues
The hook handlers and event listeners associated with `CodeMirror.prototype.initialize()` are now removed when CM is toggled off.

For 2017 Wikitext Editor, the focus is always set on the VE surface view.

Bug: T380840
Bug: T380983
Bug: T381358
Change-Id: Ib83f3d49c3d0496cb570f62e75f3fdc0d700be47
2024-12-04 13:23:48 +08:00

1428 lines
44 KiB
JavaScript

const {
CompletionSource,
HighlightStyle,
LanguageSupport,
StreamLanguage,
StreamParser,
StringStream,
Tag,
ensureSyntaxTree,
syntaxHighlighting
} = require( 'ext.CodeMirror.v6.lib' );
const mwModeConfig = require( './codemirror.mediawiki.config.js' );
const bidiIsolationExtension = require( './codemirror.mediawiki.bidiIsolation.js' );
const templateFoldingExtension = require( './codemirror.mediawiki.templateFolding.js' );
const autocompleteExtension = require( './codemirror.mediawiki.autocomplete.js' );
/**
* MediaWiki language support for CodeMirror 6.
* Adapted from the original CodeMirror 5 stream parser by Pavel Astakhov.
*
* @module CodeMirrorModeMediaWiki
*
* @example
* mw.loader.using( [
* 'ext.CodeMirror.v6',
* 'ext.CodeMirror.v6.mode.mediawiki'
* ] ).then( ( require ) => {
* const CodeMirror = require( 'ext.CodeMirror.v6' );
* const mediawikiLang = require( 'ext.CodeMirror.v6.mode.mediawiki' );
* const cm = new CodeMirror( myTextarea );
* cm.initialize( [ cm.defaultExtensions, mediawikiLang() ] );
* } );
*/
class CodeMirrorModeMediaWiki {
/**
* @param {Object} config MediaWiki configuration as generated by DataScript.php
* @internal
*/
constructor( config ) {
this.config = config;
this.urlProtocols = new RegExp( `^(?:${ config.urlProtocols })(?=[^\\s\u00a0{[\\]<>~).,'])`, 'i' );
this.isBold = false;
this.wasBold = false;
this.isItalic = false;
this.wasItalic = false;
this.firstSingleLetterWord = null;
this.firstMultiLetterWord = null;
this.firstSpace = null;
this.oldStyle = null;
this.tokens = [];
this.oldTokens = [];
this.tokenTable = mwModeConfig.tokenTable;
this.registerGroundTokens();
// Dynamically register any tags that aren't already in CodeMirrorModeMediaWikiConfig
Object.keys( config.tags ).forEach( ( tag ) => mwModeConfig.addTag( tag ) );
this.functionSynonyms = [
...Object.keys( config.functionSynonyms[ 0 ] )
.map( ( label ) => ( { type: 'function', label } ) ),
...Object.keys( config.functionSynonyms[ 1 ] )
.map( ( label ) => ( { type: 'constant', label } ) )
];
this.doubleUnderscore = [
...Object.keys( config.doubleUnderscore[ 0 ] ),
...Object.keys( config.doubleUnderscore[ 1 ] )
].map( ( label ) => ( { type: 'constant', label } ) );
const extTags = Object.keys( config.tags );
this.extTags = extTags.map( ( label ) => ( { type: 'type', label } ) );
this.htmlTags = Object.keys( mwModeConfig.permittedHtmlTags )
.filter( ( tag ) => !extTags.includes( tag ) )
.map( ( label ) => ( { type: 'type', label } ) );
this.protocols = config.urlProtocols.split( '|' )
.map( ( label ) => ( { type: 'namespace', label: label.replace( /\\([:/])/g, '$1' ) } ) );
}
/**
* Register the ground tokens. These aren't referenced directly in the StreamParser, nor do
* they have a parent Tag, so we don't need them as constants like we do for other tokens.
* See this.makeLocalStyle() for how these tokens are used.
*
* @private
*/
registerGroundTokens() {
[
'mw-ext-ground',
'mw-ext-link-ground',
'mw-ext2-ground',
'mw-ext2-link-ground',
'mw-ext3-ground',
'mw-ext3-link-ground',
'mw-link-ground',
'mw-template-ext-ground',
'mw-template-ext-link-ground',
'mw-template-ext2-ground',
'mw-template-ext2-link-ground',
'mw-template-ext3-ground',
'mw-template-ext3-link-ground',
'mw-template-ground',
'mw-template-link-ground',
'mw-template2-ext-ground',
'mw-template2-ext-link-ground',
'mw-template2-ext2-ground',
'mw-template2-ext2-link-ground',
'mw-template2-ext3-ground',
'mw-template2-ext3-link-ground',
'mw-template2-ground',
'mw-template2-link-ground',
'mw-template3-ext-ground',
'mw-template3-ext-link-ground',
'mw-template3-ext2-ground',
'mw-template3-ext2-link-ground',
'mw-template3-ext3-ground',
'mw-template3-ext3-link-ground',
'mw-template3-ground',
'mw-template3-link-ground'
].forEach( ( ground ) => mwModeConfig.addToken( ground ) );
}
eatHtmlEntity( stream, style ) {
let ok;
if ( stream.eat( '#' ) ) {
if ( stream.eat( 'x' ) ) {
ok = stream.eatWhile( /[a-fA-F\d]/ ) && stream.eat( ';' );
} else {
ok = stream.eatWhile( /[\d]/ ) && stream.eat( ';' );
}
} else {
ok = stream.eatWhile( /[\w.\-:]/ ) && stream.eat( ';' );
}
if ( ok ) {
return mwModeConfig.tags.htmlEntity;
}
return style;
}
isNested( state ) {
return state.nExt > 0 || state.nTemplate > 0 || state.nLink > 0;
}
makeStyle( style, state, endGround ) {
if ( this.isBold || state.nDt > 0 ) {
style += ' ' + mwModeConfig.tags.strong;
}
if ( this.isItalic ) {
style += ' ' + mwModeConfig.tags.em;
}
return this.makeLocalStyle( style, state, endGround );
}
makeLocalStyle( style, state, endGround ) {
let ground = '';
switch ( state.nTemplate ) {
case 0:
break;
case 1:
ground += '-template';
break;
case 2:
ground += '-template2';
break;
default:
ground += '-template3';
break;
}
switch ( state.nExt ) {
case 0:
break;
case 1:
ground += '-ext';
break;
case 2:
ground += '-ext2';
break;
default:
ground += '-ext3';
break;
}
if ( state.nLink > 0 ) {
ground += '-link';
}
if ( ground !== '' ) {
style = `mw${ ground }-ground ${ style }`;
}
if ( endGround ) {
state[ endGround ]--;
}
return style.trim();
}
eatBlock( style, terminator, consumeLast ) {
return ( stream, state ) => {
if ( stream.skipTo( terminator ) ) {
if ( consumeLast !== false ) {
stream.match( terminator );
}
state.tokenize = state.stack.pop();
} else {
stream.skipToEnd();
}
return this.makeLocalStyle( style, state );
};
}
eatEnd( style ) {
return ( stream, state ) => {
stream.skipToEnd();
state.tokenize = state.stack.pop();
return this.makeLocalStyle( style, state );
};
}
eatChar( char, style ) {
return ( stream, state ) => {
state.tokenize = state.stack.pop();
if ( stream.eat( char ) ) {
return this.makeLocalStyle( style, state );
}
return this.makeLocalStyle( mwModeConfig.tags.error, state );
};
}
eatSectionHeader( count ) {
return ( stream, state ) => {
if ( stream.match( /^[^&<[{~]+/ ) ) {
if ( stream.eol() ) {
stream.backUp( count );
state.tokenize = this.eatEnd( mwModeConfig.tags.sectionHeader );
} else if ( stream.match( /^<!--(?!.*?-->.*?=)/, false ) ) {
// T171074: handle trailing comments
stream.backUp( count );
state.tokenize = this.eatBlock( mwModeConfig.tags.sectionHeader, '<!--', false );
}
return mwModeConfig.tags.section; // style is null
}
return this.eatWikiText( mwModeConfig.tags.section )( stream, state );
};
}
inVariable( stream, state ) {
if ( stream.match( /^[^{}|]+/ ) ) {
return this.makeLocalStyle( mwModeConfig.tags.templateVariableName, state );
}
if ( stream.eat( '|' ) ) {
state.tokenize = this.inVariableDefault.bind( this );
return this.makeLocalStyle( mwModeConfig.tags.templateVariableDelimiter, state );
}
if ( stream.match( '}}}' ) ) {
state.tokenize = state.stack.pop();
return this.makeLocalStyle( mwModeConfig.tags.templateVariableBracket, state );
}
if ( stream.match( '{{{' ) ) {
state.stack.push( state.tokenize );
return this.makeLocalStyle( mwModeConfig.tags.templateVariableBracket, state );
}
stream.next();
return this.makeLocalStyle( mwModeConfig.tags.templateVariableName, state );
}
inVariableDefault( stream, state ) {
if ( stream.match( /^[^{}[<&~]+/ ) ) {
return this.makeLocalStyle( mwModeConfig.tags.templateVariable, state );
}
if ( stream.match( '}}}' ) ) {
state.tokenize = state.stack.pop();
return this.makeLocalStyle( mwModeConfig.tags.templateVariableBracket, state );
}
return this.eatWikiText( mwModeConfig.tags.templateVariable )( stream, state );
}
inParserFunctionName( stream, state ) {
// FIXME: {{#name}} and {{uc}} are wrong, must have ':'
if ( stream.match( /^#?[^:}{~]+/ ) ) {
return this.makeLocalStyle( mwModeConfig.tags.parserFunctionName, state );
}
if ( stream.eat( ':' ) ) {
state.tokenize = this.inParserFunctionArguments.bind( this );
return this.makeLocalStyle( mwModeConfig.tags.parserFunctionDelimiter, state );
}
if ( stream.match( '}}' ) ) {
state.tokenize = state.stack.pop();
return this.makeLocalStyle( mwModeConfig.tags.parserFunctionBracket, state, 'nExt' );
}
return this.eatWikiText( mwModeConfig.tags.parserFunction )( stream, state );
}
inParserFunctionArguments( stream, state ) {
if ( stream.match( /^[^|}{[<&~]+/ ) ) {
return this.makeLocalStyle( mwModeConfig.tags.parserFunction, state );
} else if ( stream.eat( '|' ) ) {
return this.makeLocalStyle( mwModeConfig.tags.parserFunctionDelimiter, state );
} else if ( stream.match( '}}' ) ) {
state.tokenize = state.stack.pop();
return this.makeLocalStyle( mwModeConfig.tags.parserFunctionBracket, state, 'nExt' );
}
return this.eatWikiText( mwModeConfig.tags.parserFunction )( stream, state );
}
eatTemplatePageName( haveAte ) {
return ( stream, state ) => {
if ( stream.match( /^[\s\u00a0]*\|[\s\u00a0]*/ ) ) {
state.tokenize = this.eatTemplateArgument( true );
return this.makeLocalStyle( mwModeConfig.tags.templateDelimiter, state );
}
if ( stream.match( /^[\s\u00a0]*\}\}/ ) ) {
state.tokenize = state.stack.pop();
return this.makeLocalStyle( mwModeConfig.tags.templateBracket, state, 'nTemplate' );
}
if ( stream.match( /^[\s\u00a0]*<!--.*?-->/ ) ) {
return this.makeLocalStyle( mwModeConfig.tags.comment, state );
}
if ( haveAte && stream.sol() ) {
// @todo error message
state.nTemplate--;
state.tokenize = state.stack.pop();
return;
}
if ( stream.match( /^[\s\u00a0]*[^\s\u00a0|}<{&~]+/ ) ) {
state.tokenize = this.eatTemplatePageName( true );
return this.makeLocalStyle( mwModeConfig.tags.templateName, state );
} else if ( stream.eatSpace() ) {
if ( stream.eol() === true ) {
return this.makeLocalStyle( mwModeConfig.tags.templateName, state );
}
return this.makeLocalStyle( mwModeConfig.tags.templateName, state );
}
return this.eatWikiText( mwModeConfig.tags.templateName )( stream, state );
};
}
eatTemplateArgument( expectArgName ) {
return ( stream, state ) => {
if ( expectArgName && stream.eatWhile( /[^=|}{[<&~]/ ) ) {
if ( stream.eat( '=' ) ) {
state.tokenize = this.eatTemplateArgument( false );
return this.makeLocalStyle( mwModeConfig.tags.templateArgumentName, state );
}
return this.makeLocalStyle( mwModeConfig.tags.template, state );
} else if ( stream.eatWhile( /[^|}{[<&~]/ ) ) {
return this.makeLocalStyle( mwModeConfig.tags.template, state );
} else if ( stream.eat( '|' ) ) {
state.tokenize = this.eatTemplateArgument( true );
return this.makeLocalStyle( mwModeConfig.tags.templateDelimiter, state );
} else if ( stream.match( '}}' ) ) {
state.tokenize = state.stack.pop();
return this.makeLocalStyle( mwModeConfig.tags.templateBracket, state, 'nTemplate' );
}
return this.eatWikiText( mwModeConfig.tags.template )( stream, state );
};
}
eatExternalLinkProtocol( chars ) {
return ( stream, state ) => {
while ( chars > 0 ) {
chars--;
stream.next();
}
if ( stream.eol() ) {
state.nLink--;
// @todo error message
state.tokenize = state.stack.pop();
} else {
state.tokenize = this.inExternalLink.bind( this );
}
return this.makeLocalStyle( mwModeConfig.tags.extLinkProtocol, state );
};
}
inExternalLink( stream, state ) {
if ( stream.sol() ) {
state.nLink--;
// @todo error message
state.tokenize = state.stack.pop();
return;
}
if ( stream.match( /^[\s\u00a0]*\]/ ) ) {
state.tokenize = state.stack.pop();
return this.makeLocalStyle( mwModeConfig.tags.extLinkBracket, state, 'nLink' );
}
if ( stream.eatSpace() ) {
state.tokenize = this.inExternalLinkText.bind( this );
return this.makeStyle( '', state );
}
if ( stream.match( /^[^\s\u00a0\]{&~']+/ ) || stream.eatSpace() ) {
if ( stream.peek() === '\'' ) {
if ( stream.match( '\'\'', false ) ) {
state.tokenize = this.inExternalLinkText.bind( this );
} else {
stream.next();
}
}
return this.makeStyle( mwModeConfig.tags.extLink, state );
}
return this.eatWikiText( mwModeConfig.tags.extLink )( stream, state );
}
inExternalLinkText( stream, state ) {
if ( stream.sol() ) {
state.nLink--;
// @todo error message
state.tokenize = state.stack.pop();
return;
}
if ( stream.eat( ']' ) ) {
state.tokenize = state.stack.pop();
return this.makeLocalStyle( mwModeConfig.tags.extLinkBracket, state, 'nLink' );
}
if ( stream.match( /^[^'\]{&~<]+/ ) ) {
return this.makeStyle( mwModeConfig.tags.extLinkText, state );
}
return this.eatWikiText( mwModeConfig.tags.extLinkText )( stream, state );
}
inLink( stream, state ) {
if ( stream.sol() ) {
state.nLink--;
// @todo error message
state.tokenize = state.stack.pop();
return;
}
if ( stream.match( /^[\s\u00a0]*#[\s\u00a0]*/ ) ) {
state.tokenize = this.inLinkToSection.bind( this );
return this.makeLocalStyle( mwModeConfig.tags.link, state );
}
if ( stream.match( /^[\s\u00a0]*\|[\s\u00a0]*/ ) ) {
state.tokenize = this.eatLinkText();
return this.makeLocalStyle( mwModeConfig.tags.linkDelimiter, state );
}
if ( stream.match( /^[\s\u00a0]*\]\]/ ) ) {
state.tokenize = state.stack.pop();
return this.makeLocalStyle( mwModeConfig.tags.linkBracket, state, 'nLink' );
}
if ( stream.match( /^[\s\u00a0]*[^\s\u00a0#|\]&~{]+/ ) || stream.eatSpace() ) {
return this.makeStyle(
`${ mwModeConfig.tags.linkPageName } ${ mwModeConfig.tags.pageName }`,
state
);
}
return this.eatWikiText(
`${ mwModeConfig.tags.linkPageName } ${ mwModeConfig.tags.pageName }`
)( stream, state );
}
inLinkToSection( stream, state ) {
if ( stream.sol() ) {
// @todo error message
state.nLink--;
state.tokenize = state.stack.pop();
return;
}
// FIXME '{{' breaks links, example: [[z{{page]]
if ( stream.match( /^[^|\]&~{}]+/ ) ) {
return this.makeLocalStyle( mwModeConfig.tags.linkToSection, state );
}
if ( stream.eat( '|' ) ) {
state.tokenize = this.eatLinkText();
return this.makeLocalStyle( mwModeConfig.tags.linkDelimiter, state );
}
if ( stream.match( ']]' ) ) {
state.tokenize = state.stack.pop();
return this.makeLocalStyle( mwModeConfig.tags.linkBracket, state, 'nLink' );
}
return this.eatWikiText( mwModeConfig.tags.linkToSection )( stream, state );
}
eatLinkText() {
let linkIsBold, linkIsItalic;
return ( stream, state ) => {
let tmpstyle;
if ( stream.match( ']]' ) ) {
state.tokenize = state.stack.pop();
return this.makeLocalStyle( mwModeConfig.tags.linkBracket, state, 'nLink' );
}
if ( stream.match( '\'\'\'' ) ) {
linkIsBold = !linkIsBold;
return this.makeLocalStyle(
`${ mwModeConfig.tags.linkText } ${ mwModeConfig.tags.apostrophes }`,
state
);
}
if ( stream.match( '\'\'' ) ) {
linkIsItalic = !linkIsItalic;
return this.makeLocalStyle(
`${ mwModeConfig.tags.linkText } ${ mwModeConfig.tags.apostrophes }`,
state
);
}
tmpstyle = mwModeConfig.tags.linkText;
if ( linkIsBold ) {
tmpstyle += ' ' + mwModeConfig.tags.strong;
}
if ( linkIsItalic ) {
tmpstyle += ' ' + mwModeConfig.tags.em;
}
if ( stream.match( /^[^'\]{&~<]+/ ) ) {
return this.makeStyle( tmpstyle, state );
}
return this.eatWikiText( tmpstyle )( stream, state );
};
}
eatTagName( chars, isCloseTag, isHtmlTag ) {
return ( stream, state ) => {
let name = '';
while ( chars > 0 ) {
chars--;
name = name + stream.next();
}
stream.eatSpace();
name = name.toLowerCase();
if ( isHtmlTag ) {
if ( isCloseTag && !mwModeConfig.implicitlyClosedHtmlTags[ name ] ) {
state.tokenize = this.eatChar( '>', mwModeConfig.tags.htmlTagBracket );
} else {
state.tokenize = this.eatHtmlTagAttribute( name );
}
return this.makeLocalStyle( mwModeConfig.tags.htmlTagName, state );
}
// it is the extension tag
if ( isCloseTag ) {
state.tokenize = this.eatChar(
'>',
`${ mwModeConfig.tags.extTagBracket } mw-ext-${ name }`
);
} else {
state.tokenize = this.eatExtTagAttribute( name );
}
return this.makeLocalStyle( `${ mwModeConfig.tags.extTagName } mw-ext-${ name }`, state );
};
}
eatHtmlTagAttribute( name ) {
return ( stream, state ) => {
if ( stream.match( /^(?:"[^<">]*"|'[^<'>]*'|[^>/<{&~])+/ ) ) {
return this.makeLocalStyle( mwModeConfig.tags.htmlTagAttribute, state );
}
if ( stream.eat( '>' ) ) {
if ( !( name in mwModeConfig.implicitlyClosedHtmlTags ) ) {
state.inHtmlTag.push( name );
}
state.tokenize = state.stack.pop();
return this.makeLocalStyle( mwModeConfig.tags.htmlTagBracket, state );
}
if ( stream.match( '/>' ) ) {
state.tokenize = state.stack.pop();
return this.makeLocalStyle( mwModeConfig.tags.htmlTagBracket, state );
}
return this.eatWikiText( mwModeConfig.tags.htmlTagAttribute )( stream, state );
};
}
eatNowiki() {
return ( stream ) => {
if ( stream.match( /^[^&]+/ ) ) {
return '';
}
// eat &
stream.next();
return this.eatHtmlEntity( stream, '' );
};
}
eatExtTagAttribute( name ) {
return ( stream, state ) => {
if ( stream.match( /^(?:"[^">]*"|'[^'>]*'|[^>/<{&~])+/ ) ) {
return this.makeLocalStyle( `${ mwModeConfig.tags.extTagAttribute } mw-ext-${ name }`, state );
}
if ( stream.eat( '>' ) ) {
state.extName = name;
// FIXME: remove nowiki and pre from TagModes in extension.json after CM6 upgrade
// leverage the tagModes system for <nowiki> and <pre>
if ( name === 'nowiki' || name === 'pre' ) {
// There's no actual processing within these tags (apart from HTML entities),
// so startState and copyState can be no-ops.
state.extMode = {
startState: () => {},
copyState: () => {},
token: this.eatNowiki()
};
} else if ( name in this.config.tagModes ) {
const mode = this.config.tagModes[ name ];
if ( mode === 'mediawiki' || mode === 'text/mediawiki' ) {
state.extMode = this.mediawiki;
state.extState = state.extMode.startState();
}
}
state.tokenize = this.eatExtTagArea( name );
return this.makeLocalStyle( `${ mwModeConfig.tags.extTagBracket } mw-ext-${ name }`, state );
}
if ( stream.match( '/>' ) ) {
state.tokenize = state.stack.pop();
return this.makeLocalStyle( `${ mwModeConfig.tags.extTagBracket } mw-ext-${ name }`, state );
}
return this.eatWikiText( `${ mwModeConfig.tags.extTagAttribute } mw-ext-${ name }` )( stream, state );
};
}
eatExtTagArea( name ) {
return ( stream, state ) => {
const from = stream.pos,
pattern = new RegExp( `</${ name }\\s*>`, 'i' ),
m = pattern.exec( from ? stream.string.slice( from ) : stream.string );
let origString = false,
to;
if ( m ) {
if ( m.index === 0 ) {
state.tokenize = this.eatExtCloseTag( name );
state.extName = false;
if ( state.extMode !== false ) {
state.extMode = false;
state.extState = false;
}
return state.tokenize( stream, state );
}
to = m.index + from;
origString = stream.string;
stream.string = origString.slice( 0, to );
}
state.stack.push( state.tokenize );
state.tokenize = this.eatExtTokens( origString );
return state.tokenize( stream, state );
};
}
eatExtCloseTag( name ) {
return ( stream, state ) => {
stream.next(); // eat <
stream.next(); // eat /
state.tokenize = this.eatTagName( name.length, true, false );
return this.makeLocalStyle( `${ mwModeConfig.tags.extTagBracket } mw-ext-${ name }`, state );
};
}
eatExtTokens( origString ) {
return ( stream, state ) => {
let ret;
if ( state.extMode === false ) {
ret = mwModeConfig.tags.extTag;
stream.skipToEnd();
} else {
ret = `mw-tag-${ state.extName } ` +
state.extMode.token( stream, state.extState, origString === false );
}
if ( stream.eol() ) {
if ( origString !== false ) {
stream.string = origString;
}
state.tokenize = state.stack.pop();
}
return this.makeLocalStyle( ret, state );
};
}
eatStartTable( stream, state ) {
stream.match( '{|' );
stream.eatSpace();
state.tokenize = this.inTableDefinition.bind( this );
return mwModeConfig.tags.tableBracket;
}
inTableDefinition( stream, state ) {
if ( stream.sol() ) {
state.tokenize = this.inTable.bind( this );
return this.inTable( stream, state );
}
return this.eatWikiText( mwModeConfig.tags.tableDefinition )( stream, state );
}
inTable( stream, state ) {
if ( stream.sol() ) {
stream.eatSpace();
if ( stream.eat( '|' ) ) {
if ( stream.eat( '-' ) ) {
stream.eatSpace();
state.tokenize = this.inTableDefinition.bind( this );
return this.makeLocalStyle( mwModeConfig.tags.tableDelimiter, state );
}
if ( stream.eat( '+' ) ) {
stream.eatSpace();
state.tokenize = this.eatTableRow( true, false, true );
return this.makeLocalStyle( mwModeConfig.tags.tableDelimiter, state );
}
if ( stream.eat( '}' ) ) {
state.tokenize = state.stack.pop();
return this.makeLocalStyle( mwModeConfig.tags.tableBracket, state );
}
stream.eatSpace();
state.tokenize = this.eatTableRow( true, false );
return this.makeLocalStyle( mwModeConfig.tags.tableDelimiter, state );
}
if ( stream.eat( '!' ) ) {
stream.eatSpace();
state.tokenize = this.eatTableRow( true, true );
return this.makeLocalStyle( mwModeConfig.tags.tableDelimiter, state );
}
}
return this.eatWikiText( '' )( stream, state );
}
// isStart actually means whether there may be attributes */
eatTableRow( isStart, isHead, isCaption ) {
let tag = '';
if ( isCaption ) {
tag = mwModeConfig.tags.tableCaption;
} else if ( isHead ) {
tag = mwModeConfig.tags.strong;
}
return ( stream, state ) => {
if ( stream.sol() ) {
if ( stream.match( /^[\s\u00a0]*[|!]/, false ) ) {
state.tokenize = this.inTable.bind( this );
return this.inTable( stream, state );
}
} else {
if ( stream.match( /^[^'|{[<&~!]+/ ) ) {
return this.makeStyle( tag, state );
}
if ( stream.match( '||' ) || ( isHead && stream.match( '!!' ) ) ) {
this.isBold = false;
this.isItalic = false;
state.tokenize = this.eatTableRow( true, isHead, isCaption );
return this.makeLocalStyle( mwModeConfig.tags.tableDelimiter, state );
}
if ( isStart && stream.eat( '|' ) ) {
state.tokenize = this.eatTableRow( false, isHead, isCaption );
return this.makeLocalStyle( mwModeConfig.tags.tableDelimiter, state );
}
}
return this.eatWikiText( tag )( stream, state );
};
}
eatFreeExternalLinkProtocol( stream, state ) {
stream.match( this.urlProtocols );
state.tokenize = this.eatFreeExternalLink.bind( this );
return this.makeLocalStyle( mwModeConfig.tags.freeExtLinkProtocol, state );
}
eatFreeExternalLink( stream, state ) {
if ( stream.sol() ) {
// @todo error message
} else if ( stream.match( /^[^\s\u00a0{[\]<>~).,']*/ ) ) {
if ( stream.peek() === '~' ) {
if ( !stream.match( /^~~~+/, false ) ) {
stream.match( /^~*/ );
return this.makeLocalStyle( mwModeConfig.tags.freeExtLink, state );
}
} else if ( stream.peek() === '{' ) {
if ( !stream.match( '{{', false ) ) {
stream.next();
return this.makeLocalStyle( mwModeConfig.tags.freeExtLink, state );
}
} else if ( stream.peek() === '\'' ) {
if ( !stream.match( '\'\'', false ) ) {
stream.next();
return this.makeLocalStyle( mwModeConfig.tags.freeExtLink, state );
}
} else if ( stream.match( /^[).,]+(?=[^\s\u00a0{[\]<>~).,])/ ) ) {
return this.makeLocalStyle( mwModeConfig.tags.freeExtLink, state );
}
}
state.tokenize = state.stack.pop();
return this.makeLocalStyle( mwModeConfig.tags.freeExtLink, state );
}
eatList( stream, state ) {
// Just consume all nested list and indention syntax when there is more
const mt = stream.match( /^[*#;:]*/u );
if ( mt && !this.isNested( state ) && mt[ 0 ].includes( ';' ) ) {
state.nDt += mt[ 0 ].split( ';' ).length - 1;
}
return this.makeLocalStyle( mwModeConfig.tags.list, state );
}
/**
* @param {string} style
* @return {string|Function}
* @private
*/
eatWikiText( style ) {
return ( stream, state ) => {
let ch, tmp, mt, name, isCloseTag, tagname;
const sol = stream.sol();
function chain( parser ) {
state.stack.push( state.tokenize );
state.tokenize = parser;
return parser( stream, state );
}
if ( sol ) {
// highlight free external links, see T108448
if ( !stream.match( '//', false ) && stream.match( this.urlProtocols ) ) {
state.stack.push( state.tokenize );
state.tokenize = this.eatFreeExternalLink.bind( this );
return this.makeLocalStyle( mwModeConfig.tags.freeExtLinkProtocol, state );
}
ch = stream.next();
switch ( ch ) {
case '-':
if ( stream.match( /^---+/ ) ) {
return mwModeConfig.tags.hr;
}
break;
case '=':
tmp = stream.match( /^(={0,5})(.+?(=\1\s*)(<!--(?!.*-->.*\S).*?)?)$/ );
// Title
if ( tmp ) {
stream.backUp( tmp[ 2 ].length );
state.stack.push( state.tokenize );
state.tokenize = this.eatSectionHeader( tmp[ 3 ].length );
return mwModeConfig.tags.sectionHeader + ' ' +
/**
* Tokens used here include:
* - cm-mw-section-1
* - cm-mw-section-2
* - cm-mw-section-3
* - cm-mw-section-4
* - cm-mw-section-5
* - cm-mw-section-6
*/
mwModeConfig.tags[ `sectionHeader${ tmp[ 1 ].length + 1 }` ];
}
break;
case ';':
stream.backUp( 1 );
// fall through
case '*':
case '#':
return this.eatList( stream, state );
case ':':
// Highlight indented tables :{|, bug T108454
if ( stream.match( /^:*[\s\u00a0]*(?={\|)/ ) ) {
state.stack.push( state.tokenize );
state.tokenize = this.eatStartTable.bind( this );
return mwModeConfig.tags.indenting;
}
return this.eatList( stream, state );
case ' ':
// Leading spaces is valid syntax for tables, bug T108454
if ( stream.match( /^[\s\u00a0]*(?::+[\s\u00a0]*)?{\|/, false ) ) {
stream.eatSpace();
if ( stream.match( /^:+/ ) ) { // ::{|
stream.eatSpace();
state.stack.push( state.tokenize );
state.tokenize = this.eatStartTable.bind( this );
return mwModeConfig.tags.indenting;
}
stream.eat( '{' );
} else {
return mwModeConfig.tags.skipFormatting;
}
// break is not necessary here
// falls through
case '{':
if ( stream.eat( '|' ) ) {
stream.eatSpace();
state.stack.push( state.tokenize );
state.tokenize = this.inTableDefinition.bind( this );
return mwModeConfig.tags.tableBracket;
}
}
} else {
ch = stream.next();
}
switch ( ch ) {
case '&':
return this.makeStyle(
this.eatHtmlEntity( stream, style ),
state
);
case '\'':
// skip the irrelevant apostrophes ( >5 or =4 )
if ( stream.match( /^'*(?=''''')/ ) || stream.match( /^'''(?!')/, false ) ) {
break;
}
if ( stream.match( '\'\'' ) ) { // bold
if ( !( this.firstSingleLetterWord || stream.match( '\'\'', false ) ) ) {
this.prepareItalicForCorrection( stream );
}
this.isBold = !this.isBold;
return this.makeLocalStyle( mwModeConfig.tags.apostrophesBold, state );
} else if ( stream.eat( '\'' ) ) { // italic
this.isItalic = !this.isItalic;
return this.makeLocalStyle( mwModeConfig.tags.apostrophesItalic, state );
}
break;
case '[':
if ( stream.eat( '[' ) ) { // Link Example: [[ Foo | Bar ]]
stream.eatSpace();
if ( /[^\]|[]/.test( stream.peek() ) ) {
state.nLink++;
state.stack.push( state.tokenize );
state.tokenize = this.inLink.bind( this );
return this.makeLocalStyle( mwModeConfig.tags.linkBracket, state );
}
} else {
mt = stream.match( this.urlProtocols );
if ( mt ) {
state.nLink++;
stream.backUp( mt[ 0 ].length );
state.stack.push( state.tokenize );
state.tokenize = this.eatExternalLinkProtocol( mt[ 0 ].length );
return this.makeLocalStyle( mwModeConfig.tags.extLinkBracket, state );
}
}
break;
case '{':
// Can't be a variable when it starts with more than 3 brackets (T108450) or
// a single { followed by a template. E.g. {{{!}} starts a table (T292967).
if ( stream.match( /^{{(?!{|[^{}]*}}(?!}))/ ) ) {
stream.eatSpace();
state.stack.push( state.tokenize );
state.tokenize = this.inVariable.bind( this );
return this.makeLocalStyle(
mwModeConfig.tags.templateVariableBracket,
state
);
} else if ( stream.match( /^{(?!{(?!{))[\s\u00a0]*/ ) ) {
// Parser function
if ( stream.peek() === '#' ) {
state.nExt++;
state.stack.push( state.tokenize );
state.tokenize = this.inParserFunctionName.bind( this );
return this.makeLocalStyle(
mwModeConfig.tags.parserFunctionBracket,
state
);
}
// Check for parser function without '#'
name = stream.match( /^([^}[\]<{|:]+)(.)?/, false );
if ( name ) {
const [ , f, delimiter ] = name,
ff = delimiter === ':' ? f : f.trim(),
ffLower = ff.toLowerCase(),
{ config: { functionSynonyms } } = this;
if (
( !delimiter || delimiter === ':' || delimiter === '}' ) &&
(
Object.prototype.hasOwnProperty.call(
functionSynonyms[ 0 ], ffLower
) ||
Object.prototype.hasOwnProperty.call(
functionSynonyms[ 1 ], ff
)
)
) {
state.nExt++;
state.stack.push( state.tokenize );
state.tokenize = this.inParserFunctionName.bind( this );
return this.makeLocalStyle(
mwModeConfig.tags.parserFunctionBracket,
state
);
}
}
// Template
state.nTemplate++;
state.stack.push( state.tokenize );
state.tokenize = this.eatTemplatePageName( false );
return this.makeLocalStyle( mwModeConfig.tags.templateBracket, state );
}
break;
case '<':
if ( stream.match( '!--' ) ) { // comment
return chain( this.eatBlock( mwModeConfig.tags.comment, '-->' ) );
}
isCloseTag = !!stream.eat( '/' );
tagname = stream.match( /^[a-z][^>/\s\u00a0]*/i );
if ( tagname ) {
tagname = tagname[ 0 ].toLowerCase();
if ( tagname in this.config.tags ) {
// Parser function
if ( isCloseTag === true ) {
return mwModeConfig.tags.error;
}
stream.backUp( tagname.length );
state.stack.push( state.tokenize );
state.tokenize = this.eatTagName( tagname.length, isCloseTag, false );
return this.makeLocalStyle( `${ mwModeConfig.tags.extTagBracket } mw-ext-${ tagname }`, state );
}
if ( tagname in mwModeConfig.permittedHtmlTags ) {
// Html tag
if ( isCloseTag === true && tagname !== state.inHtmlTag.pop() ) {
// Increment position so that the closing '>' gets highlighted red.
stream.pos++;
return mwModeConfig.tags.error;
}
if (
isCloseTag === true &&
tagname in mwModeConfig.implicitlyClosedHtmlTags
) {
return mwModeConfig.tags.error;
}
stream.backUp( tagname.length );
state.stack.push( state.tokenize );
state.tokenize = this.eatTagName(
tagname.length,
// Opening void tags should also be treated as the closing tag.
isCloseTag ||
( tagname in mwModeConfig.implicitlyClosedHtmlTags ),
true
);
return this.makeLocalStyle( mwModeConfig.tags.htmlTagBracket, state );
}
stream.backUp( tagname.length );
}
break;
case '~':
if ( stream.match( /^~{2,4}/ ) ) {
return mwModeConfig.tags.signature;
}
break;
// Maybe double underscored Magic Word such as __TOC__
case '_':
tmp = 1;
// Optimize processing of many underscore symbols
while ( stream.eat( '_' ) ) {
tmp++;
}
// Many underscore symbols
if ( tmp > 2 ) {
if ( !stream.eol() ) {
// Leave last two underscore symbols for processing in next iteration
stream.backUp( 2 );
}
// Optimization: skip regex function for EOL and backup-ed symbols
return this.makeStyle( style, state );
// Check on double underscore Magic Word
} else if ( tmp === 2 ) {
// The same as the end of function except '_' inside and '__' at the end.
name = stream.match( /^([^\s\u00a0>}[\]<{'|&:~]+?)__/ );
if ( name && name[ 0 ] ) {
if (
'__' + name[ 0 ].toLowerCase() in this.config.doubleUnderscore[ 0 ] ||
'__' + name[ 0 ] in this.config.doubleUnderscore[ 1 ]
) {
return mwModeConfig.tags.doubleUnderscore;
}
if ( !stream.eol() ) {
// Two underscore symbols at the end can be the
// beginning of another double underscored Magic Word
stream.backUp( 2 );
}
// Optimization: skip regex for EOL and backup-ed symbols
return this.makeStyle( style, state );
}
}
break;
case ':':
if ( state.nDt > 0 && !this.isNested( state ) ) {
state.nDt--;
return mwModeConfig.tags.indenting;
}
break;
default:
if ( /[\s\u00a0]/.test( ch ) ) {
stream.eatSpace();
// highlight free external links, bug T108448
if ( stream.match( this.urlProtocols, false ) && !stream.match( '//' ) ) {
state.stack.push( state.tokenize );
state.tokenize = this.eatFreeExternalLinkProtocol.bind( this );
return this.makeStyle( style, state );
}
}
break;
}
stream.match( /^[^\s\u00a0_>}[\]<{'|&:~=]+/ );
return this.makeStyle( style, state );
};
}
/**
* Remembers position and status for rollbacking.
* It is needed for changing from bold to italic with apostrophes before it, if required.
*
* @see https://phabricator.wikimedia.org/T108455
*
* @param {StringStream} stream
* @private
*/
prepareItalicForCorrection( stream ) {
// See Parser::doQuotes() in MediaWiki Core, it works similarly.
// this.firstSingleLetterWord has maximum priority
// this.firstMultiLetterWord has medium priority
// this.firstSpace has low priority
const end = stream.pos,
str = stream.string.slice( 0, end - 3 ),
x1 = str.slice( -1 ),
x2 = str.slice( -2, -1 );
// this.firstSingleLetterWord always is undefined here
if ( x1 === ' ' ) {
if ( this.firstMultiLetterWord || this.firstSpace ) {
return;
}
this.firstSpace = end;
} else if ( x2 === ' ' ) {
this.firstSingleLetterWord = end;
} else if ( this.firstMultiLetterWord ) {
return;
} else {
this.firstMultiLetterWord = end;
}
// remember bold and italic state for later restoration
this.wasBold = this.isBold;
this.wasItalic = this.isItalic;
}
/**
* Autocompletion for magic words and tag names.
*
* @return {CompletionSource}
* @private
*/
get completionSource() {
return ( context ) => {
const { state, pos, explicit } = context,
tree = ensureSyntaxTree( state, pos ),
node = tree && tree.resolve( pos, -1 );
if ( !node ) {
return null;
}
const types = new Set( node.name.split( '_' ) ),
isParserFunction = types.has( mwModeConfig.tags.parserFunctionName ),
{ from, to } = node,
search = state.sliceDoc( from, to );
if ( explicit || isParserFunction && search.includes( '#' ) ) {
const validFor = /^[^|{}<>[\]#]*$/;
if ( isParserFunction || types.has( mwModeConfig.tags.templateName ) && !search.includes( ':' ) ) {
return {
from,
options: this.functionSynonyms,
validFor
};
}
} else if ( !types.has( mwModeConfig.tags.comment ) &&
!types.has( mwModeConfig.tags.templateVariableName ) &&
!types.has( mwModeConfig.tags.templateName ) &&
!types.has( mwModeConfig.tags.linkPageName ) &&
!types.has( mwModeConfig.tags.linkToSection ) &&
!types.has( mwModeConfig.tags.extLink )
) {
let mt = context.matchBefore( /__(?:(?!__)[^\s<>[\]{}|#])*$/ );
if ( mt ) {
return {
from: mt.from,
options: this.doubleUnderscore,
validFor: /^[^\s<>[\]{}|#]*$/
};
}
mt = context.matchBefore( /<\/?[a-z\d]*$/i );
const extTags = [ ...types ].filter( ( t ) => t.startsWith( 'mw-tag-' ) ).map( ( s ) => s.slice( 7 ) );
if ( mt && mt.to - mt.from > 1 ) {
const validFor = /^[a-z\d]*$/i;
if ( mt.text[ 1 ] === '/' ) {
const extTag = extTags[ extTags.length - 1 ],
options = [
...this.htmlTags.filter( ( { label } ) => !(
label in mwModeConfig.implicitlyClosedHtmlTags
) ),
...extTag ? [ { type: 'type', label: extTag, boost: 50 } ] : []
];
return { from: mt.from + 2, options, validFor };
}
return {
from: mt.from + 1,
options: [
...this.htmlTags,
...this.extTags.filter( ( { label } ) => !extTags.includes( label ) )
],
validFor
};
}
if ( !types.has( mwModeConfig.tags.linkText ) &&
!types.has( mwModeConfig.tags.extLinkText ) ) {
mt = context.matchBefore( /(?:^|[^[])\[[a-z:/]+$/i );
if ( mt ) {
return {
from: mt.from + ( mt.text[ 1 ] === '[' ? 2 : 1 ),
options: this.protocols,
validFor: /^[a-z:/]*$/i
};
}
}
}
return null;
};
}
/**
* @see https://codemirror.net/docs/ref/#language.StreamParser
* @return {StreamParser}
* @private
*/
get mediawiki() {
return {
name: 'mediawiki',
/**
* Initial State for the parser.
*
* @return {Object}
* @private
*/
startState: () => ( {
tokenize: this.eatWikiText( '' ),
stack: [],
inHtmlTag: [],
extName: false,
extMode: false,
extState: false,
nTemplate: 0,
nLink: 0,
nExt: 0,
nDt: 0
} ),
/**
* Copies the given state.
*
* @param {Object} state
* @return {Object}
* @private
*/
copyState: ( state ) => ( {
tokenize: state.tokenize,
stack: state.stack.concat( [] ),
inHtmlTag: state.inHtmlTag.concat( [] ),
extName: state.extName,
extMode: state.extMode,
extState: state.extMode !== false && state.extMode.copyState( state.extState ),
nTemplate: state.nTemplate,
nLink: state.nLink,
nExt: state.nExt,
nDt: state.nDt
} ),
/**
* Reads one token, advancing the stream past it,
* and returning a string indicating the token's style tag.
*
* @param {StringStream} stream
* @param {Object} state
* @return {string|null}
* @private
*/
token: ( stream, state ) => {
let style, p, t, f,
readyTokens = [],
tmpTokens = [];
if ( this.oldTokens.length > 0 ) {
// just send saved tokens till they exists
t = this.oldTokens.shift();
stream.pos = t.pos;
state = t.state;
return t.style;
}
if ( stream.sol() ) {
// reset bold and italic status in every new line
state.nDt = 0;
this.isBold = false;
this.isItalic = false;
this.firstSingleLetterWord = null;
this.firstMultiLetterWord = null;
this.firstSpace = null;
}
do {
// get token style
style = state.tokenize( stream, state );
f = this.firstSingleLetterWord || this.firstMultiLetterWord || this.firstSpace;
if ( f ) {
// rollback point exists
if ( f !== p ) {
// new rollback point
p = f;
// it's not first rollback point
if ( tmpTokens.length > 0 ) {
// save tokens
readyTokens = readyTokens.concat( tmpTokens );
tmpTokens = [];
}
}
// save token
tmpTokens.push( {
pos: stream.pos,
style,
state: ( state.extMode || this.mediawiki ).copyState( state )
} );
} else {
// rollback point does not exist
// remember style before possible rollback point
this.oldStyle = style;
// just return token style
return style;
}
} while ( !stream.eol() );
if ( this.isBold && this.isItalic ) {
// needs to rollback
// restore status
this.isItalic = this.wasItalic;
this.isBold = this.wasBold;
this.firstSingleLetterWord = null;
this.firstMultiLetterWord = null;
this.firstSpace = null;
if ( readyTokens.length > 0 ) {
// it contains tickets before the point of rollback
// add one apostrophe, next token will be italic (two apostrophes)
readyTokens[ readyTokens.length - 1 ].pos++;
// for sending tokens till the point of rollback
this.oldTokens = readyTokens;
} else {
// there are no tickets before the point of rollback
stream.pos = tmpTokens[ 0 ].pos - 2; // eat( '\'')
// send saved Style
return this.oldStyle;
}
} else {
// do not need to rollback
// send all saved tokens
this.oldTokens = readyTokens.concat( tmpTokens );
}
// return first saved token
t = this.oldTokens.shift();
stream.pos = t.pos;
state = t.state;
return t.style;
},
/**
* @param {Object} state
* @private
*/
blankLine: ( state ) => {
if ( state.extMode && state.extMode.blankLine ) {
state.extMode.blankLine( state.extState );
}
},
/**
* Extra tokens to use in this parser.
*
* @see CodeMirrorModeMediaWikiConfig.defaultTokenTable
* @return {Object<Tag>}
* @private
*/
tokenTable: this.tokenTable,
/**
* @see https://codemirror.net/docs/ref/#language.StreamParser.languageData
* @return {Object<any>}
* @private
*/
languageData: {
// TODO: Rewrite the comment command using jQuery.textSelection
commentTokens: { block: { open: '<!--', close: '-->' } },
autocomplete: this.completionSource
}
};
}
}
let handler;
/**
* Gets a LanguageSupport instance for the MediaWiki mode.
*
* @member CodeMirrorModeMediaWiki
* @method
* @param {Object} [config] Configuration options for the MediaWiki mode.
* @param {boolean} [config.bidiIsolation=false] Enable bidi isolation around HTML tags.
* This should generally always be enabled on RTL pages, but it comes with a performance cost.
* @param {boolean} [config.templateFolding=true] Enable template folding.
* @param {boolean} [config.autocomplete=true] Enable autocompletion.
* @param {Object|null} [mwConfig] Ignore; used only by unit tests.
* @return {LanguageSupport}
* @stable to call
*/
const mediaWikiLang = ( config = { bidiIsolation: false }, mwConfig = null ) => {
mwConfig = mwConfig || mw.config.get( 'extCodeMirrorConfig' );
const mode = new CodeMirrorModeMediaWiki( mwConfig );
const parser = mode.mediawiki;
const lang = StreamLanguage.define( parser );
const langExtension = [ syntaxHighlighting(
HighlightStyle.define(
mwModeConfig.getTagStyles( parser )
)
) ];
// Register MW-specific Extensions into CodeMirror preferences. Whether they are enabled
// or not is determined by the user's preferences and wiki configuration.
if ( handler ) {
mw.hook( 'ext.CodeMirror.ready' ).remove( handler );
}
handler = ( _$textarea, cm ) => {
if ( cm.view ) { // T380840
if ( config.templateFolding !== false ) {
cm.preferences.registerExtension( 'templateFolding', templateFoldingExtension, cm.view );
}
if ( config.autocomplete !== false ) {
cm.preferences.registerExtension( 'autocomplete', autocompleteExtension, cm.view );
}
if ( config.bidiIsolation ) {
cm.preferences.registerExtension( 'bidiIsolation', bidiIsolationExtension, cm.view );
}
}
};
mw.hook( 'ext.CodeMirror.ready' ).add( handler );
return new LanguageSupport( lang, langExtension );
};
module.exports = mediaWikiLang;