@airtasker/form-schema-compiler
Version:
a form schema compiler
258 lines (239 loc) • 6.6 kB
JavaScript
import curryRight from "lodash/curryRight";
import curry from "lodash/curry";
import mapValues from "lodash/mapValues";
import mapKeys from "lodash/mapKeys";
import findKey from "lodash/findKey";
import flowRight from "lodash/flowRight";
import {
ANNOTATION_TYPES,
ANNOTATIONS,
TYPES,
COMPATIBLE_SCHEMA_VERSION
} from "./const";
import {
parseExpressionString,
parseTemplateString,
parseTwoWayBindingString
} from "./parsers";
import createTypeCompiler from "./typeCompiler";
import { isVersionCompatible } from "./utils";
const mapValuesFp = curryRight(mapValues);
const mapKeysFp = curryRight(mapKeys);
/**
* convert json value
* "string" to {type: string, value: "string"}
* 1 to {type: numeric, value: 1}
* true to {type: boolean, value: 1}
* null to {type: Null, value: null}
* other types to {type: Raw, value}
* @param obj
* @returns {*}
*/
const toValueObject = value => {
if (value === null) {
return {
type: TYPES.Null,
value
};
}
switch (typeof value) {
case "string":
return {
type: TYPES.String,
value
};
case "number":
return {
type: TYPES.Numeric,
value
};
case "boolean":
return {
type: TYPES.Boolean,
value
};
default:
return {
type: TYPES.Raw,
value
};
}
};
/**
* get annotation
* @param key string
*
* e.g.
* '[value]' return '[]'
* '<value>' return '<>'
* '#value#' return '##'
* '{value}' return '{}'
* '(value)' return '()'
*/
const getAnnotationType = key =>
findKey(ANNOTATIONS, { 0: key[0], 1: key.substr(-1) });
/**
* strip annotation
* @param key string
*
* e.g.
* '[value]' return 'value'
* '<value>' return 'value'
* '#value#' return 'value'
* '{value}' return 'value'
* '(value)' return 'value'
*/
const stripAnnotation = (value, key) => {
const strippedKey = key.substring(1, key.length - 1);
switch (getAnnotationType(key)) {
case ANNOTATION_TYPES.TwoWayBinding:
case ANNOTATION_TYPES.PropertyBinding:
case ANNOTATION_TYPES.Template:
case ANNOTATION_TYPES.Components:
// only strip annotation when there is one
// convert [value] to value
return strippedKey;
case ANNOTATION_TYPES.EventBinding:
// convert (click) to onClick
return `on${strippedKey[0].toUpperCase()}${strippedKey.substr(1)}`;
default:
return key;
}
};
class ErrorPathTrace extends Error {
constructor(key, value, error) {
super();
if (error.kind === 'ErrorPathTrace') {
this.path = `${key}.${error.path}`;
this.originMessage = error.originMessage;
} else {
this.path = `${key}:"${value}"`;
this.originMessage = error.message;
}
this.kind = 'ErrorPathTrace';
this.message = `Found an error in ${this.path}. Error details: ${this.originMessage}`;
}
}
/**
* compile value to ast expression,
* examples see expression parsers and tests
* @param {*} options
* @param {*} value
* @param {*} key
*/
const compileValue = curry((options, value, key) => {
try {
switch (getAnnotationType(key)) {
case ANNOTATION_TYPES.EventBinding:
return {
type: ANNOTATION_TYPES.EventBinding,
value: parseExpressionString(value)
};
case ANNOTATION_TYPES.PropertyBinding:
if (typeof value === "object") {
return {
type: ANNOTATION_TYPES.PropertyBinding,
nested: true,
value: compileProps(value, options)
};
}
return {
type: ANNOTATION_TYPES.PropertyBinding,
nested: false,
value: parseExpressionString(value)
};
case ANNOTATION_TYPES.TwoWayBinding:
throw new Error(
"Should use compile to convert TwoWayBinding to EventBinding and PropertyBinding"
);
case ANNOTATION_TYPES.Template:
return {
type: ANNOTATION_TYPES.PropertyBinding,
value: parseTemplateString(value)
};
case ANNOTATION_TYPES.Components:
// recursively compile nested component
// eslint-disable-next-line no-use-before-define
return compileComponents(value, options);
default:
return toValueObject(value);
}
} catch (error) {
throw new ErrorPathTrace(key, value, error);
}
});
/**
* compile values but not keys, compile values to ast expression.
* e.g.
* {'{value}': 'expression'} return {'{value}': 'compiled expression'}
*/
const createCompileValues = options => mapValuesFp(compileValue(options));
/**
* compile keys but not value, compile keys to non-annotation key.
* e.g.
* {'{value}': 'expression', '<component>': 'expression' } return {'value': 'expression', 'component': 'expression'}
*/
const compileKeys = mapKeysFp(stripAnnotation);
/**
* compile keys and values.
*/
export const compileProps = (props, options) =>
flowRight(
compileKeys,
createCompileValues(options)
)(props);
const compilePropsFp = curryRight(compileProps);
/**
* Take component schema return AST,
* examples see expression parsers and tests
* @param {*} componentSchema
* @param {{typeCompilers: *}} options
* @returns {{type: *}}
*/
const compileComponent = ({ type, ...props }, options = {}) => {
if (!type) {
throw new Error("type is a mandatory field in component");
}
const typeCompiler = createTypeCompiler(type, options.typeCompilers);
const composed = flowRight(
typeCompiler.after,
compilePropsFp(options),
typeCompiler.before
);
return {
type,
...composed(props)
};
};
/**
* compile components schema
* @param {*} components if components is an object convert to [components]
* @param {{typeCompilers: *}} options
* @returns {{type: string, components: Array}}
*/
export function compileComponents(components, options) {
if (typeof components !== "object") {
throw new Error("components must be a object or array");
}
const componentArray = Array.isArray(components) ? components : [components];
return {
type: TYPES.Components,
components: componentArray.map(component =>
compileComponent(component, options)
)
};
}
const compile = ({ schemaVersion, ...rest }, options) => {
if (!schemaVersion || !isVersionCompatible(schemaVersion)) {
throw new Error(
"incompatible version, you may use wrong version form-schema"
);
}
const component = rest["<component>"];
return {
...rest,
schemaVersion,
component: compileComponents(component, options)
};
};
export default compile;