chore(TypeScript): refactor objects into classes

fix: `Nil`(null, undefined) now renders as empty string
change: `parser.parseValue()` renamed to `parser.parseOutput`
change: registered tags/filters become static and shared across different liquid instances
This commit is contained in:
harttle
2019-02-17 04:55:30 +08:00
parent b51b0dabca
commit 677e8511e6
134 changed files with 3327 additions and 3858 deletions
+12
View File
@@ -0,0 +1,12 @@
import Token from './token'
export default class DelimitedToken extends Token {
trim_left: boolean
trim_right: boolean
constructor(raw, pos, input, file, line) {
super(raw, pos, input, file, line)
this.trim_left = raw[2] === '-'
this.trim_right = raw[raw.length - 3] === '-'
this.value = raw.slice(this.trim_left ? 3 : 2, this.trim_right ? -3 : -2).trim()
}
}
+9
View File
@@ -0,0 +1,9 @@
import Token from './token'
export default class HTMLToken extends Token {
constructor(str, begin, input, file, line) {
super(str, begin, input, file, line)
this.type = 'html'
this.value = str
}
}
+86
View File
@@ -0,0 +1,86 @@
// quote related
const singleQuoted = /'[^']*'/
const doubleQuoted = /"[^"]*"/
export const quoted = new RegExp(`${singleQuoted.source}|${doubleQuoted.source}`)
export const quoteBalanced = new RegExp(`(?:${quoted.source}|[^'"])*`)
// basic types
export const integer = /-?\d+/
export const number = /-?\d+\.?\d*|\.?\d+/
export const bool = /true|false/
// property access
export const identifier = /[\w-]+[?]?/
export const subscript = new RegExp(`\\[(?:${quoted.source}|[\\w-\\.]+)\\]`)
export const literal = new RegExp(`(?:${quoted.source}|${bool.source}|${number.source})`)
export const variable = new RegExp(`${identifier.source}(?:\\.${identifier.source}|${subscript.source})*`)
// range related
export const rangeLimit = new RegExp(`(?:${variable.source}|${number.source})`)
export const range = new RegExp(`\\(${rangeLimit.source}\\.\\.${rangeLimit.source}\\)`)
export const rangeCapture = new RegExp(`\\((${rangeLimit.source})\\.\\.(${rangeLimit.source})\\)`)
export const value = new RegExp(`(?:${variable.source}|${literal.source}|${range.source})`)
// hash related
export const hash = new RegExp(`(?:${identifier.source})\\s*:\\s*(?:${value.source})`)
export const hashCapture = new RegExp(`(${identifier.source})\\s*:\\s*(${value.source})`, 'g')
// full match
export const tagLine = new RegExp(`^\\s*(${identifier.source})\\s*([\\s\\S]*?)\\s*$`)
export const literalLine = new RegExp(`^${literal.source}$`, 'i')
export const variableLine = new RegExp(`^${variable.source}$`)
export const numberLine = new RegExp(`^${number.source}$`)
export const boolLine = new RegExp(`^${bool.source}$`, 'i')
export const quotedLine = new RegExp(`^${quoted.source}$`)
export const rangeLine = new RegExp(`^${rangeCapture.source}$`)
export const integerLine = new RegExp(`^${integer.source}$`)
// filter related
export const valueDeclaration = new RegExp(`(?:${identifier.source}\\s*:\\s*)?${value.source}`)
export const valueList = new RegExp(`${valueDeclaration.source}(\\s*,\\s*${valueDeclaration.source})*`)
export const filter = new RegExp(`${identifier.source}(?:\\s*:\\s*${valueList.source})?`, 'g')
export const filterCapture = new RegExp(`(${identifier.source})(?:\\s*:\\s*(${valueList.source}))?`)
export const filterLine = new RegExp(`^${filterCapture.source}$`)
export const operators = [
/\s+or\s+/,
/\s+and\s+/,
/==|!=|<=|>=|<|>|\s+contains\s+/
]
export function isInteger (str) {
return integerLine.test(str)
}
export function isLiteral (str) {
return literalLine.test(str)
}
export function isRange (str) {
return rangeLine.test(str)
}
export function isVariable (str) {
return variableLine.test(str)
}
export function matchValue (str) {
return value.exec(str)
}
export function parseLiteral (str) {
let res = str.match(numberLine)
if (res) {
return Number(str)
}
res = str.match(boolLine)
if (res) {
return str.toLowerCase() === 'true'
}
res = str.match(quotedLine)
if (res) {
return str.slice(1, -1)
}
throw new TypeError(`cannot parse '${str}' as literal`)
}
+8
View File
@@ -0,0 +1,8 @@
import DelimitedToken from './delimited-token'
export default class OutputToken extends DelimitedToken {
constructor(raw, pos, input, file, line) {
super(raw, pos, input, file, line)
this.type = 'output'
}
}
+47
View File
@@ -0,0 +1,47 @@
import Token from 'src/parser/token'
import ITemplate from 'src/template/itemplate'
type parseToken = (token: Token, remainTokens: Array<Token>) => ITemplate
type eventHandler = ((arg?: Token | ITemplate) => void)
export default class ParseStream {
private tokens: Array<Token>
private handlers: {[key: string]: eventHandler} = {}
private stopRequested: boolean
private parseToken: parseToken
constructor (tokens: Array<Token>, parseToken: parseToken) {
this.tokens = tokens
this.parseToken = parseToken
}
on (name: string, cb: eventHandler) {
this.handlers[name] = cb
return this
}
trigger (event: string, arg?: Token | ITemplate) {
const h = this.handlers[event]
if (typeof h === 'function') {
h(arg)
return true
}
}
start () {
this.trigger('start')
let token
while (!this.stopRequested && (token = this.tokens.shift())) {
if (this.trigger('token', token)) continue
if (token.type === 'tag' &&
this.trigger(`tag:${token.name}`, token)) {
continue
}
const template = this.parseToken(token, this.tokens)
this.trigger('template', template)
}
if (!this.stopRequested) this.trigger('end')
return this
}
stop () {
this.stopRequested = true
return this
}
}
+43
View File
@@ -0,0 +1,43 @@
import { ParseError } from '../util/error'
import Liquid from 'src/liquid'
import ParseStream from './parse-stream'
import Token from './token'
import Tag from 'src/template/tag/tag'
import HTMLToken from './html-token'
import TagToken from './tag-token'
import OutputToken from './output-token'
import Output from 'src/template/output'
import HTML from 'src/template/html'
import Value from 'src/template/value'
export default class Parser {
liquid: Liquid
constructor(liquid: Liquid) {
this.liquid = liquid
}
parse (tokens: Array<Token>) {
let token
const templates = []
while ((token = tokens.shift())) {
templates.push(this.parseToken(token, tokens))
}
return templates
}
parseToken (token: Token, remainTokens: Array<Token>) {
try {
if (token.type === 'tag') {
return new Tag(token, remainTokens, this.liquid)
}
if (token.type === 'output') {
return new Output(token, this.liquid.options.strict_filters)
}
return new HTML(token)
} catch (e) {
throw new ParseError(e, token)
}
}
parseStream (tokens: Array<Token>) {
return new ParseStream(tokens, (token, tokens) => this.parseToken(token, tokens))
}
}
+18
View File
@@ -0,0 +1,18 @@
import DelimitedToken from './delimited-token'
import { TokenizationError } from 'src/util/error'
import * as lexical from './lexical'
export default class TagToken extends DelimitedToken {
name: string
args: string
constructor(raw, pos, input, file, line) {
super(raw, pos, input, file, line)
this.type = 'tag'
const match = this.value.match(lexical.tagLine)
if (!match) {
throw new TokenizationError(`illegal tag syntax`, this)
}
this.name = match[1]
this.args = match[2]
}
}
+52
View File
@@ -0,0 +1,52 @@
import { last, isArray } from '../util/underscore'
function domResolve (root, path) {
const base = document.createElement('base')
base.href = root
const head = document.getElementsByTagName('head')[0]
head.insertBefore(base, head.firstChild)
const a = document.createElement('a')
a.href = path
const resolved = a.href
head.removeChild(base)
return resolved
}
export function resolve (filepath, root, options) {
root = root || options.root
if (isArray(root)) {
root = root[0]
}
if (root.length && last(root) !== '/') {
root += '/'
}
const url = domResolve(root, filepath)
return url.replace(/^(\w+:\/\/[^/]+)(\/[^?]+)/, (str, origin, path) => {
const last = path.split('/').pop()
if (/\.\w+$/.test(last)) {
return str
}
return origin + path + options.extname
})
}
export async function read (url: string): Promise<string> {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest()
xhr.onload = () => {
if (xhr.status >= 200 && xhr.status < 300) {
resolve(xhr.responseText as string)
} else {
reject(new Error(xhr.statusText))
}
}
xhr.onerror = () => {
reject(new Error('An error occurred whilst receiving the response.'))
}
xhr.open('GET', url)
xhr.send()
})
}
+29
View File
@@ -0,0 +1,29 @@
import * as _ from '../util/underscore'
import * as path from 'path'
import { anySeries } from '../util/promise'
import * as fs from 'fs'
const statFileAsync = <(filepath: string) => Promise<object>>_.promisify(fs.stat)
const readFileAsync = <(filepath: string, encoding: string) => Promise<string>>_.promisify(fs.readFile)
export async function resolve (filepath, root, options) {
if (!path.extname(filepath)) {
filepath += options.extname
}
root = options.root.concat(root || [])
root = _.uniq(root)
const paths = root.map(root => path.resolve(root, filepath))
return anySeries(paths, async path => {
try {
await statFileAsync(path)
return path
} catch (e) {
e.message = `${e.code}: Failed to lookup ${filepath} in: ${root}`
throw e
}
})
}
export async function read (filepath): Promise<string> {
return readFileAsync(filepath, 'utf8')
}
+14
View File
@@ -0,0 +1,14 @@
export default class Token {
type: string
line: number
raw: string
input: string
file: string
value: string
constructor(raw, pos, input, file, line) {
this.line = line
this.raw = raw
this.input = input
this.file = file
}
}
+53
View File
@@ -0,0 +1,53 @@
import whiteSpaceCtrl from './whitespace-ctrl'
import HTMLToken from './html-token'
import TagToken from './tag-token'
import OutputToken from './output-token'
enum ParseState { HTML, OUTPUT, TAG }
export function parse (input: string, file?: string, options?) {
const tokens = []
let p = 0
let line = 1
let state = ParseState.HTML
let buffer = ''
let bufferBegin = 0
while(p < input.length) {
if (input[p] === '\n') line++
const bin = input.substr(p, 2)
if (state === ParseState.HTML) {
if (bin === '{{' || bin === '{%') {
if (buffer) tokens.push(new HTMLToken(buffer, bufferBegin, input, file, line))
buffer = bin
bufferBegin = p
p += 2
state = bin === '{{' ? ParseState.OUTPUT : ParseState.TAG
continue
}
}
else if (state === ParseState.OUTPUT && bin === '}}') {
buffer += '}}'
tokens.push(new OutputToken(buffer, bufferBegin, input, file, line))
p += 2
buffer = ''
bufferBegin = p
state = ParseState.HTML
continue
}
else if (bin === '%}') {
buffer += '%}'
tokens.push(new TagToken(buffer, bufferBegin, input, file, line))
p += 2
buffer = ''
bufferBegin = p
state = ParseState.HTML
continue
}
buffer += input[p++]
}
if (buffer) tokens.push(new HTMLToken(buffer, bufferBegin, input, file, line))
whiteSpaceCtrl(tokens, options)
return tokens
}
+48
View File
@@ -0,0 +1,48 @@
import { assign } from 'src/util/underscore'
import TagToken from './tag-token'
import OutputToken from './output-token'
import HTMLToken from './html-token'
export default function whiteSpaceCtrl (tokens, options) {
options = assign({ greedy: true }, options)
let inRaw = false
tokens.forEach((token, i) => {
if (shouldTrimLeft(token, inRaw, options)) {
trimLeft(tokens[i - 1], options.greedy)
}
if (token.type === 'tag' && token.name === 'raw') inRaw = true
if (token.type === 'tag' && token.name === 'endraw') inRaw = false
if (shouldTrimRight(token, inRaw, options)) {
trimRight(tokens[i + 1], options.greedy)
}
})
}
function shouldTrimLeft (token, inRaw, options) {
if (inRaw) return false
if (token.type === 'tag') return token.trim_left || options.trim_tag_left
if (token.type === 'output') return token.trim_left || options.trim_value_left
}
function shouldTrimRight (token, inRaw, options) {
if (inRaw) return false
if (token.type === 'tag') return token.trim_right || options.trim_tag_right
if (token.type === 'output') return token.trim_right || options.trim_value_right
}
function trimLeft (token, greedy) {
if (!token || token.type !== 'html') return
const rLeft = greedy ? /\s+$/g : /[\t\r ]*$/g
token.value = token.value.replace(rLeft, '')
}
function trimRight (token, greedy) {
if (!token || token.type !== 'html') return
const rRight = greedy ? /^\s+/g : /^[\t\r ]*\n?/g
token.value = token.value.replace(rRight, '')
}