UNPKG

halfcab

Version:

A simple universal JavaScript framework focused on making use of es2015 template strings to build components.

480 lines (413 loc) 12.4 kB
import shiftyRouterModule from 'shifty-router' import hrefModule from 'shifty-router/href' import historyModule from 'shifty-router/history' import createLocation from 'shifty-router/create-location' import bel from 'nanohtml' import update from 'nanomorph' import axios from 'axios' import cssInject from 'csjs-inject' import merge from 'deepmerge' import marked from 'marked' import htmlEntities from 'html-entities' import eventEmitter from './eventEmitter' import qs from 'qs' import LRU from 'nanolru' import Component from 'nanocomponent' import * as deepDiff from 'deep-object-diff' import clone from 'fast-clone' const {AllHtmlEntities} = htmlEntities const cache = LRU(5000) let entities = new AllHtmlEntities() let cssTag = cssInject let componentCSSString = '' let routesArray = [] let externalRoutes = [] let state = {} let router let rootEl let components let dataInitial let el marked.setOptions({ breaks: true }) function b64DecodeUnicode (str) { // Going backwards: from bytestream, to percent-encoding, to original string. return decodeURIComponent(atob(str).split('').map(function (c) { return '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2) }).join('')) } if (typeof window !== 'undefined') { dataInitial = document.querySelector('[data-initial]') if (!!dataInitial) { state = (dataInitial && dataInitial.dataset.initial) && Object.assign({}, JSON.parse(b64DecodeUnicode(dataInitial.dataset.initial))) if (!state.router) { state.router = {} } if (!state.router.pathname) { Object.assign(state.router, { pathname: window.location.pathname, hash: window.location.hash, query: qs.parse(window.location.search) }) } } } else { cssTag = (cssStrings, ...values) => { let output = cssInject(cssStrings, ...values) componentCSSString += componentCSSString.indexOf(output[' css ']) === -1 ? output[' css '] : '' return output } } let geb = new eventEmitter({state}) let html = (strings, ...values) => { // fix for allowing csjs to coexist with nanohtml SSR values = values.map(value => { if (value && value.hasOwnProperty('toString')) { return value.toString() } return value }) return bel(strings, ...values) } function ssr (rootComponent) { let componentsString = `${rootComponent}` return {componentsString, stylesString: componentCSSString} } function defineRoute (routeObject) { if (routeObject.external) { let foundRoute = externalRoutes.findIndex(route => route.path === routeObject.path) if(foundRoute !== -1){ externalRoutes[foundRoute] = routeObject } else { externalRoutes.push(routeObject.path) } return } let foundRoute = routesArray.findIndex(route => route.path === routeObject.path) if(foundRoute !== -1){ routesArray[foundRoute] = routeObject } else { routesArray.push(routeObject) } } function formField (ob, prop) { return e => { ob[prop] = e.currentTarget.type === 'checkbox' || e.currentTarget.type === 'radio' ? e.currentTarget.checked : e.currentTarget.value let validOb let touchedOb if (!ob.valid) { if (Object.getOwnPropertySymbols(ob).length > 0) { Object.getOwnPropertySymbols(ob).forEach(symb => { if (symb.toString().indexOf('Symbol(valid)') === 0 && ob[symb]) { validOb = symb } }) } else { ob.valid = {} validOb = 'valid' } } else { validOb = 'valid' } Object.getOwnPropertySymbols(ob).forEach(symb => { if (symb.toString().indexOf('Symbol(touched)') === 0 && ob[symb]) { touchedOb = symb } }) if (touchedOb) { if (!ob[touchedOb][prop]) { ob[touchedOb][prop] = true stateUpdated() } } ob[validOb][prop] = e.currentTarget.validity.valid console.log('---formField update---') console.log(prop, ob) console.log(`Valid? ${ob[validOb][prop]}`) } } function formIsValid (holidingPen) { let validProp = holidingPen.valid && 'valid' if (!validProp) { Object.getOwnPropertySymbols(holidingPen).forEach(symb => { if (symb.toString().indexOf('Symbol(valid)') === 0 && holidingPen[symb]) { validProp = symb } }) if (!validProp) { return false } } let validOb = Object.keys(holidingPen[validProp]) for (let i = 0; i < validOb.length; i++) { if (holidingPen[validProp][validOb[i]] !== true) { return false } } return true } function fieldIsTouched (holidingPen, property) { let touchedProp Object.getOwnPropertySymbols(holidingPen).forEach(symb => { if (symb.toString().indexOf('Symbol(touched)') === 0 && holidingPen[symb]) { touchedProp = symb } }) if (!touchedProp) { return false } return !!holidingPen[touchedProp][property] } function resetTouched (holidingPen) { let touchedProp Object.getOwnPropertySymbols(holidingPen).forEach(symb => { if (symb.toString() === 'Symbol(touched)') { touchedProp = symb } }) if (!touchedProp) { return } for (let prop in holidingPen[touchedProp]) { holidingPen[touchedProp][prop] = false } stateUpdated() } let waitingAlready = false function debounce (func) { if (!waitingAlready) { waitingAlready = true nextTick(() => { func() waitingAlready = false }) } } function nextTick (func) { if (typeof window !== 'undefined' && window.requestAnimationFrame) { window.requestAnimationFrame(func) } else { setTimeout(func, 17) } } function stateUpdated () { if (rootEl) { let startTime = Date.now() let newEl = components(state) console.log(`Component render: ${Date.now() - startTime}`) startTime = Date.now() update(rootEl, newEl) console.log(`DOM morph: ${Date.now() - startTime}`) } } function updateState (updateObject, options) { if (updateObject) { if (options && options.deepMerge === false) { Object.assign(state, updateObject) } else { let deepMergeOptions = {clone: false} if (options && options.arrayMerge === false) { deepMergeOptions.arrayMerge = (destinationArray, sourceArray, options) => { //don't merge arrays, just return the new one return sourceArray } } Object.assign(state, merge(state, updateObject, deepMergeOptions)) } } if (options && options.rerender === false) { return state } debounce(stateUpdated) if (process.env.NODE_ENV !== 'production') { console.log('------STATE UPDATE------') console.log(updateObject) console.log(' ') console.log('------NEW STATE------') console.log(state) console.log(' ') } return state } function emptySSRVideos (c) { //SSR videos with source tags don't like morphing and you get double audio, // so remove src from the new one so it never starts let autoplayTrue = c.querySelectorAll('video[autoplay="true"]') let autoplayAutoplay = c.querySelectorAll('video[autoplay="autoplay"]') let autoplayOn = c.querySelectorAll('video[autoplay="on"]') let selectors = [autoplayTrue, autoplayAutoplay, autoplayOn] selectors.forEach(selector => { Array.from(selector).forEach(video => { video.pause() Array.from(video.childNodes).forEach(source => { source.src && (source.src = '') }) }) }) } function injectHTML (htmlString, options) { if (options && options.wrapper === false) { return html([htmlString]) } return html([`<div>${htmlString}</div>`]) // using html as a regular function instead of a tag function, and prevent double encoding of ampersands while we're at it } function injectMarkdown (mdString, options) { return injectHTML(entities.decode(marked(mdString)), options) //using html as a regular function instead of a tag function, and prevent double encoding of ampersands while we're at it } function gotoRoute (route) { let {pathname, hash, search, href} = createLocation({}, route) //if pathname doesn't begin with a /, add one if (pathname && pathname.indexOf('/') !== 0) { pathname = `/${pathname}` } let component = router(route, {pathname, hash, search, href}) updateState({ router: { component } }) } function getRouteComponent (pathname) { let foundRoute = routesArray.find(route => route.key === pathname || route.path === pathname) return foundRoute && foundRoute.component } function getSymbol (ob, symbolName) { let symbols = Object.getOwnPropertySymbols(ob) if (symbols.length) { return symbols.find(symb => symb.toString() .includes(`Symbol(${symbolName})`)) } } function addToHoldingPen (holdingPen, addition) { let currentValid = holdingPen[getSymbol(holdingPen, 'valid')] let currentTouched = holdingPen[getSymbol(holdingPen, 'touched')] let additionValid = addition[getSymbol(addition, 'valid')] let additionTouched = addition[getSymbol(addition, 'touched')] let additionWithoutSymbols = {} Object.keys(addition).forEach(ad => { additionWithoutSymbols[ad] = addition[ad] }) Object.assign(currentValid, additionValid) Object.assign(currentTouched, additionTouched) Object.assign(holdingPen, additionWithoutSymbols) } function removeFromHoldingPen (holdingPen, removal) { let currentValid = holdingPen[getSymbol(holdingPen, 'valid')] let currentTouched = holdingPen[getSymbol(holdingPen, 'touched')] removal.forEach(key => { delete currentValid[key] delete currentTouched[key] delete holdingPen[key] }) } export default (config, {shiftyRouter = shiftyRouterModule, href = hrefModule, history = historyModule} = {}) => { //this default function is used for setting up client side and is not run on // the server ({components, el} = config) return new Promise((resolve, reject) => { let routesFormatted = routesArray.map(r => [ r.path, (params, parts) => { r.callback && r.callback(Object.assign({}, parts, {params}), state) if (parts && window.location.pathname !== parts.pathname) { window.history.pushState({href: parts.href}, r.title, parts.href) } updateState({ router: { pathname: parts.pathname, hash: parts.hash, query: qs.parse(parts.search), params, key: r.key || r.path, href: location.href, component: null } }) document.title = r.title || '' return r.component } ]) router = shiftyRouter({default: '/404'}, routesFormatted) href(location => { if (externalRoutes.includes(location.pathname)) { window.location = location.pathname return } gotoRoute(location.href) }) history(location => { gotoRoute(location.href) }) let c = components(state)//root element generated by components if (el) { emptySSRVideos(c) let r = document.querySelector(el) rootEl = update(r, c) return resolve({rootEl, state}) } rootEl = c resolve({rootEl, state})//if no root element provided, just return the root // component and the state }) } function rerender () { debounce(stateUpdated) } class PureComponent extends Component { createElement (args) { this.args = clone(args) super.createElement(args) } update (args) { let diff = deepDiff.diff(this.args, args) Object.keys(diff).forEach(key => { if (typeof diff[key] === 'function') { this[key] = args[key] } }) return !!Object.keys(diff).find(key => typeof diff[key] !== 'function') } } function cachedComponent (Class, args, id) { let instance if (id) { let found = cache.get(id) if (found) { instance = found } else { instance = new Class() cache.set(id, instance) } return instance.render(args) } else { instance = new Class() return instance.createElement(args) } } export { getRouteComponent, rerender, formIsValid, ssr, injectHTML, injectMarkdown, geb, eventEmitter, html, defineRoute, updateState, formField, gotoRoute, cssTag as css, axios as http, fieldIsTouched, resetTouched, nextTick, addToHoldingPen, removeFromHoldingPen, Component, LRU, cachedComponent, PureComponent }