Olian04/brynja

View on GitHub
src/builder.ts

Summary

Maintainability
C
1 day
Test Coverage
import { IBuilderCTX } from './interfaces/BuilderCTX';
import { IStyleObject } from './interfaces/StyleObject';
import { IStyles } from './interfaces/Styles';
import { VNode } from './interfaces/VNode';
import { objHash } from './util/hash';

/* istanbul ignore next */ // istanbul doesn't recognize this function as covered by tests, even though it clearly is
export const newVNode = (ctx: Partial<VNode> = {}) => ({
  tag: '',
  value: null,
  text: '',
  events: {},
  props: {},
  children: [],
  ...ctx, // Replace defaults in present in ctx argument
});

const naiveTypeCheck = <T>(
  operationName: string,
  argumentPosition: string,
  expectedType: string,
  argumentValue: T,
) => {
  /* istanbul ignore if */
  if (typeof argumentValue !== expectedType) {
    throw new TypeError(
      `Brynja: Expected ${argumentPosition} argument of "${operationName}" operation to be of type ${expectedType}, but received ${typeof argumentValue}`,
    );
  }
}

const serializableTypeCheck = <T>(
  operationName: string,
  argumentPosition: string,
  argumentValue: T
) => {
  try {
    String(argumentValue)
  } catch {
    /* istanbul ignore next */
    throw new TypeError(
      `Brynja: Expected ${argumentPosition} argument of "${operationName}" operation to be serializable.`,
    );
  }
}

export type BuilderCB = (ctx: IBuilderCTX) => void;
export function buildNode(
  tagType: string,
  builder: BuilderCB,
): [VNode, IStyles] {
  const ctx: VNode = newVNode({
    tag: tagType,
  });

  let styles: IStyles = {};
  const builderCtx: IBuilderCTX = {
    style(styleObject: IStyleObject) {
      naiveTypeCheck('style', 'first', 'object', styleObject);

      const styleHash = objHash(styleObject);
      styles[styleHash] = styleObject;
      this.class(styleHash);
      return this;
    },
    on(eventName: string, handler: (e: any) => void) {
      naiveTypeCheck('on', 'first', 'string', eventName);
      naiveTypeCheck('on', 'second', 'function', handler);

      if (eventName in ctx.events) {
        ctx.events[eventName].push(handler);
      } else {
        ctx.events[eventName] = [handler];
      }
      return this;
    },
    child(tagType: string, builder: BuilderCB) {
      naiveTypeCheck('child', 'first', 'string', tagType);
      naiveTypeCheck('child', 'second', 'function', builder);

      const [child, childStyles] = buildNode(tagType, builder);
      ctx.children.push(child);
      styles = { ...styles, ...childStyles };
      return this;
    },
    children<T>(
      tagType: string,
      countOrArray: number | T[],
      builder: (ctx: IBuilderCTX, i: number | T) => void,
    ) {
      naiveTypeCheck('child', 'first', 'string', tagType);
      /* istanbul ignore if */
      if (typeof countOrArray !== 'number' && !Array.isArray(countOrArray)) {
        throw new TypeError(
          `Brynja: Expected second argument of "child" operation to be of type number or array, but received ${typeof countOrArray}`,
        );
      }
      naiveTypeCheck('child', 'third', 'function', builder);

      const items =
        typeof countOrArray === 'number'
          ? Array(countOrArray)
              .fill(0)
              .map((_, i) => i)
          : (countOrArray as T[]);
      const count = items.length;

      for (let __i = 0; __i < count; __i++) {
        const [child, childStyles] = buildNode(tagType, (_) =>
          builder(_, items[__i]),
        );
        ctx.children.push(child);
        styles = { ...styles, ...childStyles };
      }
      return this;
    },
    when(
      booleanExpression: boolean,
      then_builder: BuilderCB,
      else_builder?: BuilderCB,
    ) {
      naiveTypeCheck('when', 'first', 'boolean', booleanExpression);
      naiveTypeCheck('when', 'second', 'function', then_builder);
      if (else_builder) {
        naiveTypeCheck('when', 'third', 'function', else_builder);
      }

      if (booleanExpression) {
        then_builder(this);
      } else if (else_builder) {
        else_builder(this);
      }
      return this;
    },
    while(
      predicate: (i: number) => boolean,
      builder: (ctx: IBuilderCTX, i: number) => void,
    ) {
      naiveTypeCheck('while', 'first', 'function', predicate);
      naiveTypeCheck('while', 'second', 'function', builder);

      for (let i = 0; predicate(i); i++) {
        builder(this, i);
      }
      return this;
    },
    do(...builders: BuilderCB[]) {
      builders.forEach((builder) => {
        /* istanbul ignore if */
        if (typeof builder !== 'function') {
          throw new TypeError(
            `Brynja: Expected all arguments of "do" operation to be functions, but received ${typeof builder}`,
          );
        }

        builder(this);
      });
      return this;
    },
    value(value: any) {
      ctx.value = value;
      return this;
    },
    text(value: any) {
      serializableTypeCheck('text', 'first', value);
      ctx.text = String(value);
      return this;
    },
    prop(key: string, value: any) {
      naiveTypeCheck('prop', 'first', 'string', key);
      serializableTypeCheck('prop', 'second', value);

      ctx.props[key] = String(value);
      return this;
    },
    id(value: string) {
      naiveTypeCheck('id', 'first', 'string', value);
      ctx.props.id = value;
      return this;
    },
    class(...valuesArr: string[]) {
      valuesArr.forEach((className) => {
        /* istanbul ignore if */
        if (typeof className !== 'string') {
          throw new TypeError(
            `Brynja: Expected all arguments of "class" operation to be strings, but received ${typeof className}`,
          );
        }
      });

      if (!('class' in ctx.props)) {
        ctx.props.class = valuesArr.join(' ');
      } else {
        ctx.props.class = [...ctx.props.class.split(' '), ...valuesArr].join(
          ' ',
        );
      }
      return this;
    },
    name(value: string) {
      naiveTypeCheck('name', 'first', 'string', value);

      ctx.props.name = value;
      return this;
    },
    peek(callback) {
      naiveTypeCheck('peek', 'first', 'function', callback);

      function ctxProxy(ctx: VNode): VNode {
        return {
          tag: ctx.tag,
          text: ctx.text,
          value: ctx.value,
          props: ctx.props,
          events: ctx.events,
          children: new Proxy([], {
            get: (__, key: any) => {
              /* istanbul ignore else */
              if (key === 'length') {
                return ctx.children.length;
              } else if (!isNaN(parseFloat(key.toString()))) {
                return ctxProxy(ctx.children[key as number]);
              } else {
                throw new Error('Brynja: Illegal operation');
              }
            },
          }),
        };
      }
      callback(ctxProxy(ctx));
      return this;
    },
  };
  builder(builderCtx);
  return [ctx, styles];
}