|
|
|
@ -1,177 +1,204 @@
|
|
|
|
|
import { locate } from 'locate-character';
|
|
|
|
|
import fragment from './state/fragment.ts';
|
|
|
|
|
import { whitespace } from '../utils/patterns.ts';
|
|
|
|
|
import { trimStart, trimEnd } from '../utils/trim.ts';
|
|
|
|
|
import getCodeFrame from '../utils/getCodeFrame.ts';
|
|
|
|
|
import hash from './utils/hash.ts';
|
|
|
|
|
|
|
|
|
|
function ParseError ( message, template, index, filename ) {
|
|
|
|
|
const { line, column } = locate( template, index );
|
|
|
|
|
|
|
|
|
|
this.name = 'ParseError';
|
|
|
|
|
this.message = message;
|
|
|
|
|
this.frame = getCodeFrame( template, line, column );
|
|
|
|
|
|
|
|
|
|
this.loc = { line: line + 1, column };
|
|
|
|
|
this.pos = index;
|
|
|
|
|
this.filename = filename;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
ParseError.prototype.toString = function () {
|
|
|
|
|
return `${this.message} (${this.loc.line}:${this.loc.column})\n${this.frame}`;
|
|
|
|
|
};
|
|
|
|
|
import { locate, Location } from 'locate-character';
|
|
|
|
|
import fragment from './state/fragment';
|
|
|
|
|
import { whitespace } from '../utils/patterns';
|
|
|
|
|
import { trimStart, trimEnd } from '../utils/trim';
|
|
|
|
|
import getCodeFrame from '../utils/getCodeFrame';
|
|
|
|
|
import hash from './utils/hash';
|
|
|
|
|
import { Node } from './interfaces';
|
|
|
|
|
|
|
|
|
|
class ParseError extends Error {
|
|
|
|
|
frame: string
|
|
|
|
|
loc: { line: number, column: number }
|
|
|
|
|
pos: number
|
|
|
|
|
filename: string
|
|
|
|
|
|
|
|
|
|
constructor ( message: string, template: string, index: number, filename: string ) {
|
|
|
|
|
super( message );
|
|
|
|
|
|
|
|
|
|
const { line, column } = locate( template, index );
|
|
|
|
|
|
|
|
|
|
this.name = 'ParseError';
|
|
|
|
|
this.loc = { line: line + 1, column };
|
|
|
|
|
this.pos = index;
|
|
|
|
|
this.filename = filename;
|
|
|
|
|
|
|
|
|
|
this.frame = getCodeFrame( template, line, column );
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export default function parse ( template, options = {} ) {
|
|
|
|
|
if ( typeof template !== 'string' ) {
|
|
|
|
|
throw new TypeError( 'Template must be a string' );
|
|
|
|
|
toString () {
|
|
|
|
|
return `${this.message} (${this.loc.line}:${this.loc.column})\n${this.frame}`;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
template = template.replace( /\s+$/, '' );
|
|
|
|
|
interface ParserOptions {
|
|
|
|
|
filename?: string
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const parser = {
|
|
|
|
|
index: 0,
|
|
|
|
|
template,
|
|
|
|
|
stack: [],
|
|
|
|
|
metaTags: {},
|
|
|
|
|
export class Parser {
|
|
|
|
|
readonly template: string;
|
|
|
|
|
readonly filename?: string;
|
|
|
|
|
|
|
|
|
|
current () {
|
|
|
|
|
return this.stack[ this.stack.length - 1 ];
|
|
|
|
|
},
|
|
|
|
|
index: number;
|
|
|
|
|
stack: Array<Node>;
|
|
|
|
|
|
|
|
|
|
acornError ( err ) {
|
|
|
|
|
parser.error( err.message.replace( / \(\d+:\d+\)$/, '' ), err.pos );
|
|
|
|
|
},
|
|
|
|
|
html: Node;
|
|
|
|
|
css: Node;
|
|
|
|
|
js: Node;
|
|
|
|
|
metaTags: {}
|
|
|
|
|
|
|
|
|
|
error ( message, index = this.index ) {
|
|
|
|
|
throw new ParseError( message, this.template, index, options.filename );
|
|
|
|
|
},
|
|
|
|
|
constructor ( template: string, options: ParserOptions ) {
|
|
|
|
|
if ( typeof template !== 'string' ) {
|
|
|
|
|
throw new TypeError( 'Template must be a string' );
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
eat ( str, required ) {
|
|
|
|
|
if ( this.match( str ) ) {
|
|
|
|
|
this.index += str.length;
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
this.template = template.replace( /\s+$/, '' );
|
|
|
|
|
this.filename = options.filename;
|
|
|
|
|
|
|
|
|
|
if ( required ) {
|
|
|
|
|
this.error( `Expected ${str}` );
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
this.index = 0;
|
|
|
|
|
this.stack = [];
|
|
|
|
|
this.metaTags = {};
|
|
|
|
|
|
|
|
|
|
this.html = {
|
|
|
|
|
start: null,
|
|
|
|
|
end: null,
|
|
|
|
|
type: 'Fragment',
|
|
|
|
|
children: []
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
match ( str ) {
|
|
|
|
|
return this.template.slice( this.index, this.index + str.length ) === str;
|
|
|
|
|
},
|
|
|
|
|
this.css = null;
|
|
|
|
|
this.js = null;
|
|
|
|
|
|
|
|
|
|
allowWhitespace () {
|
|
|
|
|
while ( this.index < this.template.length && whitespace.test( this.template[ this.index ] ) ) {
|
|
|
|
|
this.index++;
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
this.stack.push( this.html );
|
|
|
|
|
|
|
|
|
|
read ( pattern ) {
|
|
|
|
|
const match = pattern.exec( this.template.slice( this.index ) );
|
|
|
|
|
if ( !match || match.index !== 0 ) return null;
|
|
|
|
|
let state = fragment;
|
|
|
|
|
|
|
|
|
|
parser.index += match[0].length;
|
|
|
|
|
while ( this.index < this.template.length ) {
|
|
|
|
|
state = state( this ) || fragment;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return match[0];
|
|
|
|
|
},
|
|
|
|
|
if ( this.stack.length > 1 ) {
|
|
|
|
|
const current = this.current();
|
|
|
|
|
|
|
|
|
|
readUntil ( pattern ) {
|
|
|
|
|
if ( this.index >= this.template.length ) parser.error( 'Unexpected end of input' );
|
|
|
|
|
const type = current.type === 'Element' ? `<${current.name}>` : 'Block';
|
|
|
|
|
this.error( `${type} was left open`, current.start );
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const start = this.index;
|
|
|
|
|
const match = pattern.exec( this.template.slice( start ) );
|
|
|
|
|
if ( state !== fragment ) {
|
|
|
|
|
this.error( 'Unexpected end of input' );
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if ( match ) {
|
|
|
|
|
const start = this.index;
|
|
|
|
|
this.index = start + match.index;
|
|
|
|
|
return this.template.slice( start, this.index );
|
|
|
|
|
}
|
|
|
|
|
// trim unnecessary whitespace
|
|
|
|
|
while ( this.html.children.length ) {
|
|
|
|
|
const firstChild = this.html.children[0];
|
|
|
|
|
this.html.start = firstChild.start;
|
|
|
|
|
|
|
|
|
|
this.index = this.template.length;
|
|
|
|
|
return this.template.slice( start );
|
|
|
|
|
},
|
|
|
|
|
if ( firstChild.type !== 'Text' ) break;
|
|
|
|
|
|
|
|
|
|
remaining () {
|
|
|
|
|
return this.template.slice( this.index );
|
|
|
|
|
},
|
|
|
|
|
const length = firstChild.data.length;
|
|
|
|
|
firstChild.data = trimStart( firstChild.data );
|
|
|
|
|
|
|
|
|
|
requireWhitespace () {
|
|
|
|
|
if ( !whitespace.test( this.template[ this.index ] ) ) {
|
|
|
|
|
this.error( `Expected whitespace` );
|
|
|
|
|
if ( firstChild.data === '' ) {
|
|
|
|
|
this.html.children.shift();
|
|
|
|
|
} else {
|
|
|
|
|
this.html.start += length - firstChild.data.length;
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
this.allowWhitespace();
|
|
|
|
|
},
|
|
|
|
|
while ( this.html.children.length ) {
|
|
|
|
|
const lastChild = this.html.children[ this.html.children.length - 1 ];
|
|
|
|
|
this.html.end = lastChild.end;
|
|
|
|
|
|
|
|
|
|
html: {
|
|
|
|
|
start: null,
|
|
|
|
|
end: null,
|
|
|
|
|
type: 'Fragment',
|
|
|
|
|
children: []
|
|
|
|
|
},
|
|
|
|
|
if ( lastChild.type !== 'Text' ) break;
|
|
|
|
|
|
|
|
|
|
css: null,
|
|
|
|
|
const length = lastChild.data.length;
|
|
|
|
|
lastChild.data = trimEnd( lastChild.data );
|
|
|
|
|
|
|
|
|
|
js: null
|
|
|
|
|
};
|
|
|
|
|
if ( lastChild.data === '' ) {
|
|
|
|
|
this.html.children.pop();
|
|
|
|
|
} else {
|
|
|
|
|
this.html.end -= length - lastChild.data.length;
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
parser.stack.push( parser.html );
|
|
|
|
|
current () {
|
|
|
|
|
return this.stack[ this.stack.length - 1 ];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let state = fragment;
|
|
|
|
|
acornError ( err: Error ) {
|
|
|
|
|
this.error( err.message.replace( / \(\d+:\d+\)$/, '' ), err.pos );
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
while ( parser.index < parser.template.length ) {
|
|
|
|
|
state = state( parser ) || fragment;
|
|
|
|
|
error ( message: string, index = this.index ) {
|
|
|
|
|
throw new ParseError( message, this.template, index, this.filename );
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if ( parser.stack.length > 1 ) {
|
|
|
|
|
const current = parser.current();
|
|
|
|
|
eat ( str: string, required?: boolean ) {
|
|
|
|
|
if ( this.match( str ) ) {
|
|
|
|
|
this.index += str.length;
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const type = current.type === 'Element' ? `<${current.name}>` : 'Block';
|
|
|
|
|
parser.error( `${type} was left open`, current.start );
|
|
|
|
|
if ( required ) {
|
|
|
|
|
this.error( `Expected ${str}` );
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if ( state !== fragment ) {
|
|
|
|
|
parser.error( 'Unexpected end of input' );
|
|
|
|
|
match ( str: string ) {
|
|
|
|
|
return this.template.slice( this.index, this.index + str.length ) === str;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// trim unnecessary whitespace
|
|
|
|
|
while ( parser.html.children.length ) {
|
|
|
|
|
const firstChild = parser.html.children[0];
|
|
|
|
|
parser.html.start = firstChild.start;
|
|
|
|
|
allowWhitespace () {
|
|
|
|
|
while ( this.index < this.template.length && whitespace.test( this.template[ this.index ] ) ) {
|
|
|
|
|
this.index++;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if ( firstChild.type !== 'Text' ) break;
|
|
|
|
|
read ( pattern: RegExp ) {
|
|
|
|
|
const match = pattern.exec( this.template.slice( this.index ) );
|
|
|
|
|
if ( !match || match.index !== 0 ) return null;
|
|
|
|
|
|
|
|
|
|
const length = firstChild.data.length;
|
|
|
|
|
firstChild.data = trimStart( firstChild.data );
|
|
|
|
|
this.index += match[0].length;
|
|
|
|
|
|
|
|
|
|
if ( firstChild.data === '' ) {
|
|
|
|
|
parser.html.children.shift();
|
|
|
|
|
} else {
|
|
|
|
|
parser.html.start += length - firstChild.data.length;
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
return match[0];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
while ( parser.html.children.length ) {
|
|
|
|
|
const lastChild = parser.html.children[ parser.html.children.length - 1 ];
|
|
|
|
|
parser.html.end = lastChild.end;
|
|
|
|
|
readUntil ( pattern: RegExp ) {
|
|
|
|
|
if ( this.index >= this.template.length ) this.error( 'Unexpected end of input' );
|
|
|
|
|
|
|
|
|
|
const start = this.index;
|
|
|
|
|
const match = pattern.exec( this.template.slice( start ) );
|
|
|
|
|
|
|
|
|
|
if ( lastChild.type !== 'Text' ) break;
|
|
|
|
|
if ( match ) {
|
|
|
|
|
const start = this.index;
|
|
|
|
|
this.index = start + match.index;
|
|
|
|
|
return this.template.slice( start, this.index );
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const length = lastChild.data.length;
|
|
|
|
|
lastChild.data = trimEnd( lastChild.data );
|
|
|
|
|
this.index = this.template.length;
|
|
|
|
|
return this.template.slice( start );
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if ( lastChild.data === '' ) {
|
|
|
|
|
parser.html.children.pop();
|
|
|
|
|
} else {
|
|
|
|
|
parser.html.end -= length - lastChild.data.length;
|
|
|
|
|
break;
|
|
|
|
|
remaining () {
|
|
|
|
|
return this.template.slice( this.index );
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
requireWhitespace () {
|
|
|
|
|
if ( !whitespace.test( this.template[ this.index ] ) ) {
|
|
|
|
|
this.error( `Expected whitespace` );
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
this.allowWhitespace();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export default function parse ( template: string, options: ParserOptions = {} ) {
|
|
|
|
|
const parser = new Parser( template, options );
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
hash: hash( template ),
|
|
|
|
|
hash: hash( parser.template ),
|
|
|
|
|
html: parser.html,
|
|
|
|
|
css: parser.css,
|
|
|
|
|
js: parser.js
|
|
|
|
|