UNPKG

@yagni-js/yagni-parser

Version:

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

567 lines (475 loc) 13 kB
import { test, replace, prefix, suffix, and, pipe, ifElse, or, transform, pick, isNil, always, identity, join, equals, transformArr, slice, filter, not, map, items, obj, isEmpty as isEmpty$1, camelize, omit, existsIn, repeat, concat, unique } from '@yagni-js/yagni'; import Tokenizer from 'parse5/lib/tokenizer'; import { parse as parse$1 } from 'path'; const hasNewLine = test(/\n/); const hasLeftCurlyBraces = test(/{{/); const hasRightCurlyBraces = test(/}}/); const leftCurlyBracesToOpenExpr = replace(/{{\s*/g, '${'); const rightCurlyBracesToCloseExpr = replace(/\s*}}/g, '}'); const leftQuote = prefix('"'); const rightQuote = suffix('"'); const leftBackTick = prefix('`'); const rightBackTick = suffix('`'); const hasVars = and(hasLeftCurlyBraces, hasRightCurlyBraces); const quotedText = pipe([ leftQuote, rightQuote ]); const templateLiteral = pipe([ leftCurlyBracesToOpenExpr, rightCurlyBracesToCloseExpr, leftBackTick, rightBackTick ]); const smartText = ifElse( or(hasNewLine, hasVars), templateLiteral, quotedText ); const transformText = transform({ line: pipe([ pick('chars'), ifElse( isNil, always(''), identity ), smartText, prefix('hText('), suffix(')') ]), yagniDom: always(['hText']) }); const attrName = pick('name'); const attrValue = pick('value'); const leftCurlyBrace = prefix('{'); const rightCurlyBrace = suffix('}'); const joinUsingComma = join(', '); const startsWithProp = test(/^@?prop-/); const isProperty = pipe([ attrName, startsWithProp ]); const stripProp = replace(/^prop-/, ''); const normalizeWs = pipe([ replace(/\n/g, ' '), replace(/\s{2,}/g, ' ') ]); const isReference = pipe([ attrName, pick(0), equals('@') ]); const stringifyR = pipe([ transformArr([ pipe([attrName, slice(1), stripProp, quotedText]), pipe([attrValue, normalizeWs]) ]), join(': ') ]); const stringifyA = pipe([ transformArr([ pipe([attrName, stripProp, quotedText]), pipe([attrValue, normalizeWs, smartText]) ]), join(': ') ]); const stringifyAttr = ifElse( isReference, stringifyR, stringifyA ); const attrsOnly = filter(not(isProperty)); const propsOnly = filter(isProperty); const stringifyAttrs = pipe([ attrsOnly, map(stringifyAttr), joinUsingComma, leftCurlyBrace, rightCurlyBrace ]); const stringifyProps = pipe([ propsOnly, map(stringifyAttr), joinUsingComma, leftCurlyBrace, rightCurlyBrace ]); function attrsToObj(attrs) { return attrs.reduce(function (acc, attr) { return Object.assign({}, acc, obj(attr.name, attr.value)); }, {}); } const stringifyObj = pipe([ items, map( pipe([ transform({ name: pick('key'), value: pick('value') }), stringifyAttr ]) ), joinUsingComma, leftCurlyBrace, rightCurlyBrace ]); const filename = pick('name'); const attrs = pick('attrs'); const pName = pick('name'); const pSrc = pick('src'); const pIf = pick('p-if'); const pIfNot = pick('p-if-not'); const pMap = pick('p-map'); const pMapIsNil = pipe([pMap, isNil]); const pIfIsNil = pipe([pIf, isNil]); const pIfNotIsNil = pipe([pIfNot, isNil]); const attrsObjIsEmpty = pipe([attrs, isEmpty$1]); const partialName = pipe([ parse$1, filename, camelize, suffix('View') ]); function partialImport(spec) { const name = pName(spec); const src = pSrc(spec); return ['import { view as ' + name + ' } from "' + src + '";']; } const yagniImport = ifElse( pMapIsNil, always([]), ifElse( attrsObjIsEmpty, always(['isArray']), always(['isArray', 'merge', 'pipe']) ) ); const leftParenthesis = always('('); const rightParenthesis = always(')'); const partialCall = pipe([ transformArr([ pName, leftParenthesis, ifElse( pipe([attrs, isEmpty$1]), always('ctx'), pipe([attrs, stringifyObj]) ), rightParenthesis ]), join('') ]); function mergeAndPipe(spec) { const name = pName(spec); const obj = attrs(spec); return 'pipe([merge(' + stringifyObj(obj) + '), ' + name + '])'; } const pMapCaller = ifElse( attrsObjIsEmpty, pName, mergeAndPipe ); function partialMap(spec) { const mapCaller = pMapCaller(spec); const mapTarget = pMap(spec); return 'isArray(' + mapTarget + ') ? ' + mapTarget + '.map(' + mapCaller + ') : hSkip()'; } const partialBody = 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 = ifElse( pIfIsNil, ifElse( pIfNotIsNil, partialBody, partialIfNot ), partialIf ); const yagniDomImport = ifElse( and(pMapIsNil, and(pIfIsNil, pIfNotIsNil)), always([]), always(['hSkip']) ); const transformPartial = pipe([ attrs, attrsToObj, transform({ src: pSrc, name: pipe([pSrc, partialName]), 'p-if': pIf, 'p-if-not': pIfNot, 'p-map': pMap, attrs: omit(['src', 'p-if', 'p-if-not', 'p-map']) }), 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 = pick('tagName'); const attrs$1 = pick('attrs'); const isSvg = pick('isSvg'); const selfClosing = pick('selfClosing'); const endTagStr = always('])'); const emptyStr = always(''); const isSelfClosing = pipe([ selfClosing, equals(true) ]); const isEmpty = pipe([ tagName, existsIn(emptyElements) ]); const isEmptyElement = or( isSelfClosing, isEmpty ); const isPartialElement = pipe([ tagName, equals('partial') ]); const yagniDomFn = ifElse( isSvg, always(['hSVG']), always(['h']) ); const tagToH = pipe([ transformArr([ yagniDomFn, pipe([tagName, quotedText, suffix(', ')]) ]), join('(') ]); const transformEndTag = transform({ line: ifElse( or(isPartialElement, isEmptyElement), emptyStr, endTagStr ) }); const transformStartTag = transform({ yagniDom: yagniDomFn, line: pipe([ transformArr([ tagToH, pipe([attrs$1, stringifyAttrs, suffix(', ')]), pipe([attrs$1, stringifyProps, suffix(', ')]), always('['), ifElse( isEmptyElement, endTagStr, emptyStr ) ]), join('') ]) }); // const isWhitespace = test(/^\s+$/); const repeatSpace = repeat(' '); const isPartial = pipe([ pick('tagName'), equals('partial') ]); const isSVG = pipe([ pick('tagName'), 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({}, obj(Tokenizer.CHARACTER_TOKEN, transformText), obj(Tokenizer.NULL_CHARACTER_TOKEN, emptyObj), obj(Tokenizer.WHITESPACE_CHARACTER_TOKEN, emptyObj), obj(Tokenizer.START_TAG_TOKEN, ifElse(isPartial, transformPartial, transformStartTag)), obj(Tokenizer.END_TAG_TOKEN, ifElse(isPartial, emptyObj, transformEndTag)), obj(Tokenizer.COMMENT_TOKEN, emptyObj), obj(Tokenizer.DOCTYPE_TOKEN, emptyObj), obj(Tokenizer.EOF_TOKEN, emptyObj) ); function concatIfNotNilAndKeepUnique(arr) { return ifElse( isNil, always(arr), pipe([concat(arr), unique]) ); } function mergeSpec(state, spec, level) { const indent = repeatSpace(level); const body = pipe([prefix(indent), concat(state.body)]); const partials = concatIfNotNilAndKeepUnique(state.partials); const yagni = concatIfNotNilAndKeepUnique(state.yagni); const yagniDom = concatIfNotNilAndKeepUnique(state.yagniDom); return { partials: partials(spec.partial), yagni: yagni(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 || isEmpty$1(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 = pipe([ pick('yagni'), ifElse( isEmpty$1, always(''), pipe([join(', '), prefix('import { '), suffix(' } from "@yagni-js/yagni";')]) ) ]); const yagniDomImport$1 = pipe([ pick('yagniDom'), ifElse( isEmpty$1, always(''), pipe([join(', '), prefix('import { '), suffix(' } from "@yagni-js/yagni-dom";')]) ) ]); const partialsImport = pipe([ pick('partials'), join('\n') ]); const viewFunction = pipe([ pick('body'), // join body using comma and newline join(',\n'), // add return statement prefix(' return '), // add 2 more spaces to each line for proper function body indentation replace(/\n/g, '\n '), // strip comma after left square bracket replace(/\[,/g, '['), // strip comma before right square bracket replace(/,\n(?=\s+\])/g, '\n'), // add export statement and left curly bracket prefix('\n\nexport function view(ctx) {\n'), // add right curly bracket suffix(';\n}') ]); const parse = pipe([ htmlToSpec, transformArr([ yagniImport$1, yagniDomImport$1, partialsImport, viewFunction ]), join('\n'), prefix('\n') ]); export { parse };