@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
JavaScript
'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;