@ryusei/code
Version:
<div align="center"> <a href="https://code.ryuseijs.com"> <img alt="RyuseiCode" src="https://code.ryuseijs.com/images/svg/logo.svg" width="70"> </a>
258 lines (224 loc) • 6.51 kB
text/typescript
import { AutoCloseConfig, Language, LanguageConfig, UseConfig } from '@ryusei/code';
import { CATEGORY_BRACKET, CATEGORY_COMMENT, CATEGORY_TAG, CATEGORY_TAG_CLOSE, html as _html } from '@ryusei/light';
import { Editor } from '../../core/Editor/Editor';
import { Lexer } from '../../core/Lexer/Lexer';
import { assign, includes } from '../../utils';
import { css } from '../css/css';
import { javascript } from '../javascript/javascript';
import { SELF_CLOSING_TAGS } from './tags';
/**
* The HTML language options.
*
* @since 0.0.12
*/
export interface HtmlOptions {
script?: {
language: Language;
use : UseConfig;
},
style?: {
language: Language;
use : UseConfig;
},
}
/**
* The max number of lines to scan up for an opening/closing tag.
*/
const SCAN_UP_LIMIT = 500;
/**
* Returns a HTML Language object.
*
* @since 0.1.0
*
* @param options - Optional. Options.
*
* @return A Language object.
*/
export function html( options: HtmlOptions = {} ): Language {
const script = options.script || {
language: javascript(),
use: {
config: javascript(),
code : '<script>',
depth : 2,
},
};
const style = options.style || {
language: css(),
use: {
config: css(),
code : '<style>',
depth : 2,
},
};
const language = _html( { script: () => script.language.language, style: () => style.language.language } );
return assign( {
id: language.id,
language,
lexer: new Lexer( language ),
use: {
[ script.language.id ]: script.use,
[ style.language.id ] : style.use,
},
}, htmlConfig() );
}
/**
* Returns a HTML LanguageConfig object.
*
* @since 0.1.0
*
* @return A LanguageConfig object.
*/
export function htmlConfig(): LanguageConfig {
const quotesCompletionData = {
close : shouldClose,
skip : shouldSkipOrRemove,
remove: shouldSkipOrRemove,
};
return {
lineComment : [ '<!--', '-->' ],
blockComment: [ '<!--', '-->' ],
multiline: [
[ '<!--', '-->', CATEGORY_COMMENT ],
],
indent: [
[ shouldIndent, /^<\/\w+/ ],
],
autoClose: [
[ "'", "'", quotesCompletionData ],
[ '"', '"', quotesCompletionData ],
[ '>', closeCurrentTag ],
[ '/', closeTag ],
[ '=', '""', {
close : shouldAppendQuotes,
offset: 1,
} ],
],
};
}
/**
* Determines whether to append `""` after `=` in a tag.
*
* @param Editor - An editor instance.
*
* @return `true` if quotes should be appended, or otherwise `false`.
*/
function shouldAppendQuotes( Editor: Editor ): boolean {
const { Components } = Editor;
return Components.Scope.isIn( [ '#attr', '#tag', '#openTag', '!value' ] )
&& /(^|[ \t])[^\s/<>"'=]+=$/.test( Components.Input.before );
}
/**
* Determines whether to indent deep or not.
*
* @param editor - An editor instance.
*
* @return `true` if the indent levent should be increased.
*/
function shouldIndent( editor: Editor ): boolean {
return !! getOpeningTagName( editor );
}
/**
* Determines whether to proceed close, skip and remove process.
*
* @param Editor - An Editor instance.
* @param config - A target config.
* @param skipOrRemove - Set `true` when checking a `skip` or `remove` process.
*
* @return `true` if the completion process should be proceeded, or otherwise `false`.
*/
function validateQuotes( Editor: Editor, config: AutoCloseConfig, skipOrRemove: boolean ): boolean {
const { Components } = Editor;
if ( Components.Scope.isIn( [ '#attr' ] ) ) {
if ( skipOrRemove ) {
return true;
}
const { Input } = Components;
return new RegExp( `=\\s*?${ config[ 0 ] }$` ).test( Input.before );
}
}
/**
* Determines whether to close quotes or not.
*
* @param Editor - An Editor instance.
* @param config - A target config.
*
* @return `true` if the quote should be completed.
*/
function shouldClose( Editor: Editor, config: AutoCloseConfig ): boolean {
return validateQuotes( Editor, config, false );
}
/**
* Determines whether to skip/remove quotes or not.
*
* @param Editor - An Editor instance.
* @param config - A target config.
*
* @return `true` if inputting a quote should be skipped.
*/
function shouldSkipOrRemove( Editor: Editor, config: AutoCloseConfig ): boolean {
return validateQuotes( Editor, config, true );
}
/**
* Returns an opening tag name at the current position.
*
* @param Editor - An Editor instance.
*
* @return A tag name if found, or an empty string if not.
*/
function getOpeningTagName( Editor: Editor ): string | undefined {
const { Components } = Editor;
const range = Components.Selection.get();
const col = range.start[ 1 ] - 1;
if ( col >= 0 ) {
const { Code, Code: { lines } } = Components;
const [ tailRow ] = range.start;
const tailInfo = lines.getInfoAt( [ tailRow, col ] );
if ( tailInfo && tailInfo.category === CATEGORY_BRACKET && tailInfo.code === '>' ) {
const { row: headRow, info: headInfo } = lines.scanUp( [ tailRow, tailInfo.from ], [ CATEGORY_BRACKET, /</ ] );
const code = Code.sliceRange( [ headRow, headInfo.from ], [ tailRow, tailInfo.to ] );
const matches = /<([^\s/<>"'=]+)/.exec( code );
if ( matches && ! includes( SELF_CLOSING_TAGS, matches[ 1 ] ) ) {
return matches[ 1 ];
}
}
}
}
/**
* Closes the HTML tag if it is not a self-closed tag.
*
* @param editor - An Editor instance.
*
* @return A closing tag.
*/
function closeCurrentTag( editor: Editor ): string {
const tag = getOpeningTagName( editor );
return tag ? `</${ tag }>` : '';
}
/**
* Attempts to close the tag when the `/` is entered.
* This function is not strict for nested tags.
*
* @param Editor - An Editor instance.
*
* @return A closing tag.
*/
function closeTag( Editor: Editor ): string {
const { Components } = Editor;
const { start } = Components.Selection.get();
const { lines } = Components.Code;
const closingInfo = lines.scanUp( start, [ CATEGORY_TAG_CLOSE, /./ ], null, 0, SCAN_UP_LIMIT );
const openingInfo = lines.scanUp( start, [ CATEGORY_TAG, /./ ], null, 0, SCAN_UP_LIMIT );
if ( openingInfo ) {
if ( closingInfo ) {
if ( ( openingInfo.row - closingInfo.row || openingInfo.info.from - closingInfo.info.from ) < 0 ) {
return '>';
}
}
}
const tag = openingInfo.info.code;
if ( includes( SELF_CLOSING_TAGS, tag ) ) {
return '>';
}
return `${ tag }>`;
}