UNPKG

views-morph

Version:
636 lines (532 loc) 15.8 kB
const { getViewNotFound, isViewNameRestricted, morph, morphFont, parse, } = require('./lib.js') const chalk = require('chalk') const chokidar = require('chokidar') const clean = require('./clean.js') const ensureBaseCss = require('./ensure-base-css.js') const flatten = require('flatten') const fs = require('mz/fs') const glob = require('fast-glob') const morphInlineSvg = require('./morph/inline-svg.js') const path = require('path') const toPascalCase = require('to-pascal-case') const uniq = require('array-uniq') const FONT_TYPES = { '.otf': 'opentype', '.eot': 'eot', '.svg': 'svg', '.ttf': 'truetype', '.woff': 'woff', '.woff2': 'woff2', } const isMorphedView = f => /\.view\.js$/.test(f) const isJs = f => path.extname(f) === '.js' const isLogic = f => /\.view\.logic\.js$/.test(f) const isView = f => path.extname(f) === '.view' || /\.view\.fake$/.test(f) const isFont = f => Object.keys(FONT_TYPES).includes(path.extname(f)) const getFontFileId = file => path.basename(file).split('.')[0] const relativise = (from, to) => { const r = path.relative(from, to) return r.substr(r.startsWith('../..') ? 3 : 1) } const onMorphWriteFile = ({ as, file, code }) => fs.writeFile(`${file}${as === 'e2e' ? '.page.js' : '.js'}`, code) module.exports = options => { return new Promise(async (resolve, reject) => { let { as, clean: shouldClean, compile, debug, enableAnimated, fake: shouldIncludeFake, isBundlingBaseCss, logic: shouldIncludeLogic, map, onMorph, onRemove, once, pretty, src, track, verbose, viewNotFound, } = Object.assign( { as: 'react-dom', clean: true, compile: false, debug: false, enableAnimated: true, fake: false, isBundlingBaseCss: false, logic: true, map: {}, onMorph: onMorphWriteFile, once: false, pretty: true, src: process.cwd(), track: true, verbose: true, }, options ) if (shouldClean) { clean(src) } if (!viewNotFound) viewNotFound = name => { const warning = `${src}/${name}.view doesn't exist but it is being used. Create the file!` verbose && console.log(chalk.magenta(`! ${warning}`)) return getViewNotFound(as, name, warning) } const jsComponents = {} const isJsComponent = f => { if (jsComponents.hasOwnProperty(f)) return jsComponents[f] let is = false try { // TODO async const filePath = path.join(src, f) const content = fs.readFileSync(filePath, 'utf-8') is = /\/\/ @view/.test(content) } catch (err) {} return (jsComponents[f] = is) } const isDirectory = f => { try { // TODO async return fs.statSync(f).isDirectory() } catch (err) { return false } } const filter = fn => (f, a, b) => { if ( isMorphedView(f, a, b) || (isJs(f) && !isJsComponent(f) && !isLogic(f)) || (!shouldIncludeLogic && isLogic(f)) || isDirectory(f) || isFont(f) ) return return fn(f, a, b) } const getImportFileName = (name, file) => { let f = views[name] if (isView(f)) { const logicFile = logic[`${name}.view.logic`] if (logicFile) f = logicFile } const ret = relativise(file, f) return isJs(ret) ? ret.replace(/\.js$/, '') : `${ret}.js` } const addFont = file => { const id = getFontFileId(file) const type = FONT_TYPES[path.extname(file)] if ( instance.customFonts.some(font => font.id === id && font.type === type) ) return instance.customFonts.push({ file, relativeFile: file.replace('Fonts/', './'), id: getFontFileId(file), type, }) } const removeFont = file => { const id = getFontFileId(file) instance.customFonts = instance.customFonts.filter(font => font.id !== id) } const fonts = {} const makeGetFont = (view, file) => { return font => { if (!fonts[font.id]) { fonts[font.id] = `Fonts/${font.id}.js` fs.writeFileSync( path.join(src, fonts[font.id]), morphFont({ as, font, files: instance.customFonts }) ) } return relativise(file, fonts[font.id]) } } const makeGetImport = (view, file) => { dependsOn[view] = [] return name => { if (name === 'ViewsBaseCss') { return isBundlingBaseCss ? `import '${relativise(file, instance.baseCss)}'` : '' } if (!dependsOn[view].includes(name)) { dependsOn[view].push(name) } // TODO track dependencies to make it easy to rebuild files as new ones get // added, eg logic is added, we need to rebuild upwards return views[name] ? `import ${name} from '${getImportFileName(name, file)}'` : viewNotFound(name) } } const dependsOn = {} const responsibleFor = {} const logic = {} const views = Object.assign({}, map) const viewsSources = {} const viewsParsed = {} const instance = { customFonts: [], dependsOn, responsibleFor, logic, views, stop() {}, } if (as === 'react-dom' && isBundlingBaseCss) { instance.baseCss = 'ViewsBaseCss.js' ensureBaseCss(path.join(src, instance.baseCss)) } const addView = filter((f, skipMorph = false) => { const { file, view } = toViewPath(f) if (isViewNameRestricted(view, as)) { verbose && console.log( chalk.magenta('X'), view, chalk.dim(`-> ${f}`), 'is a Views reserved name. To fix this, change its file name to something else.' ) return } if (views[view]) { console.log( chalk.magenta('X'), chalk.dim(`-> ${f}`), `This view will not be morphed as a view with the name ${view} already exists. If you did intend to morph this view please give it a unique name.` ) return } if (isJsComponent(f) && shouldIncludeFake) { return maybeFakeJs(f, file, view) } verbose && console.log(chalk.yellow('A'), view, chalk.dim(`-> ${f}`)) let shouldMorph = isView(file) if (isLogic(file)) { logic[view] = file if (viewsLeftToBeReady === 0) { remorphDependenciesFor(view) } } else { views[view] = file } if (shouldMorph) { if (skipMorph) { return f } else { morphView(f) } } }) const makeResponsibleFor = () => { Object.keys(views).forEach(updateResponsibleFor) } const maybeFakeJs = (f, file, view) => { const fakeView = `${view}.view.fake` if (!(isJsComponent(f) && shouldIncludeFake && !views[view])) return const fakeFile = path.join(path.dirname(f), fakeView) // TODO async if (fs.existsSync(path.join(src, fakeFile))) return // TODO async fs.writeFileSync( path.join(src, fakeFile), `${view}Fake Vertical backgroundColor rgba(53,63,69,0.5) width 50 height 50` ) console.log(chalk.green('🐿 '), view, chalk.dim(`-> ${fakeFile}`)) return fakeFile } const maybeIsReady = () => { const isReady = viewsLeftToBeReady === 0 if (isReady) return true if (viewsLeftToBeReady > 0) { viewsLeftToBeReady-- if (viewsLeftToBeReady === 0) { makeResponsibleFor() resolve(instance) } } } const getPointsOfUseFor = view => Object.keys(dependsOn).filter(dep => dependsOn[dep].includes(view)) const updateResponsibleFor = viewRaw => { if (as === 'e2e') return const view = viewRaw.split('.')[0] const list = [] const left = getPointsOfUseFor(view) while (left.length > 0) { const next = left.pop() if (!list.includes(next)) { list.push(next) getPointsOfUseFor(next).forEach(dep => left.push(dep)) } } responsibleFor[view] = uniq(flatten(list)) return responsibleFor[view] } const addViewSkipMorph = f => addView(f, true) const getViewSource = async f => { const { view } = toViewPath(f) try { const rawFile = path.join(src, f) const source = await fs.readFile(rawFile, 'utf-8') const parsed = parse({ source }) viewsSources[view] = source viewsParsed[view] = parsed if (parsed.warnings.length > 0) { console.error( chalk.red(view), chalk.dim(path.resolve(src, views[view])) ) parsed.warnings.forEach(warning => { console.error( ` ${chalk.blue(warning.type)} ${chalk.yellow( `line ${warning.loc.start.line}` )} ${warning.line}` ) }) } } catch (error) { verbose && console.error(chalk.red('M'), view, error) } } let toMorphQueue = null const morphView = filter(async (f, skipRemorph, skipSource) => { const { file, view } = toViewPath(f) if (isViewNameRestricted(view, as)) { verbose && console.log( chalk.magenta('X'), view, chalk.dim(`-> ${f}`), 'is a Views reserved name. To fix this, change its file name to something else.' ) return } if (isJs(f)) return const getFont = makeGetFont(view, file) const getImport = makeGetImport(view, file) let calledMaybeIsReady = false try { const rawFile = path.join(src, f) if (!skipSource) { await getViewSource(f) } const res = morph({ as, compile, debug, enableAnimated, file: { raw: rawFile, relative: file }, name: view, getFont, getImport, pretty, track, views: viewsParsed, }) const toMorph = { as, code: res.code, dependsOn: dependsOn[view], // responsibleFor: responsibleFor[view], file: rawFile, fonts: res.fonts, slots: res.slots, source: viewsSources[view], view, } if (maybeIsReady()) { calledMaybeIsReady = true // TODO revisit effect of rawView vs view here updateResponsibleFor(view) toMorph.responsibleFor = responsibleFor[view] if (toMorphQueue === null) { toMorphQueue = [] } toMorphQueue.push(toMorph) if (!skipRemorph) { await remorphDependenciesFor(view) await Promise.all(toMorphQueue.map(onMorph)) toMorphQueue = null } } else { await onMorph(toMorph) } if (Array.isArray(res.svgs)) { await Promise.all( res.svgs.map(async svg => { const svgFile = path.resolve(rawFile, '..', svg.source) try { const inlined = await morphInlineSvg(svgFile) // TODO revisit as most of the options don't matter here const res = morph({ as, compile, debug, enableAnimated, file: { raw: rawFile, relative: file }, name: svg.view, getImport, pretty, track, views: { [svg.view]: inlined, }, }) await onMorph({ as, code: res.code, isInlineSvg: true, file: path.resolve(rawFile, '..', `${svg.view}.view`), view, }) } catch (error) { console.error( chalk.magenta('M'), `${view}. Can't morph inline ${svgFile}` ) } }) ) } verbose && console.log(chalk.green('M'), view) } catch (error) { verbose && console.error(chalk.red('M'), view, error.codeFrame || error) if (!calledMaybeIsReady) { maybeIsReady() } } }) const remorphDependenciesFor = async viewRaw => { const view = viewRaw.split('.')[0] await Promise.all( responsibleFor[view].map(dep => { return morphView(views[dep], true) }) ) } const toViewPath = f => { const file = f.replace(/(\.ios|\.android|\.web)/, '') let view = path.basename(file) if (isLogic(file)) { view = view.replace(/\.js/, '') } else { view = toPascalCase(view.replace(/\.(view\.fake|js|view)/, '')) } return { file: `./${file}`, view, } } const removeView = filter(f => { const { view } = toViewPath(f) if (isViewNameRestricted(view, as)) return verbose && console.log(chalk.blue('D'), view) if (isLogic(f)) { delete logic[view] } else { delete views[view] delete viewsSources[view] delete viewsParsed[view] } if (typeof onRemove === 'function') { onRemove(view) } updateResponsibleFor(view) remorphDependenciesFor(view) delete dependsOn[view] }) const watcherOptions = { bashNative: ['linux'], cwd: src, ignore: ['**/node_modules/**', '**/*.view.js'], } const watcherPattern = [ `**/*.js`, `**/*.view`, shouldIncludeLogic && `**/*.view.logic.js`, shouldIncludeFake && `**/*.view.fake`, // fonts, 'Fonts/*.eot', 'Fonts/*.otf', 'Fonts/*.ttf', 'Fonts/*.svg', 'Fonts/*.woff', 'Fonts/*.woff2', ].filter(Boolean) const fontsDirectory = path.join(src, 'Fonts') if (!await fs.exists(fontsDirectory)) { await fs.mkdir(fontsDirectory) } const customFonts = await glob( [ // fonts, 'Fonts/*.eot', 'Fonts/*.otf', 'Fonts/*.ttf', 'Fonts/*.svg', 'Fonts/*.woff', 'Fonts/*.woff2', ], watcherOptions ) customFonts.forEach(addFont) console.log( 'Custom fonts:\n', instance.customFonts.map(f => f.file).join(',\n'), '\n' ) let viewsLeftToBeReady = null const listToMorph = await glob(watcherPattern, watcherOptions) const viewsToMorph = listToMorph.map(addViewSkipMorph).filter(Boolean) await Promise.all(viewsToMorph.map(getViewSource)) viewsLeftToBeReady = viewsToMorph.length viewsToMorph.forEach(v => morphView(v, false, true)) if (!once) { const watcher = chokidar.watch(watcherPattern, { cwd: src, ignored: /(node_modules|\.view.js)/, ignoreInitial: true, }) if (verbose) { watcher.on('error', console.error.bind(console)) } instance.stop = () => watcher.close() watcher.on('add', f => { if (isFont(f)) { addFont(f) } else { addView(f) } }) watcher.on('change', f => { morphView(f) }) watcher.on('unlink', f => { if (isFont(f)) { removeFont(f) } else { removeView(f) } }) } }) }