@yagni-js/yagni-parser
Version:
Yet another functional HTML to Javascript compiler (compatible with @yagni-js/yagni-dom)
567 lines (475 loc) • 13 kB
JavaScript
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 };