mediawiki-extensions-CodeMi.../resources/codemirror.mediawiki.js

1304 lines
39 KiB
JavaScript
Raw Normal View History

const {
HighlightStyle,
LanguageSupport,
StreamLanguage,
StreamParser,
StringStream,
Tag,
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' );
/**
* 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( `^(?:${ this.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( this.config.tags ).forEach( ( tag ) => mwModeConfig.addTag( tag ) );
}
/**
* 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;
}
/**
* @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
};
}
}
/**
* 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 {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 )
)
) ];
CodeMirrorPreferences: add panel to tweak prefs with the editor open This is toggled by pressing Mod-Shift-, (or Command-Shift-, on MacOS), which then puts focus on the preferences panel. It can be closed with the Escape key, just like other CM panels. The CodeMirror class comes with these extension which can be toggled in preferences: * Bracket matching * Line numbering * Line wrapping * Highlight the active line * Show special characters Only bracket matching, line numbering, and line wrapping are available in the 2017 editor. The bidi isolation and template folding extensions are registered in CodeMirrorModeMediaWiki as they are MW-specific. CodeMirrorPreferences' new registerExtension() method allows any consumer of CodeMirror to add any arbitrary extensions to the preferences panel. This is expected to be called *after* CodeMirror has finished initializing. The 'ext.CodeMirror.ready' hook now passes the CodeMirror instance to accommodate this. The preferences are stored as a single user option in the database, called 'codemirror-preferences'. The defaults can be configured with the $wgCodeMirrorDefaultPreferences configuration setting. The sysadmin-facing values are the familiar boolean, but since CodeMirror is widely used, we make extra efforts to reduce the storage footprint (see T54777). This includes only storing preferences that differ from the defaults, and using binary representation instead of boolean values, since the user option is stored as a string. For now, all preferences are ignored in the 2017 editor. In a future patch, we may add some as toggleable Tools in the VE toolbar. Other changes: * Refactor CSS to use a .darkmode() mixin * Add a method to create a CSS-only fieldset in CodeMirrorPanel * Fix Jest tests now that there are more calls to mw.user.options.get() * Adjust Selenium tests to always use CM6 * Adjust Selenium tests to delete test pages (useful for local dev) * Remove unused code Bug: T359498 Change-Id: I70dcf2f49418cea632c452c1266440effad634f3
2024-08-16 01:52:13 +00:00
// Register MW-specific Extensions into CodeMirror preferences. Whether they are enabled
// or not is determined by the user's preferences and wiki configuration.
mw.hook( 'ext.CodeMirror.ready' ).add( ( $textarea, cm ) => {
if ( config.templateFolding !== false ) {
cm.preferences.registerExtension( 'templateFolding', templateFoldingExtension, cm.view );
}
if ( config.bidiIsolation ) {
cm.preferences.registerExtension( 'bidiIsolation', bidiIsolationExtension, cm.view );
}
} );
return new LanguageSupport( lang, langExtension );
};
module.exports = mediaWikiLang;