You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

1785 lines
43 KiB

'use strict';
Object.defineProperty(exports, '__esModule', { value: true });
const JAVASCRIPT_OUTPUT_NAME = 'javascript';
const CSS_OUTPUT_NAME = 'css';
const TEMPLATE_OUTPUT_NAME = 'template';
// Tag names
const JAVASCRIPT_TAG = 'script';
const STYLE_TAG = 'style';
const TEXTAREA_TAG = 'textarea';
// Boolean attributes
const IS_RAW = 'isRaw';
const IS_SELF_CLOSING = 'isSelfClosing';
const IS_VOID = 'isVoid';
const IS_BOOLEAN = 'isBoolean';
const IS_CUSTOM = 'isCustom';
const IS_SPREAD = 'isSpread';
var c = /*#__PURE__*/Object.freeze({
__proto__: null,
JAVASCRIPT_OUTPUT_NAME: JAVASCRIPT_OUTPUT_NAME,
CSS_OUTPUT_NAME: CSS_OUTPUT_NAME,
TEMPLATE_OUTPUT_NAME: TEMPLATE_OUTPUT_NAME,
JAVASCRIPT_TAG: JAVASCRIPT_TAG,
STYLE_TAG: STYLE_TAG,
TEXTAREA_TAG: TEXTAREA_TAG,
IS_RAW: IS_RAW,
IS_SELF_CLOSING: IS_SELF_CLOSING,
IS_VOID: IS_VOID,
IS_BOOLEAN: IS_BOOLEAN,
IS_CUSTOM: IS_CUSTOM,
IS_SPREAD: IS_SPREAD
});
/**
* Not all the types are handled in this module.
*
* @enum {number}
* @readonly
*/
const TAG = 1; /* TAG */
const ATTR = 2; /* ATTR */
const TEXT = 3; /* TEXT */
const CDATA = 4; /* CDATA */
const COMMENT = 8; /* COMMENT */
const DOCUMENT = 9; /* DOCUMENT */
const DOCTYPE = 10; /* DOCTYPE */
const DOCUMENT_FRAGMENT = 11; /* DOCUMENT_FRAGMENT */
var types = /*#__PURE__*/Object.freeze({
__proto__: null,
TAG: TAG,
ATTR: ATTR,
TEXT: TEXT,
CDATA: CDATA,
COMMENT: COMMENT,
DOCUMENT: DOCUMENT,
DOCTYPE: DOCTYPE,
DOCUMENT_FRAGMENT: DOCUMENT_FRAGMENT
});
const rootTagNotFound = 'Root tag not found.';
const unclosedTemplateLiteral = 'Unclosed ES6 template literal.';
const unexpectedEndOfFile = 'Unexpected end of file.';
const unclosedComment = 'Unclosed comment.';
const unclosedNamedBlock = 'Unclosed "%1" block.';
const duplicatedNamedTag = 'Duplicate tag "<%1>".';
const unexpectedCharInExpression = 'Unexpected character %1.';
const unclosedExpression = 'Unclosed expression.';
/**
* Matches the start of valid tags names; used with the first 2 chars after the `'<'`.
* @const
* @private
*/
const TAG_2C = /^(?:\/[a-zA-Z]|[a-zA-Z][^\s>/]?)/;
/**
* Matches valid tags names AFTER the validation with `TAG_2C`.
* $1: tag name including any `'/'`, $2: non self-closing brace (`>`) w/o attributes.
* @const
* @private
*/
const TAG_NAME = /(\/?[^\s>/]+)\s*(>)?/g;
/**
* Matches an attribute name-value pair (both can be empty).
* $1: attribute name, $2: value including any quotes.
* @const
* @private
*/
const ATTR_START = /(\S[^>/=\s]*)(?:\s*=\s*([^>/])?)?/g;
/**
* Matches the spread operator
* it will be used for the spread attributes
* @type {RegExp}
*/
const SPREAD_OPERATOR = /\.\.\./;
/**
* Matches the closing tag of a `script` and `style` block.
* Used by parseText fo find the end of the block.
* @const
* @private
*/
const RE_SCRYLE = {
script: /<\/script\s*>/gi,
style: /<\/style\s*>/gi,
textarea: /<\/textarea\s*>/gi
};
// Do not touch text content inside this tags
const RAW_TAGS = /^\/?(?:pre|textarea)$/;
/**
* Add an item into a collection, if the collection is not an array
* we create one and add the item to it
* @param {Array} collection - target collection
* @param {*} item - item to add to the collection
* @returns {Array} array containing the new item added to it
*/
function addToCollection(collection = [], item) {
collection.push(item);
return collection
}
/**
* Run RegExp.exec starting from a specific position
* @param {RegExp} re - regex
* @param {number} pos - last index position
* @param {string} string - regex target
* @returns {Array} regex result
*/
function execFromPos(re, pos, string) {
re.lastIndex = pos;
return re.exec(string)
}
/**
* Escape special characters in a given string, in preparation to create a regex.
*
* @param {string} str - Raw string
* @returns {string} Escaped string.
*/
var escapeStr = (str) => str.replace(/(?=[-[\](){^*+?.$|\\])/g, '\\');
function formatError(data, message, pos) {
if (!pos) {
pos = data.length;
}
// count unix/mac/win eols
const line = (data.slice(0, pos).match(/\r\n?|\n/g) || '').length + 1;
let col = 0;
while (--pos >= 0 && !/[\r\n]/.test(data[pos])) {
++col;
}
return `[${line},${col}]: ${message}`
}
const $_ES6_BQ = '`';
/**
* Searches the next backquote that signals the end of the ES6 Template Literal
* or the "${" sequence that starts a JS expression, skipping any escaped
* character.
*
* @param {string} code - Whole code
* @param {number} pos - The start position of the template
* @param {string[]} stack - To save nested ES6 TL count
* @returns {number} The end of the string (-1 if not found)
*/
function skipES6TL(code, pos, stack) {
// we are in the char following the backquote (`),
// find the next unescaped backquote or the sequence "${"
const re = /[`$\\]/g;
let c;
while (re.lastIndex = pos, re.exec(code)) {
pos = re.lastIndex;
c = code[pos - 1];
if (c === '`') {
return pos
}
if (c === '$' && code[pos++] === '{') {
stack.push($_ES6_BQ, '}');
return pos
}
// else this is an escaped char
}
throw formatError(code, unclosedTemplateLiteral, pos)
}
/**
* Custom error handler can be implemented replacing this method.
* The `state` object includes the buffer (`data`)
* The error position (`loc`) contains line (base 1) and col (base 0).
* @param {string} data - string containing the error
* @param {string} msg - Error message
* @param {number} pos - Position of the error
* @returns {undefined} throw an exception error
*/
function panic(data, msg, pos) {
const message = formatError(data, msg, pos);
throw new Error(message)
}
// forked from https://github.com/aMarCruz/skip-regex
// safe characters to precced a regex (including `=>`, `**`, and `...`)
const beforeReChars = '[{(,;:?=|&!^~>%*/';
const beforeReSign = `${beforeReChars}+-`;
// keyword that can preceed a regex (`in` is handled as special case)
const beforeReWords = [
'case',
'default',
'do',
'else',
'in',
'instanceof',
'prefix',
'return',
'typeof',
'void',
'yield'
];
// Last chars of all the beforeReWords elements to speed up the process.
const wordsEndChar = beforeReWords.reduce((s, w) => s + w.slice(-1), '');
// Matches literal regex from the start of the buffer.
// The buffer to search must not include line-endings.
const RE_LIT_REGEX = /^\/(?=[^*>/])[^[/\\]*(?:(?:\\.|\[(?:\\.|[^\]\\]*)*\])[^[\\/]*)*?\/[gimuy]*/;
// Valid characters for JavaScript variable names and literal numbers.
const RE_JS_VCHAR = /[$\w]/;
// Match dot characters that could be part of tricky regex
const RE_DOT_CHAR = /.*/g;
/**
* Searches the position of the previous non-blank character inside `code`,
* starting with `pos - 1`.
*
* @param {string} code - Buffer to search
* @param {number} pos - Starting position
* @returns {number} Position of the first non-blank character to the left.
* @private
*/
function _prev(code, pos) {
while (--pos >= 0 && /\s/.test(code[pos]));
return pos
}
/**
* Check if the character in the `start` position within `code` can be a regex
* and returns the position following this regex or `start+1` if this is not
* one.
*
* NOTE: Ensure `start` points to a slash (this is not checked).
*
* @function skipRegex
* @param {string} code - Buffer to test in
* @param {number} start - Position the first slash inside `code`
* @returns {number} Position of the char following the regex.
*
*/
/* istanbul ignore next */
function skipRegex(code, start) {
let pos = RE_DOT_CHAR.lastIndex = start++;
// `exec()` will extract from the slash to the end of the line
// and the chained `match()` will match the possible regex.
const match = (RE_DOT_CHAR.exec(code) || ' ')[0].match(RE_LIT_REGEX);
if (match) {
const next = pos + match[0].length; // result comes from `re.match`
pos = _prev(code, pos);
let c = code[pos];
// start of buffer or safe prefix?
if (pos < 0 || beforeReChars.includes(c)) {
return next
}
// from here, `pos` is >= 0 and `c` is code[pos]
if (c === '.') {
// can be `...` or something silly like 5./2
if (code[pos - 1] === '.') {
start = next;
}
} else {
if (c === '+' || c === '-') {
// tricky case
if (code[--pos] !== c || // if have a single operator or
(pos = _prev(code, pos)) < 0 || // ...have `++` and no previous token
beforeReSign.includes(c = code[pos])) {
return next // ...this is a regex
}
}
if (wordsEndChar.includes(c)) { // looks like a keyword?
const end = pos + 1;
// get the complete (previous) keyword
while (--pos >= 0 && RE_JS_VCHAR.test(code[pos]));
// it is in the allowed keywords list?
if (beforeReWords.includes(code.slice(pos + 1, end))) {
start = next;
}
}
}
}
return start
}
/*
* Mini-parser for expressions.
* The main pourpose of this module is to find the end of an expression
* and return its text without the enclosing brackets.
* Does not works with comments, but supports ES6 template strings.
*/
/**
* @exports exprExtr
*/
const S_SQ_STR = /'[^'\n\r\\]*(?:\\(?:\r\n?|[\S\s])[^'\n\r\\]*)*'/.source;
/**
* Matches double quoted JS strings taking care about nested quotes
* and EOLs (escaped EOLs are Ok).
*
* @const
* @private
*/
const S_STRING = `${S_SQ_STR}|${S_SQ_STR.replace(/'/g, '"')}`;
/**
* Regex cache
*
* @type {Object.<string, RegExp>}
* @const
* @private
*/
const reBr = {};
/**
* Makes an optimal regex that matches quoted strings, brackets, backquotes
* and the closing brackets of an expression.
*
* @param {string} b - Closing brackets
* @returns {RegExp} - optimized regex
*/
function _regex(b) {
let re = reBr[b];
if (!re) {
let s = escapeStr(b);
if (b.length > 1) {
s = `${s}|[`;
} else {
s = /[{}[\]()]/.test(b) ? '[' : `[${s}`;
}
reBr[b] = re = new RegExp(`${S_STRING}|${s}\`/\\{}[\\]()]`, 'g');
}
return re
}
/**
* Update the scopes stack removing or adding closures to it
* @param {Array} stack - array stacking the expression closures
* @param {string} char - current char to add or remove from the stack
* @param {string} idx - matching index
* @param {string} code - expression code
* @returns {Object} result
* @returns {Object} result.char - either the char received or the closing braces
* @returns {Object} result.index - either a new index to skip part of the source code,
* or 0 to keep from parsing from the old position
*/
function updateStack(stack, char, idx, code) {
let index = 0;
switch (char) {
case '[':
case '(':
case '{':
stack.push(char === '[' ? ']' : char === '(' ? ')' : '}');
break
case ')':
case ']':
case '}':
if (char !== stack.pop()) {
panic(code, unexpectedCharInExpression.replace('%1', char), index);
}
if (char === '}' && stack[stack.length - 1] === $_ES6_BQ) {
char = stack.pop();
}
index = idx + 1;
break
case '/':
index = skipRegex(code, idx);
}
return { char, index }
}
/**
* Parses the code string searching the end of the expression.
* It skips braces, quoted strings, regexes, and ES6 template literals.
*
* @function exprExtr
* @param {string} code - Buffer to parse
* @param {number} start - Position of the opening brace
* @param {[string,string]} bp - Brackets pair
* @returns {Object} Expression's end (after the closing brace) or -1
* if it is not an expr.
*/
function exprExtr(code, start, bp) {
const [openingBraces, closingBraces] = bp;
const offset = start + openingBraces.length; // skips the opening brace
const stack = []; // expected closing braces ('`' for ES6 TL)
const re = _regex(closingBraces);
re.lastIndex = offset; // begining of the expression
let end;
let match;
while (match = re.exec(code)) { // eslint-disable-line
const idx = match.index;
const str = match[0];
end = re.lastIndex;
// end the iteration
if (str === closingBraces && !stack.length) {
return {
text: code.slice(offset, idx),
start,
end
}
}
const { char, index } = updateStack(stack, str[0], idx, code);
// update the end value depending on the new index received
end = index || end;
// update the regex last index
re.lastIndex = char === $_ES6_BQ ? skipES6TL(code, end, stack) : end;
}
if (stack.length) {
panic(code, unclosedExpression, end);
}
}
/**
* Outputs the last parsed node. Can be used with a builder too.
*
* @param {ParserStore} store - Parsing store
* @returns {undefined} void function
* @private
*/
function flush(store) {
const last = store.last;
store.last = null;
if (last && store.root) {
store.builder.push(last);
}
}
/**
* Get the code chunks from start and end range
* @param {string} source - source code
* @param {number} start - Start position of the chunk we want to extract
* @param {number} end - Ending position of the chunk we need
* @returns {string} chunk of code extracted from the source code received
* @private
*/
function getChunk(source, start, end) {
return source.slice(start, end)
}
/**
* states text in the last text node, or creates a new one if needed.
*
* @param {ParserState} state - Current parser state
* @param {number} start - Start position of the tag
* @param {number} end - Ending position (last char of the tag)
* @param {Object} extra - extra properties to add to the text node
* @param {RawExpr[]} extra.expressions - Found expressions
* @param {string} extra.unescape - Brackets to unescape
* @returns {undefined} - void function
* @private
*/
function pushText(state, start, end, extra = {}) {
const text = getChunk(state.data, start, end);
const expressions = extra.expressions;
const unescape = extra.unescape;
let q = state.last;
state.pos = end;
if (q && q.type === TEXT) {
q.text += text;
q.end = end;
} else {
flush(state);
state.last = q = { type: TEXT, text, start, end };
}
if (expressions && expressions.length) {
q.expressions = (q.expressions || []).concat(expressions);
}
if (unescape) {
q.unescape = unescape;
}
return TEXT
}
/**
* Find the end of the attribute value or text node
* Extract expressions.
* Detect if value have escaped brackets.
*
* @param {ParserState} state - Parser state
* @param {HasExpr} node - Node if attr, info if text
* @param {string} endingChars - Ends the value or text
* @param {number} start - Starting position
* @returns {number} Ending position
* @private
*/
function expr(state, node, endingChars, start) {
const re = b0re(state, endingChars);
re.lastIndex = start; // reset re position
const { unescape, expressions, end } = parseExpressions(state, re);
if (node) {
if (unescape) {
node.unescape = unescape;
}
if (expressions.length) {
node.expressions = expressions;
}
} else {
pushText(state, start, end, {expressions, unescape});
}
return end
}
/**
* Parse a text chunk finding all the expressions in it
* @param {ParserState} state - Parser state
* @param {RegExp} re - regex to match the expressions contents
* @returns {Object} result containing the expression found, the string to unescape and the end position
*/
function parseExpressions(state, re) {
const { data, options } = state;
const { brackets } = options;
const expressions = [];
let unescape, pos, match;
// Anything captured in $1 (closing quote or character) ends the loop...
while ((match = re.exec(data)) && !match[1]) {
// ...else, we have an opening bracket and maybe an expression.
pos = match.index;
if (data[pos - 1] === '\\') {
unescape = match[0]; // it is an escaped opening brace
} else {
const tmpExpr = exprExtr(data, pos, brackets);
if (tmpExpr) {
expressions.push(tmpExpr);
re.lastIndex = tmpExpr.end;
}
}
}
// Even for text, the parser needs match a closing char
if (!match) {
panic(data, unexpectedEndOfFile, pos);
}
return {
unescape,
expressions,
end: match.index
}
}
/**
* Creates a regex for the given string and the left bracket.
* The string is captured in $1.
*
* @param {ParserState} state - Parser state
* @param {string} str - String to search
* @returns {RegExp} Resulting regex.
* @private
*/
function b0re(state, str) {
const { brackets } = state.options;
const re = state.regexCache[str];
if (re) return re
const b0 = escapeStr(brackets[0]);
// cache the regex extending the regexCache object
Object.assign(state.regexCache, { [str]: new RegExp(`(${str})|${b0}`, 'g') });
return state.regexCache[str]
}
// similar to _.uniq
const uniq = l => l.filter((x, i, a) => a.indexOf(x) === i);
/**
* SVG void elements that cannot be auto-closed and shouldn't contain child nodes.
* @const {Array}
*/
const VOID_SVG_TAGS_LIST = [
'circle',
'ellipse',
'line',
'path',
'polygon',
'polyline',
'rect',
'stop',
'use'
];
/**
* List of html elements where the value attribute is allowed
* @type {Array}
*/
const HTML_ELEMENTS_HAVING_VALUE_ATTRIBUTE_LIST = [
'button',
'data',
'input',
'select',
'li',
'meter',
'option',
'output',
'progress',
'textarea',
'param'
];
/**
* List of all the available svg tags
* @const {Array}
* @see {@link https://github.com/wooorm/svg-tag-names}
*/
const SVG_TAGS_LIST = uniq([
'a',
'altGlyph',
'altGlyphDef',
'altGlyphItem',
'animate',
'animateColor',
'animateMotion',
'animateTransform',
'animation',
'audio',
'canvas',
'clipPath',
'color-profile',
'cursor',
'defs',
'desc',
'discard',
'feBlend',
'feColorMatrix',
'feComponentTransfer',
'feComposite',
'feConvolveMatrix',
'feDiffuseLighting',
'feDisplacementMap',
'feDistantLight',
'feDropShadow',
'feFlood',
'feFuncA',
'feFuncB',
'feFuncG',
'feFuncR',
'feGaussianBlur',
'feImage',
'feMerge',
'feMergeNode',
'feMorphology',
'feOffset',
'fePointLight',
'feSpecularLighting',
'feSpotLight',
'feTile',
'feTurbulence',
'filter',
'font',
'font-face',
'font-face-format',
'font-face-name',
'font-face-src',
'font-face-uri',
'foreignObject',
'g',
'glyph',
'glyphRef',
'handler',
'hatch',
'hatchpath',
'hkern',
'iframe',
'image',
'linearGradient',
'listener',
'marker',
'mask',
'mesh',
'meshgradient',
'meshpatch',
'meshrow',
'metadata',
'missing-glyph',
'mpath',
'pattern',
'prefetch',
'radialGradient',
'script',
'set',
'solidColor',
'solidcolor',
'style',
'svg',
'switch',
'symbol',
'tbreak',
'text',
'textArea',
'textPath',
'title',
'tref',
'tspan',
'unknown',
'video',
'view',
'vkern'
].concat(VOID_SVG_TAGS_LIST)).sort();
/**
* HTML void elements that cannot be auto-closed and shouldn't contain child nodes.
* @type {Array}
* @see {@link http://www.w3.org/TR/html-markup/syntax.html#syntax-elements}
* @see {@link http://www.w3.org/TR/html5/syntax.html#void-elements}
*/
const VOID_HTML_TAGS_LIST = [
'area',
'base',
'br',
'col',
'embed',
'hr',
'img',
'input',
'keygen',
'link',
'menuitem',
'meta',
'param',
'source',
'track',
'wbr'
];
/**
* List of all the html tags
* @const {Array}
* @see {@link https://github.com/sindresorhus/html-tags}
*/
const HTML_TAGS_LIST = uniq([
'a',
'abbr',
'address',
'article',
'aside',
'audio',
'b',
'bdi',
'bdo',
'blockquote',
'body',
'canvas',
'caption',
'cite',
'code',
'colgroup',
'datalist',
'dd',
'del',
'details',
'dfn',
'dialog',
'div',
'dl',
'dt',
'em',
'fieldset',
'figcaption',
'figure',
'footer',
'form',
'h1',
'h2',
'h3',
'h4',
'h5',
'h6',
'head',
'header',
'hgroup',
'html',
'i',
'iframe',
'ins',
'kbd',
'label',
'legend',
'main',
'map',
'mark',
'math',
'menu',
'nav',
'noscript',
'object',
'ol',
'optgroup',
'p',
'picture',
'pre',
'q',
'rb',
'rp',
'rt',
'rtc',
'ruby',
's',
'samp',
'script',
'section',
'select',
'slot',
'small',
'span',
'strong',
'style',
'sub',
'summary',
'sup',
'svg',
'table',
'tbody',
'td',
'template',
'tfoot',
'th',
'thead',
'time',
'title',
'tr',
'u',
'ul',
'var',
'video'
]
.concat(VOID_HTML_TAGS_LIST)
.concat(HTML_ELEMENTS_HAVING_VALUE_ATTRIBUTE_LIST)
).sort();
/**
* List of all boolean HTML attributes
* @const {RegExp}
* @see {@link https://www.w3.org/TR/html5/infrastructure.html#sec-boolean-attributes}
*/
const BOOLEAN_ATTRIBUTES_LIST = [
'disabled',
'visible',
'checked',
'readonly',
'required',
'allowfullscreen',
'autofocus',
'autoplay',
'compact',
'controls',
'default',
'formnovalidate',
'hidden',
'ismap',
'itemscope',
'loop',
'multiple',
'muted',
'noresize',
'noshade',
'novalidate',
'nowrap',
'open',
'reversed',
'seamless',
'selected',
'sortable',
'truespeed',
'typemustmatch'
];
/**
* Join a list of items with the pipe symbol (usefull for regex list concatenation)
* @private
* @param {Array} list - list of strings
* @returns {string} the list received joined with pipes
*/
function joinWithPipe(list) {
return list.join('|')
}
/**
* Convert list of strings to regex in order to test against it ignoring the cases
* @private
* @param {...Array} lists - array of strings
* @returns {RegExp} regex that will match all the strings in the array received ignoring the cases
*/
function listsToRegex(...lists) {
return new RegExp(`^/?(?:${joinWithPipe(lists.map(joinWithPipe))})$`, 'i')
}
/**
* Regex matching all the html tags ignoring the cases
* @const {RegExp}
*/
const HTML_TAGS_RE = listsToRegex(HTML_TAGS_LIST);
/**
* Regex matching all the svg tags ignoring the cases
* @const {RegExp}
*/
const SVG_TAGS_RE = listsToRegex(SVG_TAGS_LIST);
/**
* Regex matching all the void html tags ignoring the cases
* @const {RegExp}
*/
const VOID_HTML_TAGS_RE = listsToRegex(VOID_HTML_TAGS_LIST);
/**
* Regex matching all the void svg tags ignoring the cases
* @const {RegExp}
*/
const VOID_SVG_TAGS_RE = listsToRegex(VOID_SVG_TAGS_LIST);
/**
* Regex matching all the html tags where the value tag is allowed
* @const {RegExp}
*/
const HTML_ELEMENTS_HAVING_VALUE_ATTRIBUTE_RE = listsToRegex(HTML_ELEMENTS_HAVING_VALUE_ATTRIBUTE_LIST);
/**
* Regex matching all the boolean attributes
* @const {RegExp}
*/
const BOOLEAN_ATTRIBUTES_RE = listsToRegex(BOOLEAN_ATTRIBUTES_LIST);
/**
* True if it's a self closing tag
* @param {string} tag - test tag
* @returns {boolean} true if void
* @example
* isVoid('meta') // true
* isVoid('circle') // true
* isVoid('IMG') // true
* isVoid('div') // false
* isVoid('mask') // false
*/
function isVoid(tag) {
return [
VOID_HTML_TAGS_RE,
VOID_SVG_TAGS_RE
].some(r => r.test(tag))
}
/**
* True if it's not SVG nor a HTML known tag
* @param {string} tag - test tag
* @returns {boolean} true if custom element
* @example
* isCustom('my-component') // true
* isCustom('div') // false
*/
function isCustom(tag) {
return [
HTML_TAGS_RE,
SVG_TAGS_RE
].every(l => !l.test(tag))
}
/**
* True if it's a boolean attribute
* @param {string} attribute - test attribute
* @returns {boolean} true if the attribute is a boolean type
* @example
* isBoolAttribute('selected') // true
* isBoolAttribute('class') // false
*/
function isBoolAttribute(attribute) {
return BOOLEAN_ATTRIBUTES_RE.test(attribute)
}
/**
* Memoization function
* @param {Function} fn - function to memoize
* @returns {*} return of the function to memoize
*/
function memoize(fn) {
const cache = new WeakMap();
return (...args) => {
if (cache.has(args[0])) return cache.get(args[0])
const ret = fn(...args);
cache.set(args[0], ret);
return ret
}
}
const expressionsContentRe = memoize(brackets => RegExp(`(${brackets[0]}[^${brackets[1]}]*?${brackets[1]})`, 'g'));
const isSpreadAttribute = name => SPREAD_OPERATOR.test(name);
const isAttributeExpression = (name, brackets) => name[0] === brackets[0];
const getAttributeEnd = (state, attr) => expr(state, attr, '[>/\\s]', attr.start);
/**
* The more complex parsing is for attributes as it can contain quoted or
* unquoted values or expressions.
*
* @param {ParserStore} state - Parser state
* @returns {number} New parser mode.
* @private
*/
function attr(state) {
const { data, last, pos, root } = state;
const tag = last; // the last (current) tag in the output
const _CH = /\S/g; // matches the first non-space char
const ch = execFromPos(_CH, pos, data);
switch (true) {
case !ch:
state.pos = data.length; // reaching the end of the buffer with
// NodeTypes.ATTR will generate error
break
case ch[0] === '>':
// closing char found. If this is a self-closing tag with the name of the
// Root tag, we need decrement the counter as we are changing mode.
state.pos = tag.end = _CH.lastIndex;
if (tag[IS_SELF_CLOSING]) {
state.scryle = null; // allow selfClosing script/style tags
if (root && root.name === tag.name) {
state.count--; // "pop" root tag
}
}
return TEXT
case ch[0] === '/':
state.pos = _CH.lastIndex; // maybe. delegate the validation
tag[IS_SELF_CLOSING] = true; // the next loop
break
default:
delete tag[IS_SELF_CLOSING]; // ensure unmark as selfclosing tag
setAttribute(state, ch.index, tag);
}
return ATTR
}
/**
* Parses an attribute and its expressions.
*
* @param {ParserStore} state - Parser state
* @param {number} pos - Starting position of the attribute
* @param {Object} tag - Current parent tag
* @returns {undefined} void function
* @private
*/
function setAttribute(state, pos, tag) {
const { data } = state;
const expressionContent = expressionsContentRe(state.options.brackets);
const re = ATTR_START; // (\S[^>/=\s]*)(?:\s*=\s*([^>/])?)? g
const start = re.lastIndex = expressionContent.lastIndex = pos; // first non-whitespace
const attrMatches = re.exec(data);
const isExpressionName = isAttributeExpression(attrMatches[1], state.options.brackets);
const match = isExpressionName ? [null, expressionContent.exec(data)[1], null] : attrMatches;
if (match) {
const end = re.lastIndex;
const attr = parseAttribute(state, match, start, end, isExpressionName);
//assert(q && q.type === Mode.TAG, 'no previous tag for the attr!')
// Pushes the attribute and shifts the `end` position of the tag (`last`).
state.pos = tag.end = attr.end;
tag.attributes = addToCollection(tag.attributes, attr);
}
}
function parseNomalAttribute(state, attr, quote) {
const { data } = state;
let { end } = attr;
if (isBoolAttribute(attr.name)) {
attr[IS_BOOLEAN] = true;
}
// parse the whole value (if any) and get any expressions on it
if (quote) {
// Usually, the value's first char (`quote`) is a quote and the lastIndex
// (`end`) is the start of the value.
let valueStart = end;
// If it not, this is an unquoted value and we need adjust the start.
if (quote !== '"' && quote !== '\'') {
quote = ''; // first char of value is not a quote
valueStart--; // adjust the starting position
}
end = expr(state, attr, quote || '[>/\\s]', valueStart);
// adjust the bounds of the value and save its content
return Object.assign(attr, {
value: getChunk(data, valueStart, end),
valueStart,
end: quote ? ++end : end
})
}
return attr
}
/**
* Parse expression names <a {href}>
* @param {ParserStore} state - Parser state
* @param {Object} attr - attribute object parsed
* @returns {Object} normalized attribute object
*/
function parseSpreadAttribute(state, attr) {
const end = getAttributeEnd(state, attr);
return {
[IS_SPREAD]: true,
start: attr.start,
expressions: attr.expressions.map(expr => Object.assign(expr, {
text: expr.text.replace(SPREAD_OPERATOR, '').trim()
})),
end: end
}
}
/**
* Parse expression names <a {href}>
* @param {ParserStore} state - Parser state
* @param {Object} attr - attribute object parsed
* @returns {Object} normalized attribute object
*/
function parseExpressionNameAttribute(state, attr) {
const end = getAttributeEnd(state, attr);
return {
start: attr.start,
name: attr.expressions[0].text.trim(),
expressions: attr.expressions,
end: end
}
}
/**
* Parse the attribute values normalising the quotes
* @param {ParserStore} state - Parser state
* @param {Array} match - results of the attributes regex
* @param {number} start - attribute start position
* @param {number} end - attribute end position
* @param {boolean} isExpressionName - true if the attribute name is an expression
* @returns {Object} attribute object
*/
function parseAttribute(state, match, start, end, isExpressionName) {
const attr = {
name: match[1],
value: '',
start,
end
};
const quote = match[2]; // first letter of value or nothing
switch (true) {
case isSpreadAttribute(attr.name):
return parseSpreadAttribute(state, attr)
case isExpressionName === true:
return parseExpressionNameAttribute(state, attr)
default:
return parseNomalAttribute(state, attr, quote)
}
}
/**
* Function to curry any javascript method
* @param {Function} fn - the target function we want to curry
* @param {...[args]} acc - initial arguments
* @returns {Function|*} it will return a function until the target function
* will receive all of its arguments
*/
function curry(fn, ...acc) {
return (...args) => {
args = [...acc, ...args];
return args.length < fn.length ?
curry(fn, ...args) :
fn(...args)
}
}
/**
* Parses comments in long or short form
* (any DOCTYPE & CDATA blocks are parsed as comments).
*
* @param {ParserState} state - Parser state
* @param {string} data - Buffer to parse
* @param {number} start - Position of the '<!' sequence
* @returns {number} node type id
* @private
*/
function comment(state, data, start) {
const pos = start + 2; // skip '<!'
const isLongComment = data.substr(pos, 2) === '--';
const str = isLongComment ? '-->' : '>';
const end = data.indexOf(str, pos);
if (end < 0) {
panic(data, unclosedComment, start);
}
pushComment(
state,
start,
end + str.length,
data.substring(start, end + str.length)
);
return TEXT
}
/**
* Parse a comment.
*
* @param {ParserState} state - Current parser state
* @param {number} start - Start position of the tag
* @param {number} end - Ending position (last char of the tag)
* @param {string} text - Comment content
* @returns {undefined} void function
* @private
*/
function pushComment(state, start, end, text) {
state.pos = end;
if (state.options.comments === true) {
flush(state);
state.last = {
type: COMMENT,
start,
end,
text
};
}
}
/**
* Pushes a new *tag* and set `last` to this, so any attributes
* will be included on this and shifts the `end`.
*
* @param {ParserState} state - Current parser state
* @param {string} name - Name of the node including any slash
* @param {number} start - Start position of the tag
* @param {number} end - Ending position (last char of the tag + 1)
* @returns {undefined} - void function
* @private
*/
function pushTag(state, name, start, end) {
const root = state.root;
const last = { type: TAG, name, start, end };
if (isCustom(name)) {
last[IS_CUSTOM] = true;
}
if (isVoid(name)) {
last[IS_VOID] = true;
}
state.pos = end;
if (root) {
if (name === root.name) {
state.count++;
} else if (name === root.close) {
state.count--;
}
flush(state);
} else {
// start with root (keep ref to output)
state.root = { name: last.name, close: `/${name}` };
state.count = 1;
}
state.last = last;
}
/**
* Parse the tag following a '<' character, or delegate to other parser
* if an invalid tag name is found.
*
* @param {ParserState} state - Parser state
* @returns {number} New parser mode
* @private
*/
function tag(state) {
const { pos, data } = state; // pos of the char following '<'
const start = pos - 1; // pos of '<'
const str = data.substr(pos, 2); // first two chars following '<'
switch (true) {
case str[0] === '!':
return comment(state, data, start)
case TAG_2C.test(str):
return parseTag(state, start)
default:
return pushText(state, start, pos) // pushes the '<' as text
}
}
function parseTag(state, start) {
const { data, pos } = state;
const re = TAG_NAME; // (\/?[^\s>/]+)\s*(>)? g
const match = execFromPos(re, pos, data);
const end = re.lastIndex;
const name = match[1].toLowerCase(); // $1: tag name including any '/'
// script/style block is parsed as another tag to extract attributes
if (name in RE_SCRYLE) {
state.scryle = name; // used by parseText
}
pushTag(state, name, start, end);
// only '>' can ends the tag here, the '/' is handled in parseAttribute
if (!match[2]) {
return ATTR
}
return TEXT
}
/**
* Parses regular text and script/style blocks ...scryle for short :-)
* (the content of script and style is text as well)
*
* @param {ParserState} state - Parser state
* @returns {number} New parser mode.
* @private
*/
function text(state) {
const { pos, data, scryle } = state;
switch (true) {
case typeof scryle === 'string': {
const name = scryle;
const re = RE_SCRYLE[name];
const match = execFromPos(re, pos, data);
if (!match) {
panic(data, unclosedNamedBlock.replace('%1', name), pos - 1);
}
const start = match.index;
const end = re.lastIndex;
state.scryle = null; // reset the script/style flag now
// write the tag content, if any
if (start > pos) {
parseSpecialTagsContent(state, name, match);
}
// now the closing tag, either </script> or </style>
pushTag(state, `/${name}`, start, end);
break
}
case data[pos] === '<':
state.pos++;
return TAG
default:
expr(state, null, '<', pos);
}
return TEXT
}
/**
* Parse the text content depending on the name
* @param {ParserState} state - Parser state
* @param {string} name - one of the tags matched by the RE_SCRYLE regex
* @param {Array} match - result of the regex matching the content of the parsed tag
* @returns {undefined} void function
*/
function parseSpecialTagsContent(state, name, match) {
const { pos } = state;
const start = match.index;
if (name === TEXTAREA_TAG) {
expr(state, null, match[0], pos);
} else {
pushText(state, pos, start);
}
}
/*---------------------------------------------------------------------
* Tree builder for the riot tag parser.
*
* The output has a root property and separate arrays for `html`, `css`,
* and `js` tags.
*
* The root tag is included as first element in the `html` array.
* Script tags marked with "defer" are included in `html` instead `js`.
*
* - Mark SVG tags
* - Mark raw tags
* - Mark void tags
* - Split prefixes from expressions
* - Unescape escaped brackets and escape EOLs and backslashes
* - Compact whitespace (option `compact`) for non-raw tags
* - Create an array `parts` for text nodes and attributes
*
* Throws on unclosed tags or closing tags without start tag.
* Selfclosing and void tags has no nodes[] property.
*/
/**
* Escape the carriage return and the line feed from a string
* @param {string} string - input string
* @returns {string} output string escaped
*/
function escapeReturn(string) {
return string
.replace(/\r/g, '\\r')
.replace(/\n/g, '\\n')
}
/**
* Escape double slashes in a string
* @param {string} string - input string
* @returns {string} output string escaped
*/
function escapeSlashes(string) {
return string.replace(/\\/g, '\\\\')
}
/**
* Replace the multiple spaces with only one
* @param {string} string - input string
* @returns {string} string without trailing spaces
*/
function cleanSpaces(string) {
return string.replace(/\s+/g, ' ')
}
const TREE_BUILDER_STRUCT = Object.seal({
get() {
const store = this.store;
// The real root tag is in store.root.nodes[0]
return {
[TEMPLATE_OUTPUT_NAME]: store.root.nodes[0],
[CSS_OUTPUT_NAME]: store[STYLE_TAG],
[JAVASCRIPT_OUTPUT_NAME]: store[JAVASCRIPT_TAG]
}
},
/**
* Process the current tag or text.
* @param {Object} node - Raw pseudo-node from the parser
* @returns {undefined} void function
*/
push(node) {
const store = this.store;
switch (node.type) {
case COMMENT:
this.pushComment(store, node);
break
case TEXT:
this.pushText(store, node);
break
case TAG: {
const name = node.name;
const closingTagChar = '/';
const [firstChar] = name;
if (firstChar === closingTagChar && !node.isVoid) {
this.closeTag(store, node, name);
} else if (firstChar !== closingTagChar) {
this.openTag(store, node);
}
break
}
}
},
pushComment(store, node) {
const parent = store.last;
parent.nodes.push(node);
},
closeTag(store, node) {
const last = store.scryle || store.last;
last.end = node.end;
// update always the root node end position
if (store.root.nodes[0]) store.root.nodes[0].end = node.end;
if (store.scryle) {
store.scryle = null;
} else {
store.last = store.stack.pop();
}
},
openTag(store, node) {
const name = node.name;
const attrs = node.attributes;
if ([JAVASCRIPT_TAG, STYLE_TAG].includes(name)) {
// Only accept one of each
if (store[name]) {
panic(this.store.data, duplicatedNamedTag.replace('%1', name), node.start);
}
store[name] = node;
store.scryle = store[name];
} else {
// store.last holds the last tag pushed in the stack and this are
// non-void, non-empty tags, so we are sure the `lastTag` here
// have a `nodes` property.
const lastTag = store.last;
const newNode = node;
lastTag.nodes.push(newNode);
if (lastTag[IS_RAW] || RAW_TAGS.test(name)) {
node[IS_RAW] = true;
}
if (!node[IS_SELF_CLOSING] && !node[IS_VOID]) {
store.stack.push(lastTag);
newNode.nodes = [];
store.last = newNode;
}
}
if (attrs) {
this.attrs(attrs);
}
},
attrs(attributes) {
attributes.forEach(attr => {
if (attr.value) {
this.split(attr, attr.value, attr.valueStart, true);
}
});
},
pushText(store, node) {
const text = node.text;
const empty = !/\S/.test(text);
const scryle = store.scryle;
if (!scryle) {
// store.last always have a nodes property
const parent = store.last;
const pack = this.compact && !parent[IS_RAW];
if (pack && empty) {
return
}
this.split(node, text, node.start, pack);
parent.nodes.push(node);
} else if (!empty) {
scryle.text = node;
}
},
split(node, source, start, pack) {
const expressions = node.expressions;
const parts = [];
if (expressions) {
let pos = 0;
expressions.forEach(expr => {
const text = source.slice(pos, expr.start - start);
const code = expr.text;
parts.push(this.sanitise(node, text, pack), escapeReturn(escapeSlashes(code).trim()));
pos = expr.end - start;
});
if (pos < node.end) {
parts.push(this.sanitise(node, source.slice(pos), pack));
}
} else {
parts[0] = this.sanitise(node, source, pack);
}
node.parts = parts.filter(p => p); // remove the empty strings
},
// unescape escaped brackets and split prefixes of expressions
sanitise(node, text, pack) {
let rep = node.unescape;
if (rep) {
let idx = 0;
rep = `\\${rep}`;
while ((idx = text.indexOf(rep, idx)) !== -1) {
text = text.substr(0, idx) + text.substr(idx + 1);
idx++;
}
}
text = escapeSlashes(text);
return pack ? cleanSpaces(text) : escapeReturn(text)
}
});
function createTreeBuilder(data, options) {
const root = {
type: TAG,
name: '',
start: 0,
end: 0,
nodes: []
};
return Object.assign(Object.create(TREE_BUILDER_STRUCT), {
compact: options.compact !== false,
store: {
last: root,
stack: [],
scryle: null,
root,
style: null,
script: null,
data
}
})
}
/**
* Factory for the Parser class, exposing only the `parse` method.
* The export adds the Parser class as property.
*
* @param {Object} options - User Options
* @param {Function} customBuilder - Tree builder factory
* @returns {Function} Public Parser implementation.
*/
function parser(options, customBuilder) {
const state = curry(createParserState)(options, customBuilder || createTreeBuilder);
return {
parse: (data) => parse(state(data))
}
}
/**
* Create a new state object
* @param {Object} userOptions - parser options
* @param {Function} builder - Tree builder factory
* @param {string} data - data to parse
* @returns {ParserState} it represents the current parser state
*/
function createParserState(userOptions, builder, data) {
const options = Object.assign({
brackets: ['{', '}']
}, userOptions);
return {
options,
regexCache: {},
pos: 0,
count: -1,
root: null,
last: null,
scryle: null,
builder: builder(data, options),
data
}
}
/**
* It creates a raw output of pseudo-nodes with one of three different types,
* all of them having a start/end position:
*
* - TAG -- Opening or closing tags
* - TEXT -- Raw text
* - COMMENT -- Comments
*
* @param {ParserState} state - Current parser state
* @returns {ParserResult} Result, contains data and output properties.
*/
function parse(state) {
const { data } = state;
walk(state);
flush(state);
if (state.count) {
panic(data, state.count > 0 ? unexpectedEndOfFile : rootTagNotFound, state.pos);
}
return {
data,
output: state.builder.get()
}
}
/**
* Parser walking recursive function
* @param {ParserState} state - Current parser state
* @param {string} type - current parsing context
* @returns {undefined} void function
*/
function walk(state, type) {
const { data } = state;
// extend the state adding the tree builder instance and the initial data
const length = data.length;
// The "count" property is set to 1 when the first tag is found.
// This becomes the root and precedent text or comments are discarded.
// So, at the end of the parsing count must be zero.
if (state.pos < length && state.count) {
walk(state, eat(state, type));
}
}
/**
* Function to help iterating on the current parser state
* @param {ParserState} state - Current parser state
* @param {string} type - current parsing context
* @returns {string} parsing context
*/
function eat(state, type) {
switch (type) {
case TAG:
return tag(state)
case ATTR:
return attr(state)
default:
return text(state)
}
}
/**
* Expose the internal constants
*/
const constants = c;
/**
* The nodeTypes definition
*/
const nodeTypes = types;
exports.constants = constants;
exports.default = parser;
exports.nodeTypes = nodeTypes;