UNPKG

@yagni-js/yagni-parser

Version:

Yet another functional HTML to Javascript compiler (compatible with @yagni-js/yagni-dom)

573 lines (478 loc) 14.2 kB
'use strict'; Object.defineProperty(exports, '__esModule', { value: true }); function _interopDefault (ex) { return (ex && (typeof ex === 'object') && 'default' in ex) ? ex['default'] : ex; } var yagni = require('@yagni-js/yagni'); var Tokenizer = _interopDefault(require('parse5/lib/tokenizer')); var path = require('path'); const hasNewLine = yagni.test(/\n/); const hasLeftCurlyBraces = yagni.test(/{{/); const hasRightCurlyBraces = yagni.test(/}}/); const leftCurlyBracesToOpenExpr = yagni.replace(/{{\s*/g, '${'); const rightCurlyBracesToCloseExpr = yagni.replace(/\s*}}/g, '}'); const leftQuote = yagni.prefix('"'); const rightQuote = yagni.suffix('"'); const leftBackTick = yagni.prefix('`'); const rightBackTick = yagni.suffix('`'); const hasVars = yagni.and(hasLeftCurlyBraces, hasRightCurlyBraces); const quotedText = yagni.pipe([ leftQuote, rightQuote ]); const templateLiteral = yagni.pipe([ leftCurlyBracesToOpenExpr, rightCurlyBracesToCloseExpr, leftBackTick, rightBackTick ]); const smartText = yagni.ifElse( yagni.or(hasNewLine, hasVars), templateLiteral, quotedText ); const transformText = yagni.transform({ line: yagni.pipe([ yagni.pick('chars'), yagni.ifElse( yagni.isNil, yagni.always(''), yagni.identity ), smartText, yagni.prefix('hText('), yagni.suffix(')') ]), yagniDom: yagni.always(['hText']) }); const attrName = yagni.pick('name'); const attrValue = yagni.pick('value'); const leftCurlyBrace = yagni.prefix('{'); const rightCurlyBrace = yagni.suffix('}'); const joinUsingComma = yagni.join(', '); const startsWithProp = yagni.test(/^@?prop-/); const isProperty = yagni.pipe([ attrName, startsWithProp ]); const stripProp = yagni.replace(/^prop-/, ''); const normalizeWs = yagni.pipe([ yagni.replace(/\n/g, ' '), yagni.replace(/\s{2,}/g, ' ') ]); const isReference = yagni.pipe([ attrName, yagni.pick(0), yagni.equals('@') ]); const stringifyR = yagni.pipe([ yagni.transformArr([ yagni.pipe([attrName, yagni.slice(1), stripProp, quotedText]), yagni.pipe([attrValue, normalizeWs]) ]), yagni.join(': ') ]); const stringifyA = yagni.pipe([ yagni.transformArr([ yagni.pipe([attrName, stripProp, quotedText]), yagni.pipe([attrValue, normalizeWs, smartText]) ]), yagni.join(': ') ]); const stringifyAttr = yagni.ifElse( isReference, stringifyR, stringifyA ); const attrsOnly = yagni.filter(yagni.not(isProperty)); const propsOnly = yagni.filter(isProperty); const stringifyAttrs = yagni.pipe([ attrsOnly, yagni.map(stringifyAttr), joinUsingComma, leftCurlyBrace, rightCurlyBrace ]); const stringifyProps = yagni.pipe([ propsOnly, yagni.map(stringifyAttr), joinUsingComma, leftCurlyBrace, rightCurlyBrace ]); function attrsToObj(attrs) { return attrs.reduce(function (acc, attr) { return Object.assign({}, acc, yagni.obj(attr.name, attr.value)); }, {}); } const stringifyObj = yagni.pipe([ yagni.items, yagni.map( yagni.pipe([ yagni.transform({ name: yagni.pick('key'), value: yagni.pick('value') }), stringifyAttr ]) ), joinUsingComma, leftCurlyBrace, rightCurlyBrace ]); const filename = yagni.pick('name'); const attrs = yagni.pick('attrs'); const pName = yagni.pick('name'); const pSrc = yagni.pick('src'); const pIf = yagni.pick('p-if'); const pIfNot = yagni.pick('p-if-not'); const pMap = yagni.pick('p-map'); const pMapIsNil = yagni.pipe([pMap, yagni.isNil]); const pIfIsNil = yagni.pipe([pIf, yagni.isNil]); const pIfNotIsNil = yagni.pipe([pIfNot, yagni.isNil]); const attrsObjIsEmpty = yagni.pipe([attrs, yagni.isEmpty]); const partialName = yagni.pipe([ path.parse, filename, yagni.camelize, yagni.suffix('View') ]); function partialImport(spec) { const name = pName(spec); const src = pSrc(spec); return ['import { view as ' + name + ' } from "' + src + '";']; } const yagniImport = yagni.ifElse( pMapIsNil, yagni.always([]), yagni.ifElse( attrsObjIsEmpty, yagni.always(['isArray']), yagni.always(['isArray', 'merge', 'pipe']) ) ); const leftParenthesis = yagni.always('('); const rightParenthesis = yagni.always(')'); const partialCall = yagni.pipe([ yagni.transformArr([ pName, leftParenthesis, yagni.ifElse( yagni.pipe([attrs, yagni.isEmpty]), yagni.always('ctx'), yagni.pipe([attrs, stringifyObj]) ), rightParenthesis ]), yagni.join('') ]); function mergeAndPipe(spec) { const name = pName(spec); const obj = attrs(spec); return 'pipe([merge(' + stringifyObj(obj) + '), ' + name + '])'; } const pMapCaller = yagni.ifElse( attrsObjIsEmpty, pName, mergeAndPipe ); function partialMap(spec) { const mapCaller = pMapCaller(spec); const mapTarget = pMap(spec); return 'isArray(' + mapTarget + ') ? ' + mapTarget + '.map(' + mapCaller + ') : hSkip()'; } const partialBody = yagni.ifElse( pMapIsNil, partialCall, partialMap ); function partialIf(spec) { const cond = pIf(spec); const body = partialBody(spec); return '(' + cond + ') ? (' + body + ') : hSkip()'; } function partialIfNot(spec) { const cond = pIfNot(spec); const body = partialBody(spec); return '!(' + cond + ') ? (' + body + ') : hSkip()'; } const stringifyPartial = yagni.ifElse( pIfIsNil, yagni.ifElse( pIfNotIsNil, partialBody, partialIfNot ), partialIf ); const yagniDomImport = yagni.ifElse( yagni.and(pMapIsNil, yagni.and(pIfIsNil, pIfNotIsNil)), yagni.always([]), yagni.always(['hSkip']) ); const transformPartial = yagni.pipe([ attrs, attrsToObj, yagni.transform({ src: pSrc, name: yagni.pipe([pSrc, partialName]), 'p-if': pIf, 'p-if-not': pIfNot, 'p-map': pMap, attrs: yagni.omit(['src', 'p-if', 'p-if-not', 'p-map']) }), yagni.transform({ partial: partialImport, yagni: yagniImport, yagniDom: yagniDomImport, line: stringifyPartial }) ]); // see https://developer.mozilla.org/en-US/docs/Glossary/empty_element const emptyElements = [ 'area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input', 'keygen', 'link', 'meta', 'param', 'source', 'track', 'wbr' ]; const tagName = yagni.pick('tagName'); const attrs$1 = yagni.pick('attrs'); const isSvg = yagni.pick('isSvg'); const selfClosing = yagni.pick('selfClosing'); const endTagStr = yagni.always('])'); const emptyStr = yagni.always(''); const isSelfClosing = yagni.pipe([ selfClosing, yagni.equals(true) ]); const isEmpty = yagni.pipe([ tagName, yagni.existsIn(emptyElements) ]); const isEmptyElement = yagni.or( isSelfClosing, isEmpty ); const isPartialElement = yagni.pipe([ tagName, yagni.equals('partial') ]); const yagniDomFn = yagni.ifElse( isSvg, yagni.always(['hSVG']), yagni.always(['h']) ); const tagToH = yagni.pipe([ yagni.transformArr([ yagniDomFn, yagni.pipe([tagName, quotedText, yagni.suffix(', ')]) ]), yagni.join('(') ]); const transformEndTag = yagni.transform({ line: yagni.ifElse( yagni.or(isPartialElement, isEmptyElement), emptyStr, endTagStr ) }); const transformStartTag = yagni.transform({ yagniDom: yagniDomFn, line: yagni.pipe([ yagni.transformArr([ tagToH, yagni.pipe([attrs$1, stringifyAttrs, yagni.suffix(', ')]), yagni.pipe([attrs$1, stringifyProps, yagni.suffix(', ')]), yagni.always('['), yagni.ifElse( isEmptyElement, endTagStr, emptyStr ) ]), yagni.join('') ]) }); // const isWhitespace = test(/^\s+$/); const repeatSpace = yagni.repeat(' '); const isPartial = yagni.pipe([ yagni.pick('tagName'), yagni.equals('partial') ]); const isSVG = yagni.pipe([ yagni.pick('tagName'), yagni.equals('svg') ]); function plus2(x) { return x + 2; } function minus2(x) { return x - 2; } function emptyObj() { return {}; } function last(arr) { return arr[arr.length - 1]; } const tokenTransformers = Object.assign({}, yagni.obj(Tokenizer.CHARACTER_TOKEN, transformText), yagni.obj(Tokenizer.NULL_CHARACTER_TOKEN, emptyObj), yagni.obj(Tokenizer.WHITESPACE_CHARACTER_TOKEN, emptyObj), yagni.obj(Tokenizer.START_TAG_TOKEN, yagni.ifElse(isPartial, transformPartial, transformStartTag)), yagni.obj(Tokenizer.END_TAG_TOKEN, yagni.ifElse(isPartial, emptyObj, transformEndTag)), yagni.obj(Tokenizer.COMMENT_TOKEN, emptyObj), yagni.obj(Tokenizer.DOCTYPE_TOKEN, emptyObj), yagni.obj(Tokenizer.EOF_TOKEN, emptyObj) ); function concatIfNotNilAndKeepUnique(arr) { return yagni.ifElse( yagni.isNil, yagni.always(arr), yagni.pipe([yagni.concat(arr), yagni.unique]) ); } function mergeSpec(state, spec, level) { const indent = repeatSpace(level); const body = yagni.pipe([yagni.prefix(indent), yagni.concat(state.body)]); const partials = concatIfNotNilAndKeepUnique(state.partials); const yagni$1 = concatIfNotNilAndKeepUnique(state.yagni); const yagniDom = concatIfNotNilAndKeepUnique(state.yagniDom); return { partials: partials(spec.partial), yagni: yagni$1(spec.yagni), yagniDom: yagniDom(spec.yagniDom), body: body(spec.line) }; } function isEofToken(token) { return token.type === Tokenizer.EOF_TOKEN; } function isStartTagToken(token) { return token.type === Tokenizer.START_TAG_TOKEN; } function isEndTagToken(token) { return token.type === Tokenizer.END_TAG_TOKEN; } function isCharacterToken(token) { return token.type === Tokenizer.CHARACTER_TOKEN; } function isWhitespaceToken(token) { return token.type === Tokenizer.WHITESPACE_CHARACTER_TOKEN; } function isNullToken(token) { return token.type === Tokenizer.NULL_CHARACTER_TOKEN; } function isTextToken(token) { return isCharacterToken(token) || isWhitespaceToken(token) || isNullToken(token); } function process(acc, token) { const state = acc.state; const meta = acc.meta; const tagName = token.tagName; const isSvg = isSVG(token); const isEof = isEofToken(token); const isStartTag = isStartTagToken(token); const isEndTag = isEndTagToken(token); const emptyElement = isEmptyElement(token); const transformer = tokenTransformers[token.type]; // eslint-disable-next-line better/no-ifs if (isEndTag && (tagName !== meta.stack[meta.stack.length - 1])) { // eslint-disable-next-line fp/no-throw,better/no-new throw new Error('Html markup error (opening/closing tags differ)'); } const currentLevel = isEndTag ? minus2(meta.level) : meta.level; const nextLevel = isStartTag && !emptyElement ? plus2(meta.level) : currentLevel; const spec = transformer(Object.assign({}, token, {isSvg: meta.isSvg || isSvg})); const nextState = (isEof || yagni.isEmpty(spec)) ? state : mergeSpec(state, spec, currentLevel); // TODO test for proper nested svg detection in tree const nextMeta = { level: nextLevel, stack: isStartTag && !emptyElement? meta.stack.concat(tagName) : (isEndTag ? meta.stack.slice(0, meta.stack.length -1) : meta.stack), rootCounter: isStartTag && currentLevel === 0 ? meta.rootCounter + 1 : meta.rootCounter, isSvg: isStartTag && isSvg ? true : (isEndTag && isSvg ? false : meta.isSvg) }; // eslint-disable-next-line better/no-ifs if (nextMeta.rootCounter > 1) { // eslint-disable-next-line fp/no-throw,better/no-new throw new Error('Multiple root elements error'); } // eslint-disable-next-line better/no-ifs if (isEof && meta.stack.length > 0) { // eslint-disable-next-line fp/no-throw,better/no-new throw new Error('Html markup error'); } return isEof ? {state: state} : {state: nextState, meta: nextMeta}; } function createTokenizer() { // eslint-disable-next-line better/no-new return new Tokenizer(); } function tokenize(tokenizer, tokens) { const token = tokenizer.getNextToken(); const isEof = isEofToken(token); const isText = isTextToken(token); const hasPrev = tokens.length > 0; const prevToken = hasPrev ? last(tokens) : false; const prevIsText = hasPrev ? isTextToken(prevToken) : false; const nextToken = (isText && prevIsText) ? {type: Tokenizer.CHARACTER_TOKEN, chars: prevToken.chars + token.chars} : token; const nextTokens = (isText && prevIsText) ? tokens.slice(0, -1) : tokens; return isEof ? tokens.concat([token]) : tokenize(tokenizer, nextTokens.concat([nextToken])); } function htmlToSpec(source) { const tokenizer = createTokenizer(); const isLastChunk = true; const state = { partials: [], yagni: [], yagniDom: [], body: [] }; const meta = { level: 0, stack: [], rootCounter: 0, isSvg: false }; // NB. unused assignment with no value const res = tokenizer.write(source, isLastChunk); const tokens = tokenize(tokenizer, []); const spec = tokens.reduce(process, {state: state, meta: meta}); return spec.state; } const yagniImport$1 = yagni.pipe([ yagni.pick('yagni'), yagni.ifElse( yagni.isEmpty, yagni.always(''), yagni.pipe([yagni.join(', '), yagni.prefix('import { '), yagni.suffix(' } from "@yagni-js/yagni";')]) ) ]); const yagniDomImport$1 = yagni.pipe([ yagni.pick('yagniDom'), yagni.ifElse( yagni.isEmpty, yagni.always(''), yagni.pipe([yagni.join(', '), yagni.prefix('import { '), yagni.suffix(' } from "@yagni-js/yagni-dom";')]) ) ]); const partialsImport = yagni.pipe([ yagni.pick('partials'), yagni.join('\n') ]); const viewFunction = yagni.pipe([ yagni.pick('body'), // join body using comma and newline yagni.join(',\n'), // add return statement yagni.prefix(' return '), // add 2 more spaces to each line for proper function body indentation yagni.replace(/\n/g, '\n '), // strip comma after left square bracket yagni.replace(/\[,/g, '['), // strip comma before right square bracket yagni.replace(/,\n(?=\s+\])/g, '\n'), // add export statement and left curly bracket yagni.prefix('\n\nexport function view(ctx) {\n'), // add right curly bracket yagni.suffix(';\n}') ]); const parse = yagni.pipe([ htmlToSpec, yagni.transformArr([ yagniImport$1, yagniDomImport$1, partialsImport, viewFunction ]), yagni.join('\n'), yagni.prefix('\n') ]); exports.parse = parse;