diff --git a/.eslintrc b/.eslintrc index d6b5f88bf..07e17b382 100644 --- a/.eslintrc +++ b/.eslintrc @@ -9,6 +9,7 @@ "window": false, "document": false }, + "root": true, "rules": { "comma-style": [ 2, @@ -33,7 +34,9 @@ "no-cond-assign": [ 2, "except-parens" - ] + ], + "no-unused-vars": ["off", { "argsIgnorePattern": "^_" }], + "@typescript-eslint/no-unused-vars": ["error", { "argsIgnorePattern": "^_" }] }, "overrides": [ { @@ -47,6 +50,40 @@ { "files": ["test/**/*.js"], "env": {"mocha": true, "node": true} + }, + { + "files": ["src/**/*.ts"], + "parser": "@typescript-eslint/parser", + "plugins": ["@typescript-eslint", "prettier", "import"], + "extends": [ + "eslint:recommended", + "plugin:@typescript-eslint/eslint-recommended", + "plugin:@typescript-eslint/recommended", + "plugin:import/recommended", + "plugin:import/typescript" + ], + "rules": { + "@typescript-eslint/no-explicit-any": ["warn", { "ignoreRestArgs": false }], + "import/no-unresolved": "error", + "import/order": [ + "error", + { + "groups": [ + "builtin", + "external", + "internal", + ["sibling", "parent"], + "index", + "unknown" + ], + "newlines-between": "always", + "alphabetize": { + "order": "asc", + "caseInsensitive": true + } + } + ] + } } ] } diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 52ed55ca8..4dcf1a832 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -17,9 +17,9 @@ jobs: strategy: matrix: node: - - 14 - 16 - 18 + - 20 timeout-minutes: 10 steps: - uses: actions/checkout@v2 diff --git a/.gitignore b/.gitignore index ef7d85ff1..6967f2a90 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,4 @@ *.swp node_modules .vscode +dist/ diff --git a/index.js b/index.js deleted file mode 100644 index dfac0a173..000000000 --- a/index.js +++ /dev/null @@ -1,3 +0,0 @@ -var serverRequire = require('racer').util.serverRequire; -var Derby = serverRequire(module, './lib/DerbyForServer') || require('./lib/Derby'); -module.exports = new Derby(); diff --git a/lib/App.js b/lib/App.js deleted file mode 100644 index 6120aec4b..000000000 --- a/lib/App.js +++ /dev/null @@ -1,417 +0,0 @@ -/* - * App.js - * - * Provides the glue between views, controllers, and routes for an - * application's functionality. Apps are responsible for creating pages. - * - */ - -var path = require('path'); -var EventEmitter = require('events').EventEmitter; -var tracks = require('tracks'); -var util = require('racer/lib/util'); -var derbyTemplates = require('./templates'); -var templates = derbyTemplates.templates; -var components = require('./components'); -var PageBase = require('./Page'); - -module.exports = App; - -// TODO: Change to Map once we officially drop support for ES5. -global.APPS = global.APPS || {}; - -function App(derby, name, filename, options) { - EventEmitter.call(this); - this.derby = derby; - this.name = name; - this.filename = filename; - this.scriptHash = process.env.DERBY_SCRIPT_HASH || '{{DERBY_SCRIPT_HASH}}'; - this.bundledAt = process.env.DERBY_BUNDLED_AT || '{{DERBY_BUNDLED_AT}}'; - this.buildVersion = process.env.DERBY_BUILD_VERSION; - this.Page = createAppPage(derby); - this.proto = this.Page.prototype; - this.views = new templates.Views(); - this.tracksRoutes = tracks.setup(this); - this.model = null; - this.page = null; - this._pendingComponentMap = {}; - this._init(options); -} - -function createAppPage(derby) { - var Page = (derby && derby.Page) || PageBase; - // Inherit from Page/PageForServer so that we can add controller functions as prototype - // methods on this app's pages - function AppPage() { - Page.apply(this, arguments); - } - AppPage.prototype = Object.create(Page.prototype); - return AppPage; -} - -util.mergeInto(App.prototype, EventEmitter.prototype); - -// Overriden on server -App.prototype._init = function() { - this._waitForAttach = true; - this._cancelAttach = false; - this.model = new this.derby.Model(); - var serializedViews = this._views(); - serializedViews(derbyTemplates, this.views); - // Must init async so that app.on('model') listeners can be added. - // Must also wait for content ready so that bundle is fully downloaded. - this._contentReady(); -}; - -App.prototype._views = function () { - return require('./_views'); -} - -App.prototype._finishInit = function() { - var data = this._getAppData(); - util.isProduction = data.nodeEnv === 'production'; - - var previousAppInfo; - if (!util.isProduction) { - previousAppInfo = global.APPS[this.name]; - if (previousAppInfo) { - previousAppInfo.app._destroyCurrentPage(); - } - global.APPS[this.name] = { - app: this, - initialState: data, - }; - } - - this.model.createConnection(data); - this.emit('model', this.model); - - if (!util.isProduction) this._autoRefresh(); - - this.model.unbundle(data); - - var page = this.createPage(); - page.params = this.model.get('$render.params'); - this.emit('ready', page); - - this._waitForAttach = false; - // Instead of attaching, do a route and render if a link was clicked before - // the page finished attaching, or if this is a new app from hot reload. - if (this._cancelAttach || previousAppInfo) { - this.history.refresh(); - return; - } - // Since an attachment failure is *fatal* and could happen as a result of a - // browser extension like AdBlock, an invalid template, or a small bug in - // Derby or Saddle, re-render from scratch on production failures - if (util.isProduction) { - try { - page.attach(); - } catch (err) { - this.history.refresh(); - console.warn('attachment error', err.stack); - } - } else { - page.attach(); - } - this.emit('load', page); -}; - -App.prototype._getAppData = function () { - var script = this._getAppStateScript(); - if (script) { - return App._parseInitialData(script.textContent); - } else { - return global.APPS[this.name].initialState; - } -} - -// Modified from: https://github.com/addyosmani/jquery.parts/blob/master/jquery.documentReady.js -App.prototype._contentReady = function() { - // Is the DOM ready to be used? Set to true once it occurs. - var isReady = false; - var app = this; - - // The ready event handler - function onDOMContentLoaded() { - if (document.addEventListener) { - document.removeEventListener('DOMContentLoaded', onDOMContentLoaded, false); - } else { - // we're here because readyState !== 'loading' in oldIE - // which is good enough for us to call the dom ready! - document.detachEvent('onreadystatechange', onDOMContentLoaded); - } - onDOMReady(); - } - - // Handle when the DOM is ready - function onDOMReady() { - // Make sure that the DOM is not already loaded - if (isReady) return; - // Make sure body exists, at least, in case IE gets a little overzealous (ticket #5443). - if (!document.body) return setTimeout(onDOMReady, 0); - // Remember that the DOM is ready - isReady = true; - // Make sure this is always async and then finishin init - setTimeout(function() { - app._finishInit(); - }, 0); - } - - // The DOM ready check for Internet Explorer - function doScrollCheck() { - if (isReady) return; - try { - // If IE is used, use the trick by Diego Perini - // http://javascript.nwbox.com/IEContentLoaded/ - document.documentElement.doScroll('left'); - } catch (err) { - setTimeout(doScrollCheck, 0); - return; - } - // and execute any waiting functions - onDOMReady(); - } - - // Catch cases where called after the browser event has already occurred. - if (document.readyState !== 'loading') return onDOMReady(); - - // Mozilla, Opera and webkit nightlies currently support this event - if (document.addEventListener) { - // Use the handy event callback - document.addEventListener('DOMContentLoaded', onDOMContentLoaded, false); - // A fallback to window.onload, that will always work - window.addEventListener('load', onDOMContentLoaded, false); - // If IE event model is used - } else if (document.attachEvent) { - // ensure firing before onload, - // maybe late but safe also for iframes - document.attachEvent('onreadystatechange', onDOMContentLoaded); - // A fallback to window.onload, that will always work - window.attachEvent('onload', onDOMContentLoaded); - // If IE and not a frame - // continually check to see if the document is ready - var toplevel; - try { - toplevel = window.frameElement == null; - } catch (err) {} - if (document.documentElement.doScroll && toplevel) { - doScrollCheck(); - } - } -}; - -App.prototype._getAppStateScript = function() { - return document.querySelector('script[data-derby-app-state]'); -}; - -App.prototype.use = util.use; -App.prototype.serverUse = util.serverUse; - -App.prototype.loadViews = function() {}; - -App.prototype.loadStyles = function() {}; - -// This function is overriden by requiring 'derby/parsing' -App.prototype.addViews = function() { - throw new Error( - 'Parsing not available. Registering a view from source should not be used ' + - 'in application code. Instead, specify a filename with view.file.' - ); -}; - -App.prototype.component = function(name, constructor, isDependency) { - if (typeof name === 'function') { - constructor = name; - name = null; - } - if (typeof constructor !== 'function') { - throw new Error('Missing component constructor argument'); - } - - var viewProp = constructor.view; - var viewIs, viewFilename, viewSource, viewDependencies; - // Always using an object for the static `view` property is preferred - if (viewProp && typeof viewProp === 'object') { - viewIs = viewProp.is; - viewFilename = viewProp.file; - viewSource = viewProp.source; - viewDependencies = viewProp.dependencies; - } else { - // Ignore other properties when `view` is an object. It is possible that - // properties could be inherited from a parent component when extending it. - // - // DEPRECATED: constructor.prototype.name and constructor.prototype.view - // use the equivalent static properties instead - viewIs = constructor.is || constructor.prototype.name; - viewFilename = constructor.view || constructor.prototype.view; - } - var viewName = name || viewIs || - (viewFilename && path.basename(viewFilename, '.html')); - - if (!viewName) { - throw new Error('No view specified for component'); - } - if (viewFilename && viewSource) { - throw new Error('Component may not specify both a view file and source'); - } - - // TODO: DRY. This is copy-pasted from ./templates - var mapName = viewName.replace(/:index$/, ''); - var currentView = this.views.nameMap[mapName]; - var currentConstructor = (currentView && currentView.componentFactory) ? - currentView.componentFactory.constructor : - this._pendingComponentMap[mapName]; - - // Avoid registering the same component twice; we want to avoid the overhead - // of loading view files from disk again. This is also what prevents - // circular dependencies from infinite looping - if (currentConstructor === constructor) return; - - // Calling app.component() overrides existing views or components. Prevent - // dependencies from doing this without warning - if (isDependency && currentView && !currentView.fromSerialized) { - throw new Error('Dependencies cannot override existing views. Already registered "' + viewName + '"'); - } - - // This map is used to prevent infinite loops from circular dependencies - this._pendingComponentMap[mapName] = constructor; - - // Recursively register component dependencies - if (viewDependencies) { - for (var i = 0; i < viewDependencies.length; i++) { - var dependency = viewDependencies[i]; - if (Array.isArray(dependency)) { - this.component(dependency[0], dependency[1], true); - } else { - this.component(null, dependency, true); - } - } - } - - // Register or find views specified by the component - var view; - if (viewFilename) { - this.loadViews(viewFilename, viewName); - view = this.views.find(viewName); - - } else if (viewSource) { - this.addViews(viewSource, viewName); - view = this.views.find(viewName); - - } else if (name) { - view = this.views.find(viewName); - - } else { - view = this.views.register(viewName, ''); - } - if (!view) { - var message = this.views.findErrorMessage(viewName); - throw new Error(message); - } - - // Inherit from Component - components.extendComponent(constructor); - // Associate the appropriate view with the component constructor - view.componentFactory = components.createFactory(constructor); - - delete this._pendingComponentMap[mapName]; - - // Make chainable - return this; -}; - -App.prototype.createPage = function() { - this._destroyCurrentPage(); - var page = new this.Page(this, this.model); - this.page = page; - return page; -}; - -App.prototype._destroyCurrentPage = function() { - if (this.page) { - this.emit('destroyPage', this.page); - this.page.destroy(); - } -}; - -App.prototype.onRoute = function(callback, page, next, done) { - if (this._waitForAttach) { - // Cancel any routing before the initial page attachment. Instead, do a - // render once derby is ready - this._cancelAttach = true; - return; - } - this.emit('route', page); - // HACK: To update render in transitional routes - page.model.set('$render.params', page.params); - page.model.set('$render.url', page.params.url); - page.model.set('$render.query', page.params.query); - // If transitional - if (done) { - var app = this; - var _done = function() { - app.emit('routeDone', page, 'transition'); - done(); - }; - callback.call(page, page, page.model, page.params, next, _done); - return; - } - callback.call(page, page, page.model, page.params, next); -}; - -App.prototype._autoRefresh = function() { - var app = this; - var connection = this.model.connection; - connection.on('connected', function() { - connection.send({ - derby: 'app', - name: app.name, - hash: app.scriptHash - }); - }); - connection.on('receive', function(request) { - if (request.data.derby) { - var message = request.data; - request.data = null; - app._handleMessage(message.derby, message); - } - }); -}; - -App.prototype._handleMessage = function(action, message) { - if (action === 'refreshViews') { - var fn = new Function('return ' + message.views)(); // jshint ignore:line - fn(derbyTemplates, this.views); - var ns = this.model.get('$render.ns'); - this.page.render(ns); - - } else if (action === 'refreshStyles') { - var styleElement = document.querySelector('style[data-filename="' + - message.filename + '"]'); - if (styleElement) styleElement.innerHTML = message.css; - - } else if (action === 'reload') { - this.model.whenNothingPending(function() { - window.location = window.location; - }); - } -}; - -App._parseInitialData = function _parseInitialData(jsonString) { - try { - return JSON.parse(jsonString); - } catch (error) { - var message = error.message || ''; - var match = message.match(/Unexpected token (.) in JSON at position (\d+)/); - if (match) { - var p = parseInt(match[2], 10); - var stringContext = jsonString.substring( - Math.min(0, p - 30), - Math.max(p + 30, jsonString.length - 1) - ); - throw new Error('Parse failure: ' + error.message + ' context: \'' + stringContext + '\''); - } - throw error; - } -}; diff --git a/lib/AppForServer.js b/lib/AppForServer.js deleted file mode 100644 index b96171db0..000000000 --- a/lib/AppForServer.js +++ /dev/null @@ -1,349 +0,0 @@ -/* - * App.server.js - * - * Application level functionality that is - * only applicable to the server. - * - */ - -var racer = require('racer'); -var util = racer.util; -var App = require('./App'); -var parsing = require('../parsing'); -var derbyTemplates = require('../templates'); - -// Avoid Browserifying these dependencies -var chokidar, crypto, files, fs, path, through; -if (module.require) { - chokidar = module.require('chokidar'); - crypto = module.require('crypto'); - files = module.require('./files'); - fs = module.require('fs'); - path = module.require('path'); - through = module.require('through'); -} - -var STYLE_EXTENSIONS = ['.css']; -var VIEW_EXTENSIONS = ['.html']; -var COMPILERS = { - '.css': cssCompiler, - '.html': htmlCompiler -}; -function cssCompiler(file, filename, options) { - return {css: file, files: [filename]}; -} -function htmlCompiler(file) { - return file; -} - -module.exports = AppForServer; - -function AppForServer(derby, name, filename, options) { - App.call(this, derby, name, filename, options); -} -AppForServer.prototype = Object.create(App.prototype); -AppForServer.prototype.constructor = AppForServer; - -AppForServer.prototype._init = function(options) { - this._initBundle(options); - this._initRefresh(); - this._initLoad(); - this._initViews(); -}; -AppForServer.prototype._initBundle = function(options) { - this.scriptFilename = null; - this.scriptMapFilename = null; - this.scriptBaseUrl = (options && options.scriptBaseUrl) || ''; - this.scriptMapBaseUrl = (options && options.scriptMapBaseUrl) || ''; - this.scriptCrossOrigin = (options && options.scriptCrossOrigin) || false; - this.scriptUrl = null; - this.scriptMapUrl = null; -}; -AppForServer.prototype._initRefresh = function() { - this.watchFiles = !util.isProduction; - this.agents = null; -}; -AppForServer.prototype._initLoad = function() { - this.styleExtensions = STYLE_EXTENSIONS.slice(); - this.viewExtensions = VIEW_EXTENSIONS.slice(); - this.compilers = util.copyObject(COMPILERS); -}; -AppForServer.prototype._initViews = function() { - this.serializedDir = path.dirname(this.filename || '') + '/derby-serialized'; - this.serializedBase = this.serializedDir + '/' + this.name; - if (fs.existsSync(this.serializedBase + '.json')) { - this.deserialize(); - this.loadViews = function() {}; - this.loadStyles = function() {}; - return; - } - - this.views.register('Page', - '' + - '' + - '' + - '' + - '' + - '', - {serverOnly: true} - ); - this.views.register('TitleElement', - '<view is="{{$render.prefix}}Title"></view>' - ); - this.views.register('BodyElement', - '' + - '' - ); - this.views.register('Title', 'Derby App'); - this.views.register('Styles', '', {serverOnly: true}); - this.views.register('Head', '', {serverOnly: true}); - this.views.register('Body', ''); - this.views.register('Tail', ''); -}; - -AppForServer.prototype.createPage = function(req, res, next) { - var model = req.model || new racer.Model(); - this.emit('model', model); - var page = new this.Page(this, model, req, res); - if (next) { - model.on('error', function(err){ - model.hasErrored = true; - next(err); - }); - page.on('error', next); - } - return page; -}; - -AppForServer.prototype.bundle = function(backend, options, cb) { - throw new Error( - 'bundle implementation missing; use racer-bundler for implementation, or remove call to this method and use another bundler', - ); -}; - -AppForServer.prototype.writeScripts = function(backend, dir, options, cb) { - throw new Error( - 'writeScripts implementation missing; use racer-bundler for implementation, or remove call to this method and use another bundler', - ); -}; - -AppForServer.prototype._viewsSource = function(options) { - return `/*DERBY_SERIALIZED_VIEWS ${this.name}*/\n` + - 'module.exports = ' + this.views.serialize(options) + ';\n' + - `/*DERBY_SERIALIZED_VIEWS_END ${this.name}*/\n`; -}; - -AppForServer.prototype.serialize = function() { - if (!fs.existsSync(this.serializedDir)) { - fs.mkdirSync(this.serializedDir); - } - // Don't minify the views (which doesn't include template source), since this - // is for use on the server - var viewsSource = this._viewsSource({server: true, minify: true}); - fs.writeFileSync(this.serializedBase + '.views.js', viewsSource, 'utf8'); - var scriptUrl = (this.scriptUrl.indexOf(this.scriptBaseUrl) === 0) ? - this.scriptUrl.slice(this.scriptBaseUrl.length) : - this.scriptUrl; - var scriptMapUrl = (this.scriptMapUrl.indexOf(this.scriptMapBaseUrl) === 0) ? - this.scriptMapUrl.slice(this.scriptMapBaseUrl.length) : - this.scriptMapUrl; - var serialized = JSON.stringify({ - scriptBaseUrl: this.scriptBaseUrl, - scriptMapBaseUrl: this.scriptMapBaseUrl, - scriptUrl: scriptUrl, - scriptMapUrl: scriptMapUrl - }); - fs.writeFileSync(this.serializedBase + '.json', serialized, 'utf8'); -}; - -AppForServer.prototype.deserialize = function() { - var serializedViews = require(this.serializedBase + '.views.js'); - var serialized = require(this.serializedBase + '.json'); - serializedViews(derbyTemplates, this.views); - this.scriptUrl = (this.scriptBaseUrl || serialized.scriptBaseUrl) + serialized.scriptUrl; - this.scriptMapUrl = (this.scriptMapBaseUrl || serialized.scriptMapBaseUrl) + serialized.scriptMapUrl; -}; - -AppForServer.prototype.loadViews = function(filename, namespace) { - var data = files.loadViewsSync(this, filename, namespace); - parsing.registerParsedViews(this, data.views); - if (this.watchFiles) this._watchViews(data.files, filename, namespace); - // Make chainable - return this; -}; - -AppForServer.prototype.loadStyles = function(filename, options) { - this._loadStyles(filename, options); - var stylesView = this.views.find('Styles'); - stylesView.source += ''; - // Make chainable - return this; -}; - -AppForServer.prototype._loadStyles = function(filename, options) { - var styles = files.loadStylesSync(this, filename, options); - - var filepath = ''; - if (this.watchFiles) { - /** - * Mark the path to file as an attribute - * Used in development to add event watchers and autorefreshing of styles - * SEE: local file, method this._watchStyles - * SEE: file ./App.js, method App._autoRefresh() - */ - filepath = ' data-filename="' + filename + '"'; - } - var source = '' + styles.css + ''; - - this.views.register(filename, source, { - serverOnly: true - }); - - if (this.watchFiles) { - this._watchStyles(styles.files, filename, options); - } - - return styles; -}; - -AppForServer.prototype._watchViews = function(filenames, filename, namespace) { - var app = this; - watchOnce(filenames, function() { - app.loadViews(filename, namespace); - app._updateScriptViews(); - app._refreshClients(); - }); -}; - -AppForServer.prototype._watchStyles = function(filenames, filename, options) { - var app = this; - watchOnce(filenames, function() { - var styles = app._loadStyles(filename, options); - app._updateScriptViews(); - app._refreshStyles(filename, styles); - }); -}; - -AppForServer.prototype._watchBundle = function(filenames) { - if (!process.send) return; - var app = this; - watchOnce(filenames, function() { - process.send({type: 'reload'}); - }); -}; - -function watchOnce(filenames, callback) { - var watcher = chokidar.watch(filenames); - var closed = false; - watcher.on('change', function() { - if (closed) return; - closed = true; - // HACK: chokidar 3.1.1 crashes when you synchronously call close - // in the change event. Delaying appears to prevent the crash - process.nextTick(function() { - watcher.close(); - }); - callback(); - }); -} - -AppForServer.prototype._updateScriptViews = function() { - if (!this.scriptFilename) return; - var script = fs.readFileSync(this.scriptFilename, 'utf8'); - var i = script.indexOf('/*DERBY_SERIALIZED_VIEWS*/'); - var before = script.slice(0, i); - var i = script.indexOf('/*DERBY_SERIALIZED_VIEWS_END*/'); - var after = script.slice(i + 30); - var viewsSource = this._viewsSource(); - fs.writeFileSync(this.scriptFilename, before + viewsSource + after, 'utf8'); -}; - -AppForServer.prototype._autoRefresh = function(backend) { - // already been setup if agents is defined - if (this.agents) return; - this.agents = {}; - var app = this; - - // Auto-refresh is implemented on top of ShareDB's messaging layer. - // - // However, ShareDB wasn't originally designed to support custom message types, so ShareDB's - // Agent class will log out "Invalid or unknown message" warnings if it encounters a message - // it doesn't recognize. - // - // A workaround is to register a "receive" middleware, which fires when a ShareDB server - // receives a message from a client. If the message is Derby-related, the middleware will - // "exit" the middleware chain early by not calling `next()`. That way, the custom message never - // gets to the ShareDB Agent and won't result in warnings. - // - // However, multiple Derby apps can run together on the same ShareDB backend, each adding a - // "receive" middleware, and they all need to be notified of incoming Derby messages. This - // solution combines the exit-early approach with a custom event to accomplish that. - backend.use('receive', function(request, next) { - var data = request.data; - if (data.derby) { - // Derby-related message, emit custom event and "exit" middleware chain early. - backend.emit('derby:_messageReceived', request.agent, data.derby, data); - return; - } else { - // Not a Derby-related message, pass to next middleware. - next(); - } - }); - - backend.on('derby:_messageReceived', function(agent, action, message) { - app._handleMessage(agent, action, message); - }); -}; - -AppForServer.prototype._handleMessage = function(agent, action, message) { - if (action === 'app') { - if (message.name !== this.name) { - return; - } - if (message.hash !== this.scriptHash) { - return agent.send({derby: 'reload'}); - } - this._addAgent(agent); - } -}; - -AppForServer.prototype._addAgent = function(agent) { - this.agents[agent.clientId] = agent; - var app = this; - agent.stream.once('end', function() { - delete app.agents[agent.clientId]; - }); -}; - -AppForServer.prototype._refreshClients = function() { - if (!this.agents) return; - var views = this.views.serialize({minify: true}); - var message = { - derby: 'refreshViews', - views: views - }; - for (var id in this.agents) { - this.agents[id].send(message); - } -}; - -AppForServer.prototype._refreshStyles = function(filename, styles) { - if (!this.agents) return; - var data = {filename: filename, css: styles.css}; - var message = { - derby: 'refreshStyles', - filename: filename, - css: styles.css - }; - for (var id in this.agents) { - this.agents[id].send(message); - } -}; - -AppForServer.prototype.middleware = function(backend) { - return [backend.modelMiddware(), this.router()]; -} - -AppForServer.prototype.initAutoRefresh = function(backend) { - this._autoRefresh(backend); -} diff --git a/lib/Controller.js b/lib/Controller.js deleted file mode 100644 index 511976d5d..000000000 --- a/lib/Controller.js +++ /dev/null @@ -1,46 +0,0 @@ -var EventEmitter = require('events').EventEmitter; -var util = require('racer/lib/util'); -var Dom = require('./Dom'); - -module.exports = Controller; - -function Controller(app, page, model) { - EventEmitter.call(this); - this.dom = new Dom(this); - this.app = app; - this.page = page; - this.model = model; - model.data.$controller = this; -} - -util.mergeInto(Controller.prototype, EventEmitter.prototype); - -Controller.prototype.emitCancellable = function() { - var cancelled = false; - function cancel() { - cancelled = true; - } - - var args = Array.prototype.slice.call(arguments); - args.push(cancel); - this.emit.apply(this, args); - - return cancelled; -}; - -Controller.prototype.emitDelayable = function() { - var args = Array.prototype.slice.call(arguments); - var callback = args.pop(); - - var delayed = false; - function delay() { - delayed = true; - return callback; - } - - args.push(delay); - this.emit.apply(this, args); - if (!delayed) callback(); - - return delayed; -}; diff --git a/lib/Derby.js b/lib/Derby.js deleted file mode 100644 index 2f5db2201..000000000 --- a/lib/Derby.js +++ /dev/null @@ -1,24 +0,0 @@ -/* - * Derby.js - * Meant to be the entry point for the framework. - * - */ -var racer = require('racer'); - -module.exports = Derby; - -function Derby() {} -Derby.prototype = Object.create(racer); -Derby.prototype.constructor = Derby; - -Derby.prototype.App = require('./App'); -Derby.prototype.Page = require('./Page'); -Derby.prototype.Component = require('./components').Component; - -Derby.prototype.createApp = function(name, filename, options) { - return new this.App(this, name, filename, options); -}; - -if (!racer.util.isServer) { - require('./documentListeners').add(document); -} diff --git a/lib/DerbyForServer.js b/lib/DerbyForServer.js deleted file mode 100644 index 8401959f8..000000000 --- a/lib/DerbyForServer.js +++ /dev/null @@ -1,40 +0,0 @@ -var cluster = require('cluster'); -var Derby = require('./Derby'); -var util = require('racer/lib/util'); - -util.isProduction = process.env.NODE_ENV === 'production'; - -module.exports = DerbyForServer; -function DerbyForServer() {} -DerbyForServer.prototype = Object.create(Derby.prototype); -DerbyForServer.prototype.constructor = DerbyForServer; - -DerbyForServer.prototype.App = require('./AppForServer'); -DerbyForServer.prototype.Page = require('./PageForServer'); - -DerbyForServer.prototype.run = function(createServer) { - // In production - if (this.util.isProduction) return createServer(); - if (cluster.isMaster) { - console.log('Master pid ', process.pid); - startWorker(); - } else { - createServer(); - } -}; - -function startWorker() { - var worker = cluster.fork(); - worker.once('disconnect', function () { - worker.process.kill(); - }); - worker.on('message', function(message) { - if (message.type === 'reload') { - if (worker.disconnecting) return; - console.log('Killing %d', worker.process.pid); - worker.process.kill(); - worker.disconnecting = true; - startWorker(); - } - }); -} diff --git a/lib/DerbyStandalone.js b/lib/DerbyStandalone.js deleted file mode 100644 index a083da55a..000000000 --- a/lib/DerbyStandalone.js +++ /dev/null @@ -1,38 +0,0 @@ -var EventEmitter = require('events').EventEmitter; -var Model = require('racer/lib/Model/ModelStandalone'); -var util = require('racer/lib/util'); -var App = require('./App'); -var Page = require('./Page'); -var components = require('./components'); - -module.exports = DerbyStandalone; - -require('./documentListeners').add(document); - -// Standard Derby inherits from Racer, but we only set up the event emitter and -// expose the Model and util here instead -function DerbyStandalone() { - EventEmitter.call(this); -} -util.mergeInto(DerbyStandalone.prototype, EventEmitter.prototype); -DerbyStandalone.prototype.Model = Model; -DerbyStandalone.prototype.util = util; - -DerbyStandalone.prototype.App = AppStandalone; -DerbyStandalone.prototype.Page = Page; -DerbyStandalone.prototype.Component = components.Component; - -DerbyStandalone.prototype.createApp = function() { - return new this.App(this); -}; - -function AppStandalone(derby) { - App.call(this, derby); -} -AppStandalone.prototype = Object.create(App.prototype); -AppStandalone.prototype.constructor = AppStandalone; - -AppStandalone.prototype._init = function() { - this.model = new this.derby.Model(); - this.createPage(); -}; diff --git a/lib/Dom.js b/lib/Dom.js deleted file mode 100644 index ed582d6ca..000000000 --- a/lib/Dom.js +++ /dev/null @@ -1,109 +0,0 @@ -module.exports = Dom; - -function Dom(controller) { - this.controller = controller; - this._listeners = null; -} - -Dom.prototype._initListeners = function() { - var dom = this; - this.controller.on('destroy', function domOnDestroy() { - var listeners = dom._listeners; - if (!listeners) return; - for (var i = listeners.length; i--;) { - listeners[i].remove(); - } - dom._listeners = null; - }); - return this._listeners = []; -}; - -Dom.prototype._listenerIndex = function(domListener) { - var listeners = this._listeners; - if (!listeners) return -1; - for (var i = listeners.length; i--;) { - if (listeners[i].equals(domListener)) return i; - } - return -1; -}; - -Dom.prototype.addListener = function(type, target, listener, useCapture) { - if (typeof target === 'function') { - useCapture = listener; - listener = target; - target = document; - } - var domListener = - (type === 'destroy') ? new DestroyListener(target, listener) : - new DomListener(type, target, listener, useCapture); - if (-1 === this._listenerIndex(domListener)) { - var listeners = this._listeners || this._initListeners(); - listeners.push(domListener); - } - domListener.add(); -}; -Dom.prototype.on = Dom.prototype.addListener; - -Dom.prototype.once = function(type, target, listener, useCapture) { - if (typeof target === 'function') { - useCapture = listener; - listener = target; - target = document; - } - this.addListener(type, target, wrappedListener, useCapture); - var dom = this; - function wrappedListener() { - dom.removeListener(type, target, wrappedListener, useCapture); - return listener.apply(this, arguments); - } -}; - -Dom.prototype.removeListener = function(type, target, listener, useCapture) { - if (typeof target === 'function') { - useCapture = listener; - listener = target; - target = document; - } - var domListener = new DomListener(type, target, listener, useCapture); - domListener.remove(); - var i = this._listenerIndex(domListener); - if (i > -1) this._listeners.splice(i, 1); -}; - -function DomListener(type, target, listener, useCapture) { - this.type = type; - this.target = target; - this.listener = listener; - this.useCapture = !!useCapture; -} -DomListener.prototype.equals = function(domListener) { - return this.listener === domListener.listener && - this.target === domListener.target && - this.type === domListener.type && - this.useCapture === domListener.useCapture; -}; -DomListener.prototype.add = function() { - this.target.addEventListener(this.type, this.listener, this.useCapture); -}; -DomListener.prototype.remove = function() { - this.target.removeEventListener(this.type, this.listener, this.useCapture); -}; - -function DestroyListener(target, listener) { - DomListener.call(this, 'destroy', target, listener); -} -DestroyListener.prototype = new DomListener(); -DestroyListener.prototype.add = function() { - var listeners = this.target.$destroyListeners || (this.target.$destroyListeners = []); - if (listeners.indexOf(this.listener) === -1) { - listeners.push(this.listener); - } -}; -DestroyListener.prototype.remove = function() { - var listeners = this.target.$destroyListeners; - if (!listeners) return; - var index = listeners.indexOf(this.listener); - if (index !== -1) { - listeners.splice(index, 1); - } -}; diff --git a/lib/Page.js b/lib/Page.js deleted file mode 100644 index 30622a43b..000000000 --- a/lib/Page.js +++ /dev/null @@ -1,414 +0,0 @@ -var derbyTemplates = require('./templates'); -var contexts = derbyTemplates.contexts; -var expressions = derbyTemplates.expressions; -var templates = derbyTemplates.templates; -var DependencyOptions = derbyTemplates.options.DependencyOptions; -var util = require('racer/lib/util'); -var components = require('./components'); -var EventModel = require('./eventmodel'); -var textDiff = require('./textDiff'); -var Controller = require('./Controller'); -var documentListeners = require('./documentListeners'); - -module.exports = Page; - -function Page(app, model) { - Controller.call(this, app, this, model); - this.params = null; - if (this.init) this.init(model); - this.context = this._createContext(); - this._eventModel = null; - this._removeModelListeners = null; - this._components = {}; - this._addListeners(); -} - -util.mergeInto(Page.prototype, Controller.prototype); - -Page.prototype.$bodyClass = function(ns) { - if (!ns) return; - var classNames = []; - var segments = ns.split(':'); - for (var i = 0, len = segments.length; i < len; i++) { - var className = segments.slice(0, i + 1).join('-'); - classNames.push(className); - } - return classNames.join(' '); -}; - -Page.prototype.$preventDefault = function(e) { - e.preventDefault(); -}; - -Page.prototype.$stopPropagation = function(e) { - e.stopPropagation(); -}; - -Page.prototype._setRenderParams = function(ns) { - this.model.set('$render.ns', ns); - this.model.set('$render.params', this.params); - this.model.set('$render.url', this.params && this.params.url); - this.model.set('$render.query', this.params && this.params.query); -}; - -Page.prototype._setRenderPrefix = function(ns) { - var prefix = (ns) ? ns + ':' : ''; - this.model.set('$render.prefix', prefix); -}; - -Page.prototype.get = function(viewName, ns, unescaped) { - this._setRenderPrefix(ns); - var view = this.getView(viewName, ns); - return view.get(this.context, unescaped); -}; - -Page.prototype.getFragment = function(viewName, ns) { - this._setRenderPrefix(ns); - var view = this.getView(viewName, ns); - return view.getFragment(this.context); -}; - -Page.prototype.getView = function(viewName, ns) { - return this.app.views.find(viewName, ns); -}; - -Page.prototype.render = function(ns) { - this.app.emit('render', this); - this.context.pause(); - this._setRenderParams(ns); - var titleFragment = this.getFragment('TitleElement', ns); - var bodyFragment = this.getFragment('BodyElement', ns); - var titleElement = document.getElementsByTagName('title')[0]; - titleElement.parentNode.replaceChild(titleFragment, titleElement); - document.body.parentNode.replaceChild(bodyFragment, document.body); - this.context.unpause(); - if (this.create) this.create(this.model, this.dom); - this.app.emit('routeDone', this, 'render'); -}; - -Page.prototype.attach = function() { - this.context.pause(); - var ns = this.model.get('$render.ns'); - var titleView = this.getView('TitleElement', ns); - var bodyView = this.getView('BodyElement', ns); - var titleElement = document.getElementsByTagName('title')[0]; - titleView.attachTo(titleElement.parentNode, titleElement, this.context); - bodyView.attachTo(document.body.parentNode, document.body, this.context); - this.context.unpause(); - if (this.create) this.create(this.model, this.dom); -}; - -Page.prototype._createContext = function() { - var contextMeta = new contexts.ContextMeta(); - contextMeta.views = this.app && this.app.views; - var context = new contexts.Context(contextMeta, this); - context.expression = new expressions.PathExpression([]); - context.alias = '#root'; - return context; -}; - -Page.prototype._addListeners = function() { - var eventModel = this._eventModel = new EventModel(); - this._addModelListeners(eventModel); - this._addContextListeners(eventModel); -}; - -Page.prototype.destroy = function() { - this.emit('destroy'); - this._removeModelListeners(); - for (var id in this._components) { - var component = this._components[id]; - component.destroy(); - } - // Remove all data, refs, listeners, and reactive functions - // for the previous page - var silentModel = this.model.silent(); - silentModel.destroy('_page'); - silentModel.destroy('$components'); - // Unfetch and unsubscribe from all queries and documents - if (silentModel.unloadAll) { - silentModel.unloadAll(); - } -}; - -Page.prototype._addModelListeners = function(eventModel) { - var model = this.model; - if (!model) return; - // Registering model listeners with the *Immediate events helps to prevent - // a bug with binding updates where a model listener causes a change to the - // path being listened on, directly or indirectly. - - // TODO: Remove this when upgrading Racer to the next major version. Feature - // detect which type of event listener to register by emitting a test event - if (useLegacyListeners(model)) { - return this._addModelListenersLegacy(eventModel); - } - - // `util.castSegments(segments)` is needed to cast string segments into - // numbers, since EventModel#child does typeof checks against segments. This - // could be done once in Racer's Model#emit, instead of in every listener. - var changeListener = model.on('changeImmediate', function onChange(segments, event) { - // The pass parameter is passed in for special handling of updates - // resulting from stringInsert or stringRemove - segments = util.castSegments(segments.slice()); - eventModel.set(segments, event.previous, event.passed); - }); - var loadListener = model.on('loadImmediate', function onLoad(segments) { - segments = util.castSegments(segments.slice()); - eventModel.set(segments); - }); - var unloadListener = model.on('unloadImmediate', function onUnload(segments, event) { - segments = util.castSegments(segments.slice()); - eventModel.set(segments, event.previous); - }); - var insertListener = model.on('insertImmediate', function onInsert(segments, event) { - segments = util.castSegments(segments.slice()); - eventModel.insert(segments, event.index, event.values.length); - }); - var removeListener = model.on('removeImmediate', function onRemove(segments, event) { - segments = util.castSegments(segments.slice()); - eventModel.remove(segments, event.index, event.values.length); - }); - var moveListener = model.on('moveImmediate', function onMove(segments, event) { - segments = util.castSegments(segments.slice()); - eventModel.move(segments, event.from, event.to, event.howMany); - }); - - this._removeModelListeners = function() { - model.removeListener('changeImmediate', changeListener); - model.removeListener('loadImmediate', loadListener); - model.removeListener('unloadImmediate', unloadListener); - model.removeListener('insertImmediate', insertListener); - model.removeListener('removeImmediate', removeListener); - model.removeListener('moveImmediate', moveListener); - }; -}; -function useLegacyListeners(model) { - var useLegacy = true; - // model.once is broken in older racer, so manually remove event - var listener = model.on('changeImmediate', function(segments, event) { - model.removeListener('changeImmediate', listener); - // Older Racer emits an array of eventArgs, whereas newer racer emits an event object - useLegacy = Array.isArray(event); - }); - model.set('$derby.testEvent', true); - return useLegacy; -} -Page.prototype._addModelListenersLegacy = function(eventModel) { - var model = this.model; - if (!model) return; - - // `util.castSegments(segments)` is needed to cast string segments into - // numbers, since EventModel#child does typeof checks against segments. This - // could be done once in Racer's Model#emit, instead of in every listener. - var changeListener = model.on('changeImmediate', function onChange(segments, eventArgs) { - // eventArgs[0] is the new value, which Derby bindings don't use directly. - var previous = eventArgs[1]; - // The pass parameter is passed in for special handling of updates - // resulting from stringInsert or stringRemove - var pass = eventArgs[2]; - segments = util.castSegments(segments.slice()); - eventModel.set(segments, previous, pass); - }); - var loadListener = model.on('loadImmediate', function onLoad(segments) { - segments = util.castSegments(segments.slice()); - eventModel.set(segments); - }); - var unloadListener = model.on('unloadImmediate', function onUnload(segments) { - segments = util.castSegments(segments.slice()); - eventModel.set(segments); - }); - var insertListener = model.on('insertImmediate', function onInsert(segments, eventArgs) { - var index = eventArgs[0]; - var values = eventArgs[1]; - segments = util.castSegments(segments.slice()); - eventModel.insert(segments, index, values.length); - }); - var removeListener = model.on('removeImmediate', function onRemove(segments, eventArgs) { - var index = eventArgs[0]; - var values = eventArgs[1]; - segments = util.castSegments(segments.slice()); - eventModel.remove(segments, index, values.length); - }); - var moveListener = model.on('moveImmediate', function onMove(segments, eventArgs) { - var from = eventArgs[0]; - var to = eventArgs[1]; - var howMany = eventArgs[2]; - segments = util.castSegments(segments.slice()); - eventModel.move(segments, from, to, howMany); - }); - - this._removeModelListeners = function() { - model.removeListener('changeImmediate', changeListener); - model.removeListener('loadImmediate', loadListener); - model.removeListener('unloadImmediate', unloadListener); - model.removeListener('insertImmediate', insertListener); - model.removeListener('removeImmediate', removeListener); - model.removeListener('moveImmediate', moveListener); - }; -}; - -Page.prototype._addContextListeners = function(eventModel) { - this.context.meta.addBinding = addBinding; - this.context.meta.removeBinding = removeBinding; - this.context.meta.removeNode = removeNode; - this.context.meta.addItemContext = addItemContext; - this.context.meta.removeItemContext = removeItemContext; - - function addItemContext(context) { - var segments = context.expression.resolve(context); - eventModel.addItemContext(segments, context); - } - function removeItemContext(context) { - // TODO - } - function addBinding(binding) { - patchTextBinding(binding); - var expressions = binding.template.expressions; - if (expressions) { - for (var i = 0, len = expressions.length; i < len; i++) { - addDependencies(eventModel, expressions[i], binding); - } - } else { - var expression = binding.template.expression; - addDependencies(eventModel, expression, binding); - } - } - function removeBinding(binding) { - var bindingWrappers = binding.meta; - if (!bindingWrappers) return; - for (var i = bindingWrappers.length; i--;) { - eventModel.removeBinding(bindingWrappers[i]); - } - } - function removeNode(node) { - var component = node.$component; - if (component) component.destroy(); - var destroyListeners = node.$destroyListeners; - if (destroyListeners) { - for (var i = 0; i < destroyListeners.length; i++) { - destroyListeners[i](); - } - } - } -}; - -function addDependencies(eventModel, expression, binding) { - var bindingWrapper = new BindingWrapper(eventModel, expression, binding); - bindingWrapper.updateDependencies(); -} - -// The code here uses object-based set pattern where objects are keyed using -// sequentially generated IDs. -var nextId = 1; -function BindingWrapper(eventModel, expression, binding) { - this.eventModel = eventModel; - this.expression = expression; - this.binding = binding; - this.id = nextId++; - this.eventModels = null; - this.dependencies = null; - this.ignoreTemplateDependency = ( - binding instanceof components.ComponentAttributeBinding - ) || ( - (binding.template instanceof templates.DynamicText) && - (binding instanceof templates.RangeBinding) - ); - if (binding.meta) { - binding.meta.push(this); - } else { - binding.meta = [this]; - } -} -BindingWrapper.prototype.updateDependencies = function() { - var dependencyOptions; - if (this.ignoreTemplateDependency && this.binding.condition instanceof templates.Template) { - dependencyOptions = new DependencyOptions(); - dependencyOptions.setIgnoreTemplate(this.binding.condition); - } - var dependencies = this.expression.dependencies(this.binding.context, dependencyOptions); - if (this.dependencies) { - // Do nothing if dependencies haven't changed - if (equalDependencies(this.dependencies, dependencies)) return; - // Otherwise, remove current dependencies - this.eventModel.removeBinding(this); - } - // Add new dependencies - if (!dependencies) return; - this.dependencies = dependencies; - for (var i = 0, len = dependencies.length; i < len; i++) { - var dependency = dependencies[i]; - if (dependency) this.eventModel.addBinding(dependency, this); - } -}; -BindingWrapper.prototype.update = function(previous, pass) { - this.binding.update(previous, pass); - this.updateDependencies(); -}; -BindingWrapper.prototype.insert = function(index, howMany) { - this.binding.insert(index, howMany); - this.updateDependencies(); -}; -BindingWrapper.prototype.remove = function(index, howMany) { - this.binding.remove(index, howMany); - this.updateDependencies(); -}; -BindingWrapper.prototype.move = function(from, to, howMany) { - this.binding.move(from, to, howMany); - this.updateDependencies(); -}; - -function equalDependencies(a, b) { - var lenA = a ? a.length : -1; - var lenB = b ? b.length : -1; - if (lenA !== lenB) return false; - for (var i = 0; i < lenA; i++) { - var itemA = a[i]; - var itemB = b[i]; - var lenItemA = itemA ? itemA.length : -1; - var lenItemB = itemB ? itemB.length : -1; - if (lenItemA !== lenItemB) return false; - for (var j = 0; j < lenItemB; j++) { - if (itemA[j] !== itemB[j]) return false; - } - } - return true; -} - -function patchTextBinding(binding) { - if ( - binding instanceof templates.AttributeBinding && - binding.name === 'value' && - (binding.element.tagName === 'INPUT' || binding.element.tagName === 'TEXTAREA') && - documentListeners.inputSupportsSelection(binding.element) && - binding.template.expression.resolve(binding.context) - ) { - binding.update = textInputUpdate; - } -} - -function textInputUpdate(previous, pass) { - textUpdate(this, this.element, previous, pass); -} -function textUpdate(binding, element, previous, pass) { - if (pass) { - if (pass.$event && pass.$event.target === element) { - return; - } else if (pass.$stringInsert) { - return textDiff.onStringInsert( - element, - previous, - pass.$stringInsert.index, - pass.$stringInsert.text - ); - } else if (pass.$stringRemove) { - return textDiff.onStringRemove( - element, - previous, - pass.$stringRemove.index, - pass.$stringRemove.howMany - ); - } - } - binding.template.update(binding.context, binding); -} diff --git a/lib/PageForServer.js b/lib/PageForServer.js deleted file mode 100644 index 20d44b085..000000000 --- a/lib/PageForServer.js +++ /dev/null @@ -1,93 +0,0 @@ -var Page = require('./Page'); - -module.exports = PageForServer; -function PageForServer(app, model, req, res) { - Page.call(this, app, model); - this.req = req; - this.res = res; -} - -PageForServer.prototype = Object.create(Page.prototype); -PageForServer.prototype.constructor = PageForServer; - -PageForServer.prototype.render = function(status, ns) { - if (typeof status !== 'number') { - ns = status; - status = null; - } - this.app.emit('render', this); - - if (status) this.res.statusCode = status; - // Prevent the browser from storing the HTML response in its back cache, since - // that will cause it to render with the data from the initial load first - this.res.setHeader('Cache-Control', 'no-store'); - // Set HTML utf-8 content type unless already set - if (!this.res.getHeader('Content-Type')) { - this.res.setHeader('Content-Type', 'text/html; charset=utf-8'); - } - - this._setRenderParams(ns); - var pageHtml = this.get('Page', ns); - this.res.write(pageHtml); - this.app.emit('htmlDone', this); - - this.res.write('' + tailHtml); - page.app.emit('routeDone', page, 'render'); - }); -}; - -PageForServer.prototype.renderStatic = function(status, ns) { - if (typeof status !== 'number') { - ns = status; - status = null; - } - this.app.emit('renderStatic', this); - - if (status) this.res.statusCode = status; - this.params = pageParams(this.req); - this._setRenderParams(ns); - var pageHtml = this.get('Page', ns); - var tailHtml = this.get('Tail', ns); - this.res.send(pageHtml + tailHtml); - this.app.emit('routeDone', this, 'renderStatic'); -}; - -// Don't register any listeners on the server -PageForServer.prototype._addListeners = function() {}; - -function stringifyBundle(bundle) { - var json = JSON.stringify(bundle); - return json.replace(/<[\/!]/g, function(match) { - // Replace the end tag sequence with an equivalent JSON string to make - // sure the script is not prematurely closed - if (match === ' 0 : !!value; -} - -function pathSegments(segments) { - var result = []; - for (var i = 0; i < segments.length; i++) { - var segment = segments[i]; - result[i] = (typeof segment === 'object') ? segment.item : segment; - } - return result; -} - -function renderValue(value, context) { - return (typeof value !== 'object') ? value : - (value instanceof Template) ? renderTemplate(value, context) : - (Array.isArray(value)) ? renderArray(value, context) : - renderObject(value, context); -} -function renderTemplate(value, context) { - var i = 1000; - while (value instanceof Template) { - if (--i < 0) throw new Error('Maximum template render passes exceeded'); - value = value.get(context, true); - } - return value; -} -function renderArray(array, context) { - for (var i = 0; i < array.length; i++) { - if (hasTemplateProperty(array[i])) { - return renderArrayProperties(array, context); - } - } - return array; -} -function renderObject(object, context) { - return (hasTemplateProperty(object)) ? - renderObjectProperties(object, context) : object; -} -function hasTemplateProperty(object) { - if (!object) return false; - if (object.constructor !== Object) return false; - for (var key in object) { - if (object[key] instanceof Template) return true; - } - return false; -} -function renderArrayProperties(array, context) { - var out = new Array(array.length); - for (var i = 0; i < array.length; i++) { - out[i] = renderValue(array[i], context); - } - return out; -} -function renderObjectProperties(object, context) { - var out = {}; - for (var key in object) { - out[key] = renderValue(object[key], context); - } - return out; -} - -function ExpressionMeta(source, blockType, isEnd, as, keyAs, unescaped, bindType, valueType) { - this.source = source; - this.blockType = blockType; - this.isEnd = isEnd; - this.as = as; - this.keyAs = keyAs; - this.unescaped = unescaped; - this.bindType = bindType; - this.valueType = valueType; -} -ExpressionMeta.prototype.module = 'expressions'; -ExpressionMeta.prototype.type = 'ExpressionMeta'; -ExpressionMeta.prototype.serialize = function() { - return serializeObject.instance( - this, - this.source, - this.blockType, - this.isEnd, - this.as, - this.keyAs, - this.unescaped, - this.bindType, - this.valueType - ); -}; - -function Expression(meta) { - this.meta = meta; -} -Expression.prototype.module = 'expressions'; -Expression.prototype.type = 'Expression'; -Expression.prototype.serialize = function() { - return serializeObject.instance(this, this.meta); -}; -Expression.prototype.toString = function() { - return this.meta && this.meta.source; -}; -Expression.prototype.truthy = function(context) { - var blockType = this.meta.blockType; - if (blockType === 'else') return true; - var value = this.get(context, true); - var truthy = templateTruthy(value); - return (blockType === 'unless') ? !truthy : truthy; -}; -Expression.prototype.get = function() {}; -// Return the expression's segment list with context objects -Expression.prototype.resolve = function() {}; -// Return a list of segment lists or null -Expression.prototype.dependencies = function() {}; -// Return the pathSegments that the expression currently resolves to or null -Expression.prototype.pathSegments = function(context) { - var segments = this.resolve(context); - return segments && pathSegments(segments); -}; -Expression.prototype.set = function(context, value) { - var segments = this.pathSegments(context); - if (!segments) throw new Error('Expression does not support setting'); - context.controller.model._set(segments, value); -}; -Expression.prototype._resolvePatch = function(context, segments) { - return (context && context.expression === this && context.item != null) ? - segments.concat(context) : segments; -}; -Expression.prototype.isUnbound = function(context) { - // If the template being rendered has an explicit bindType keyword, such as: - // {{unbound #item.text}} - var bindType = this.meta && this.meta.bindType; - if (bindType === 'unbound') return true; - if (bindType === 'bound') return false; - // Otherwise, inherit from the context - return context.unbound; -}; -Expression.prototype._lookupAndContextifyValue = function(value, context) { - if (this.segments && this.segments.length) { - // If expression has segments, e.g. `bar.baz` in `#foo.bar.baz`, then - // render the base value (e.g. `#foo`) if it's a template and look up the - // value at the indicated path. - value = renderTemplate(value, context); - value = lookup(this.segments, value); - } - if (value instanceof Template && !(value instanceof templates.ContextClosure)) { - // If we're not immediately rendering the template, then create a ContextClosure - // so that the value renders with the correct context later. - value = new templates.ContextClosure(value, context); - } - return value; -}; - - -function LiteralExpression(value, meta) { - this.value = value; - this.meta = meta; -} -LiteralExpression.prototype = Object.create(Expression.prototype); -LiteralExpression.prototype.constructor = LiteralExpression; -LiteralExpression.prototype.type = 'LiteralExpression'; -LiteralExpression.prototype.serialize = function() { - return serializeObject.instance(this, this.value, this.meta); -}; -LiteralExpression.prototype.get = function() { - return this.value; -}; - -function PathExpression(segments, meta) { - this.segments = segments; - this.meta = meta; -} -PathExpression.prototype = Object.create(Expression.prototype); -PathExpression.prototype.constructor = PathExpression; -PathExpression.prototype.type = 'PathExpression'; -PathExpression.prototype.serialize = function() { - return serializeObject.instance(this, this.segments, this.meta); -}; -PathExpression.prototype.get = function(context) { - // See View::dependencies. This is needed in order to handle the case of - // getting dependencies within a component template, in which case we cannot - // access model data separate from rendering. - if (!context.controller) return; - return lookup(this.segments, context.controller.model.data); -}; -PathExpression.prototype.resolve = function(context) { - // See View::dependencies. This is needed in order to handle the case of - // getting dependencies within a component template, in which case we cannot - // access model data separate from rendering. - if (!context.controller) return; - var segments = concat(context.controller._scope, this.segments); - return this._resolvePatch(context, segments); -}; -PathExpression.prototype.dependencies = function(context, options) { - // See View::dependencies. This is needed in order to handle the case of - // getting dependencies within a component template, in which case we cannot - // access model data separate from rendering. - if (!context.controller) return; - var value = lookup(this.segments, context.controller.model.data); - var dependencies = getDependencies(value, context, options); - return appendDependency(dependencies, this, context); -}; - -function RelativePathExpression(segments, meta) { - this.segments = segments; - this.meta = meta; -} -RelativePathExpression.prototype = Object.create(Expression.prototype); -RelativePathExpression.prototype.constructor = RelativePathExpression; -RelativePathExpression.prototype.type = 'RelativePathExpression'; -RelativePathExpression.prototype.serialize = function() { - return serializeObject.instance(this, this.segments, this.meta); -}; -RelativePathExpression.prototype.get = function(context) { - var relativeContext = context.forRelative(this); - var value = relativeContext.get(); - return this._lookupAndContextifyValue(value, relativeContext); -}; -RelativePathExpression.prototype.resolve = function(context) { - var relativeContext = context.forRelative(this); - var base = (relativeContext.expression) ? - relativeContext.expression.resolve(relativeContext) : - []; - if (!base) return; - var segments = base.concat(this.segments); - return this._resolvePatch(context, segments); -}; -RelativePathExpression.prototype.dependencies = function(context, options) { - // Return inner dependencies from our ancestor - // (e.g., {{ with foo[bar] }} ... {{ this.x }} has 'bar' as a dependency.) - var relativeContext = context.forRelative(this); - var dependencies = relativeContext.expression && - relativeContext.expression.dependencies(relativeContext, options); - return swapLastDependency(dependencies, this, context); -}; - -function AliasPathExpression(alias, segments, meta) { - this.alias = alias; - this.segments = segments; - this.meta = meta; -} -AliasPathExpression.prototype = Object.create(Expression.prototype); -AliasPathExpression.prototype.constructor = AliasPathExpression; -AliasPathExpression.prototype.type = 'AliasPathExpression'; -AliasPathExpression.prototype.serialize = function() { - return serializeObject.instance(this, this.alias, this.segments, this.meta); -}; -AliasPathExpression.prototype.get = function(context) { - var aliasContext = context.forAlias(this.alias); - if (!aliasContext) return; - if (aliasContext.keyAlias === this.alias) { - return aliasContext.item; - } - var value = aliasContext.get(); - return this._lookupAndContextifyValue(value, aliasContext); -}; -AliasPathExpression.prototype.resolve = function(context) { - var aliasContext = context.forAlias(this.alias); - if (!aliasContext) return; - if (aliasContext.keyAlias === this.alias) return; - var base = aliasContext.expression.resolve(aliasContext); - if (!base) return; - var segments = base.concat(this.segments); - return this._resolvePatch(context, segments); -}; -AliasPathExpression.prototype.dependencies = function(context, options) { - var aliasContext = context.forAlias(this.alias); - if (!aliasContext) return; - if (aliasContext.keyAlias === this.alias) { - // For keyAliases, use a dependency of the entire list, so that it will - // always update when the list itself changes. This is over-binding, but - // would otherwise be much more complex - var base = aliasContext.expression.resolve(aliasContext.parent); - if (!base) return; - return [base]; - } - - var dependencies = aliasContext.expression.dependencies(aliasContext, options); - return swapLastDependency(dependencies, this, context); -}; - -function AttributePathExpression(attribute, segments, meta) { - this.attribute = attribute; - this.segments = segments; - this.meta = meta; -} -AttributePathExpression.prototype = Object.create(Expression.prototype); -AttributePathExpression.prototype.constructor = AttributePathExpression; -AttributePathExpression.prototype.type = 'AttributePathExpression'; -AttributePathExpression.prototype.serialize = function() { - return serializeObject.instance(this, this.attribute, this.segments, this.meta); -}; -AttributePathExpression.prototype.get = function(context) { - var attributeContext = context.forAttribute(this.attribute); - if (!attributeContext) return; - var value = attributeContext.attributes[this.attribute]; - if (value instanceof Expression) { - value = value.get(attributeContext); - } - return this._lookupAndContextifyValue(value, attributeContext); -}; -AttributePathExpression.prototype.resolve = function(context) { - var attributeContext = context.forAttribute(this.attribute); - if (!attributeContext) return; - // Attributes may be a template, an expression, or a literal value - var base; - var value = attributeContext.attributes[this.attribute]; - if (value instanceof Expression || value instanceof Template) { - base = value.resolve(attributeContext); - } - if (!base) return; - var segments = base.concat(this.segments); - return this._resolvePatch(context, segments); -}; -AttributePathExpression.prototype.dependencies = function(context, options) { - var attributeContext = context.forAttribute(this.attribute); - if (!attributeContext) return; - - // Attributes may be a template, an expression, or a literal value - var value = attributeContext.attributes[this.attribute]; - var dependencies = getDependencies(value, attributeContext, options); - return swapLastDependency(dependencies, this, context); -}; - -function BracketsExpression(before, inside, afterSegments, meta) { - this.before = before; - this.inside = inside; - this.afterSegments = afterSegments; - this.meta = meta; -} -BracketsExpression.prototype = Object.create(Expression.prototype); -BracketsExpression.prototype.constructor = BracketsExpression; -BracketsExpression.prototype.type = 'BracketsExpression'; -BracketsExpression.prototype.serialize = function() { - return serializeObject.instance(this, this.before, this.inside, this.afterSegments, this.meta); -}; -BracketsExpression.prototype.get = function(context) { - var inside = this.inside.get(context); - if (inside == null) return; - var before = this.before.get(context); - if (!before) return; - var base = before[inside]; - return (this.afterSegments) ? lookup(this.afterSegments, base) : base; -}; -BracketsExpression.prototype.resolve = function(context) { - // Get and split the current value of the expression inside the brackets - var inside = this.inside.get(context); - if (inside == null) return; - - // Concat the before, inside, and optional after segments - var base = this.before.resolve(context); - if (!base) return; - var segments = (this.afterSegments) ? - base.concat(inside, this.afterSegments) : - base.concat(inside); - return this._resolvePatch(context, segments); -}; -BracketsExpression.prototype.dependencies = function(context, options) { - var before = this.before.dependencies(context, options); - if (before) before.pop(); - var inner = this.inside.dependencies(context, options); - var dependencies = concat(before, inner); - return appendDependency(dependencies, this, context); -}; - -// This Expression is used to wrap a template so that when its containing -// Expression--such as an ObjectExpression or ArrayExpression--is evaluated, -// it returns the template unrendered and wrapped in the current context. -// Separating evaluation of the containing expression from template rendering -// is used to support array attributes of views. This way, we can evaluate an -// array and iterate through it separately from rendering template content -function DeferRenderExpression(template, meta) { - if (!(template instanceof Template)) { - throw new Error('DeferRenderExpression requires a Template argument'); - } - this.template = template; - this.meta = meta; -} -DeferRenderExpression.prototype = Object.create(Expression.prototype); -DeferRenderExpression.prototype.constructor = DeferRenderExpression; -DeferRenderExpression.prototype.type = 'DeferRenderExpression'; -DeferRenderExpression.prototype.serialize = function() { - return serializeObject.instance(this, this.template, this.meta); -}; -DeferRenderExpression.prototype.get = function(context) { - return new templates.ContextClosure(this.template, context); -}; - -function ArrayExpression(items, afterSegments, meta) { - this.items = items; - this.afterSegments = afterSegments; - this.meta = meta; -} -ArrayExpression.prototype = Object.create(Expression.prototype); -ArrayExpression.prototype.constructor = ArrayExpression; -ArrayExpression.prototype.type = 'ArrayExpression'; -ArrayExpression.prototype.serialize = function() { - return serializeObject.instance(this, this.items, this.afterSegments, this.meta); -}; -ArrayExpression.prototype.get = function(context) { - var items = new Array(this.items.length); - for (var i = 0; i < this.items.length; i++) { - var value = this.items[i].get(context); - items[i] = value; - } - return (this.afterSegments) ? lookup(this.afterSegments, items) : items; -}; -ArrayExpression.prototype.dependencies = function(context, options) { - if (!this.items) return; - var dependencies; - for (var i = 0; i < this.items.length; i++) { - var itemDependencies = this.items[i].dependencies(context, options); - dependencies = concat(dependencies, itemDependencies); - } - return dependencies; -}; - -function ObjectExpression(properties, afterSegments, meta) { - this.properties = properties; - this.afterSegments = afterSegments; - this.meta = meta; -} -ObjectExpression.prototype = Object.create(Expression.prototype); -ObjectExpression.prototype.constructor = ObjectExpression; -ObjectExpression.prototype.type = 'ObjectExpression'; -ObjectExpression.prototype.serialize = function() { - return serializeObject.instance(this, this.properties, this.afterSegments, this.meta); -}; -ObjectExpression.prototype.get = function(context) { - var object = {}; - for (var key in this.properties) { - var value = this.properties[key].get(context); - object[key] = value; - } - return (this.afterSegments) ? lookup(this.afterSegments, object) : object; -}; -ObjectExpression.prototype.dependencies = function(context, options) { - if (!this.properties) return; - var dependencies; - for (var key in this.properties) { - var propertyDependencies = this.properties[key].dependencies(context, options); - dependencies = concat(dependencies, propertyDependencies); - } - return dependencies; -}; - -function FnExpression(segments, args, afterSegments, meta) { - this.segments = segments; - this.args = args; - this.afterSegments = afterSegments; - this.meta = meta; - var parentSegments = segments && segments.slice(); - this.lastSegment = parentSegments && parentSegments.pop(); - this.parentSegments = (parentSegments && parentSegments.length) ? parentSegments : null; -} -FnExpression.prototype = Object.create(Expression.prototype); -FnExpression.prototype.constructor = FnExpression; -FnExpression.prototype.type = 'FnExpression'; -FnExpression.prototype.serialize = function() { - return serializeObject.instance(this, this.segments, this.args, this.afterSegments, this.meta); -}; -FnExpression.prototype.get = function(context) { - var value = this.apply(context); - // Lookup property underneath computed value if needed - return (this.afterSegments) ? lookup(this.afterSegments, value) : value; -}; -FnExpression.prototype.apply = function(context, extraInputs) { - // See View::dependencies. This is needed in order to handle the case of - // getting dependencies within a component template, in which case we cannot - // access model data separate from rendering. - if (!context.controller) return; - var parent = this._lookupParent(context); - var fn = parent[this.lastSegment]; - var getFn = fn.get || fn; - var out = this._applyFn(getFn, context, extraInputs, parent); - return out; -}; -FnExpression.prototype._lookupParent = function(context) { - // Lookup function on current controller - var controller = context.controller; - var segments = this.parentSegments; - var parent = (segments) ? lookup(segments, controller) : controller; - if (parent && parent[this.lastSegment]) return parent; - // Otherwise lookup function on page - var page = controller.page; - if (controller !== page) { - parent = (segments) ? lookup(segments, page) : page; - if (parent && parent[this.lastSegment]) return parent; - } - // Otherwise lookup function on global - parent = (segments) ? lookup(segments, global) : global; - if (parent && parent[this.lastSegment]) return parent; - // Throw if not found - throw new Error('Function not found for: ' + this.segments.join('.')); -}; -FnExpression.prototype._getInputs = function(context) { - var inputs = []; - for (var i = 0, len = this.args.length; i < len; i++) { - var value = this.args[i].get(context); - inputs.push(renderValue(value, context)); - } - return inputs; -}; -FnExpression.prototype._applyFn = function(fn, context, extraInputs, thisArg) { - // Apply if there are no path inputs - if (!this.args) { - return (extraInputs) ? - fn.apply(thisArg, extraInputs) : - fn.call(thisArg); - } - // Otherwise, get the current value for path inputs and apply - var inputs = this._getInputs(context); - if (extraInputs) { - for (var i = 0, len = extraInputs.length; i < len; i++) { - inputs.push(extraInputs[i]); - } - } - return fn.apply(thisArg, inputs); -}; -FnExpression.prototype.dependencies = function(context, options) { - var dependencies = []; - if (!this.args) return dependencies; - for (var i = 0, len = this.args.length; i < len; i++) { - var argDependencies = this.args[i].dependencies(context, options); - if (!argDependencies || argDependencies.length < 1) continue; - var end = argDependencies.length - 1; - for (var j = 0; j < end; j++) { - dependencies.push(argDependencies[j]); - } - var last = argDependencies[end]; - if (last[last.length - 1] !== '*') { - last = last.concat('*'); - } - dependencies.push(last); - } - return dependencies; -}; -FnExpression.prototype.set = function(context, value) { - var controller = context.controller; - var fn, parent; - while (controller) { - parent = (this.parentSegments) ? - lookup(this.parentSegments, controller) : - controller; - fn = parent && parent[this.lastSegment]; - if (fn) break; - controller = controller.parent; - } - var setFn = fn && fn.set; - if (!setFn) throw new Error('No setter function for: ' + this.segments.join('.')); - var inputs = this._getInputs(context); - inputs.unshift(value); - var out = setFn.apply(parent, inputs); - for (var i in out) { - this.args[i].set(context, out[i]); - } -}; - -function NewExpression(segments, args, afterSegments, meta) { - FnExpression.call(this, segments, args, afterSegments, meta); -} -NewExpression.prototype = Object.create(FnExpression.prototype); -NewExpression.prototype.constructor = NewExpression; -NewExpression.prototype.type = 'NewExpression'; -NewExpression.prototype._applyFn = function(Fn, context) { - // Apply if there are no path inputs - if (!this.args) return new Fn(); - // Otherwise, get the current value for path inputs and apply - var inputs = this._getInputs(context); - inputs.unshift(null); - return new (Fn.bind.apply(Fn, inputs))(); -}; - -function OperatorExpression(name, args, afterSegments, meta) { - this.name = name; - this.args = args; - this.afterSegments = afterSegments; - this.meta = meta; - this.getFn = operatorFns.get[name]; - this.setFn = operatorFns.set[name]; -} -OperatorExpression.prototype = Object.create(FnExpression.prototype); -OperatorExpression.prototype.constructor = OperatorExpression; -OperatorExpression.prototype.type = 'OperatorExpression'; -OperatorExpression.prototype.serialize = function() { - return serializeObject.instance(this, this.name, this.args, this.afterSegments, this.meta); -}; -OperatorExpression.prototype.apply = function(context) { - var inputs = this._getInputs(context); - return this.getFn.apply(null, inputs); -}; -OperatorExpression.prototype.set = function(context, value) { - var inputs = this._getInputs(context); - inputs.unshift(value); - var out = this.setFn.apply(null, inputs); - for (var i in out) { - this.args[i].set(context, out[i]); - } -}; - -function SequenceExpression(args, afterSegments, meta) { - this.args = args; - this.afterSegments = afterSegments; - this.meta = meta; -} -SequenceExpression.prototype = Object.create(OperatorExpression.prototype); -SequenceExpression.prototype.constructor = SequenceExpression; -SequenceExpression.prototype.type = 'SequenceExpression'; -SequenceExpression.prototype.serialize = function() { - return serializeObject.instance(this, this.args, this.afterSegments, this.meta); -}; -SequenceExpression.prototype.name = ','; -SequenceExpression.prototype.getFn = operatorFns.get[',']; -SequenceExpression.prototype.resolve = function(context) { - var last = this.args[this.args.length - 1]; - return last.resolve(context); -}; -SequenceExpression.prototype.dependencies = function(context, options) { - var dependencies = []; - for (var i = 0, len = this.args.length; i < len; i++) { - var argDependencies = this.args[i].dependencies(context, options); - for (var j = 0, jLen = argDependencies.length; j < jLen; j++) { - dependencies.push(argDependencies[j]); - } - } - return dependencies; -}; - -// For each method that takes a context argument, get the nearest parent view -// context, then delegate methods to the inner expression -function ViewParentExpression(expression, meta) { - this.expression = expression; - this.meta = meta; -} -ViewParentExpression.prototype = Object.create(Expression.prototype); -ViewParentExpression.prototype.constructor = ViewParentExpression; -ViewParentExpression.prototype.type = 'ViewParentExpression'; -ViewParentExpression.prototype.serialize = function() { - return serializeObject.instance(this, this.expression, this.meta); -}; -ViewParentExpression.prototype.get = function(context) { - var parentContext = context.forViewParent(); - return this.expression.get(parentContext); -}; -ViewParentExpression.prototype.resolve = function(context) { - var parentContext = context.forViewParent(); - return this.expression.resolve(parentContext); -}; -ViewParentExpression.prototype.dependencies = function(context, options) { - var parentContext = context.forViewParent(); - return this.expression.dependencies(parentContext, options); -}; -ViewParentExpression.prototype.pathSegments = function(context) { - var parentContext = context.forViewParent(); - return this.expression.pathSegments(parentContext); -}; -ViewParentExpression.prototype.set = function(context, value) { - var parentContext = context.forViewParent(); - return this.expression.set(parentContext, value); -}; - -function ScopedModelExpression(expression, meta) { - this.expression = expression; - this.meta = meta; -} -ScopedModelExpression.prototype = Object.create(Expression.prototype); -ScopedModelExpression.prototype.constructor = ScopedModelExpression; -ScopedModelExpression.prototype.type = 'ScopedModelExpression'; -ScopedModelExpression.prototype.serialize = function() { - return serializeObject.instance(this, this.expression, this.meta); -}; -// Return a scoped model instead of the value -ScopedModelExpression.prototype.get = function(context) { - var segments = this.pathSegments(context); - if (!segments) return; - return context.controller.model.scope(segments.join('.')); -}; -// Delegate other methods to the inner expression -ScopedModelExpression.prototype.resolve = function(context) { - return this.expression.resolve(context); -}; -ScopedModelExpression.prototype.dependencies = function(context, options) { - return this.expression.dependencies(context, options); -}; -ScopedModelExpression.prototype.pathSegments = function(context) { - return this.expression.pathSegments(context); -}; -ScopedModelExpression.prototype.set = function(context, value) { - return this.expression.set(context, value); -}; - -function getDependencies(value, context, options) { - if (value instanceof Expression || value instanceof Template) { - return value.dependencies(context, options); - } -} - -function appendDependency(dependencies, expression, context) { - var segments = expression.resolve(context); - if (!segments) return dependencies; - if (dependencies) { - dependencies.push(segments); - return dependencies; - } - return [segments]; -} - -function swapLastDependency(dependencies, expression, context) { - if (!expression.segments.length) { - return dependencies; - } - var segments = expression.resolve(context); - if (!segments) return dependencies; - if (dependencies) { - dependencies.pop(); - dependencies.push(segments); - return dependencies; - } - return [segments]; -} diff --git a/lib/templates/index.js b/lib/templates/index.js deleted file mode 100644 index 22ee76465..000000000 --- a/lib/templates/index.js +++ /dev/null @@ -1,5 +0,0 @@ -exports.contexts = require('./contexts'); -exports.expressions = require('./expressions'); -exports.operatorFns = require('./operatorFns'); -exports.options = require('./dependencyOptions'); -exports.templates = require('./templates'); diff --git a/lib/templates/templates.js b/lib/templates/templates.js deleted file mode 100644 index bc7793837..000000000 --- a/lib/templates/templates.js +++ /dev/null @@ -1,2107 +0,0 @@ -if (typeof require === 'function') { - var serializeObject = require('serialize-object'); -} -var DependencyOptions = require('./dependencyOptions').DependencyOptions; -var util = require('./util'); -var concat = util.concat; -var hasKeys = util.hasKeys; -var traverseAndCreate = util.traverseAndCreate; - -// UPDATE_PROPERTIES map HTML attribute names to an Element DOM property that -// should be used for setting on bindings updates instead of setAttribute. -// -// https://github.com/jquery/jquery/blob/1.x-master/src/attributes/prop.js -// https://github.com/jquery/jquery/blob/master/src/attributes/prop.js -// http://webbugtrack.blogspot.com/2007/08/bug-242-setattribute-doesnt-always-work.html -var BOOLEAN_PROPERTIES = { - checked: 'checked', - disabled: 'disabled', - indeterminate: 'indeterminate', - readonly: 'readOnly', - selected: 'selected' -}; -var INTEGER_PROPERTIES = { - colspan: 'colSpan', - maxlength: 'maxLength', - rowspan: 'rowSpan', - tabindex: 'tabIndex' -}; -var STRING_PROPERTIES = { - cellpadding: 'cellPadding', - cellspacing: 'cellSpacing', - 'class': 'className', - contenteditable: 'contentEditable', - enctype: 'encoding', - 'for': 'htmlFor', - frameborder: 'frameBorder', - id: 'id', - title: 'title', - type: 'type', - usemap: 'useMap', - value: 'value' -}; -var UPDATE_PROPERTIES = {}; -mergeInto(BOOLEAN_PROPERTIES, UPDATE_PROPERTIES); -mergeInto(INTEGER_PROPERTIES, UPDATE_PROPERTIES); -mergeInto(STRING_PROPERTIES, UPDATE_PROPERTIES); - -// CREATE_PROPERTIES map HTML attribute names to an Element DOM property that -// should be used for setting on Element rendering instead of setAttribute. -// input.defaultChecked and input.defaultValue affect the attribute, so we want -// to use these for initial dynamic rendering. For binding updates, -// input.checked and input.value are modified. -var CREATE_PROPERTIES = {}; -mergeInto(UPDATE_PROPERTIES, CREATE_PROPERTIES); -CREATE_PROPERTIES.checked = 'defaultChecked'; -CREATE_PROPERTIES.value = 'defaultValue'; - -// http://www.w3.org/html/wg/drafts/html/master/syntax.html#void-elements -var VOID_ELEMENTS = { - area: true, - base: true, - br: true, - col: true, - embed: true, - hr: true, - img: true, - input: true, - keygen: true, - link: true, - menuitem: true, - meta: true, - param: true, - source: true, - track: true, - wbr: true -}; - -var NAMESPACE_URIS = { - svg: 'http://www.w3.org/2000/svg', - xlink: 'http://www.w3.org/1999/xlink', - xmlns: 'http://www.w3.org/2000/xmlns/' -}; - -exports.CREATE_PROPERTIES = CREATE_PROPERTIES; -exports.BOOLEAN_PROPERTIES = BOOLEAN_PROPERTIES; -exports.INTEGER_PROPERTIES = INTEGER_PROPERTIES; -exports.STRING_PROPERTIES = STRING_PROPERTIES; -exports.UPDATE_PROPERTIES = UPDATE_PROPERTIES; -exports.VOID_ELEMENTS = VOID_ELEMENTS; -exports.NAMESPACE_URIS = NAMESPACE_URIS; - -// Template Classes -exports.Template = Template; -exports.Doctype = Doctype; -exports.Text = Text; -exports.DynamicText = DynamicText; -exports.Comment = Comment; -exports.DynamicComment = DynamicComment; -exports.Html = Html; -exports.DynamicHtml = DynamicHtml; -exports.Element = Element; -exports.DynamicElement = DynamicElement; -exports.Block = Block; -exports.ConditionalBlock = ConditionalBlock; -exports.EachBlock = EachBlock; - -exports.Attribute = Attribute; -exports.DynamicAttribute = DynamicAttribute; - -// Binding Classes -exports.Binding = Binding; -exports.NodeBinding = NodeBinding; -exports.AttributeBinding = AttributeBinding; -exports.RangeBinding = RangeBinding; - -function Template(content, source) { - this.content = content; - this.source = source; -} -Template.prototype.toString = function() { - return this.source; -}; -Template.prototype.get = function(context, unescaped) { - return contentHtml(this.content, context, unescaped); -}; -Template.prototype.getFragment = function(context, binding) { - var fragment = document.createDocumentFragment(); - this.appendTo(fragment, context, binding); - return fragment; -}; -Template.prototype.appendTo = function(parent, context) { - context.pause(); - appendContent(parent, this.content, context); - context.unpause(); -}; -Template.prototype.attachTo = function(parent, node, context) { - context.pause(); - var node = attachContent(parent, node, this.content, context); - context.unpause(); - return node; -}; -Template.prototype.update = function() {}; -Template.prototype.stringify = function(value) { - return (value == null) ? '' : value + ''; -}; -Template.prototype.equals = function(other) { - return this === other; -}; -Template.prototype.module = 'templates'; -Template.prototype.type = 'Template'; -Template.prototype.serialize = function() { - return serializeObject.instance(this, this.content, this.source); -}; - - -function Doctype(name, publicId, systemId) { - this.name = name; - this.publicId = publicId; - this.systemId = systemId; -} -Doctype.prototype = Object.create(Template.prototype); -Doctype.prototype.constructor = Doctype; -Doctype.prototype.get = function() { - var publicText = (this.publicId) ? - ' PUBLIC "' + this.publicId + '"' : - ''; - var systemText = (this.systemId) ? - (this.publicId) ? - ' "' + this.systemId + '"' : - ' SYSTEM "' + this.systemId + '"' : - ''; - return ''; -}; -Doctype.prototype.appendTo = function() { - // Doctype could be created via: - // document.implementation.createDocumentType(this.name, this.publicId, this.systemId) - // However, it does not appear possible or useful to append it to the - // document fragment. Therefore, just don't render it in the browser -}; -Doctype.prototype.attachTo = function(parent, node) { - if (!node || node.nodeType !== 10) { - throw attachError(parent, node); - } - return node.nextSibling; -}; -Doctype.prototype.type = 'Doctype'; -Doctype.prototype.serialize = function() { - return serializeObject.instance(this, this.name, this.publicId, this.systemId); -}; - -function Text(data) { - this.data = data; - this.escaped = escapeHtml(data); -} -Text.prototype = Object.create(Template.prototype); -Text.prototype.constructor = Text; -Text.prototype.get = function(context, unescaped) { - return (unescaped) ? this.data : this.escaped; -}; -Text.prototype.appendTo = function(parent) { - var node = document.createTextNode(this.data); - parent.appendChild(node); -}; -Text.prototype.attachTo = function(parent, node) { - return attachText(parent, node, this.data, this); -}; -Text.prototype.type = 'Text'; -Text.prototype.serialize = function() { - return serializeObject.instance(this, this.data); -}; - -// DynamicText might be more accurately named DynamicContent. When its -// expression returns a template, it acts similar to a Block, and it renders -// the template surrounded by comment markers for range replacement. When its -// expression returns any other type, it renders a DOM Text node with no -// markers. Text nodes are bound by updating their data property dynamically. -// The update method must take care to switch between these types of bindings -// in case the expression return type changes dynamically. -function DynamicText(expression) { - this.expression = expression; - this.unbound = false; -} -DynamicText.prototype = Object.create(Template.prototype); -DynamicText.prototype.constructor = DynamicText; -DynamicText.prototype.get = function(context, unescaped) { - var value = this.expression.get(context); - if (value instanceof Template) { - do { - value = value.get(context, unescaped); - } while (value instanceof Template); - return value; - } - var data = this.stringify(value); - return (unescaped) ? data : escapeHtml(data); -}; -DynamicText.prototype.appendTo = function(parent, context, binding) { - var value = this.expression.get(context); - if (value instanceof Template) { - var start = document.createComment(this.expression); - var end = document.createComment('/' + this.expression); - var condition = this.getCondition(context); - parent.appendChild(start); - value.appendTo(parent, context); - parent.appendChild(end); - updateRange(context, binding, this, start, end, null, condition); - return; - } - var data = this.stringify(value); - var node = document.createTextNode(data); - parent.appendChild(node); - addNodeBinding(this, context, node); -}; -DynamicText.prototype.attachTo = function(parent, node, context) { - var value = this.expression.get(context); - if (value instanceof Template) { - var start = document.createComment(this.expression); - var end = document.createComment('/' + this.expression); - var condition = this.getCondition(context); - parent.insertBefore(start, node || null); - node = value.attachTo(parent, node, context); - parent.insertBefore(end, node || null); - updateRange(context, null, this, start, end, null, condition); - return node; - } - var data = this.stringify(value); - return attachText(parent, node, data, this, context); -}; -DynamicText.prototype.update = function(context, binding) { - if (binding instanceof RangeBinding) { - this._blockUpdate(context, binding); - return; - } - var value = this.expression.get(context); - if (value instanceof Template) { - var start = binding.node; - if (!start.parentNode) return; - var end = start; - var fragment = this.getFragment(context); - replaceRange(context, start, end, fragment, binding); - return; - } - binding.node.data = this.stringify(value); -}; -DynamicText.prototype.getCondition = function(context) { - return this.expression.get(context); -}; -DynamicText.prototype.type = 'DynamicText'; -DynamicText.prototype.serialize = function() { - return serializeObject.instance(this, this.expression); -}; - -function attachText(parent, node, data, template, context) { - if (!node) { - var newNode = document.createTextNode(data); - parent.appendChild(newNode); - addNodeBinding(template, context, newNode); - return; - } - if (node.nodeType === 3) { - // Proceed if nodes already match - if (node.data === data) { - addNodeBinding(template, context, node); - return node.nextSibling; - } - data = normalizeLineBreaks(data); - // Split adjacent text nodes that would have been merged together in HTML - var nextNode = splitData(node, data.length); - if (node.data !== data) { - throw attachError(parent, node); - } - addNodeBinding(template, context, node); - return nextNode; - } - // An empty text node might not be created at the end of some text - if (data === '') { - var newNode = document.createTextNode(''); - parent.insertBefore(newNode, node || null); - addNodeBinding(template, context, newNode); - return node; - } - throw attachError(parent, node); -} - -function Comment(data, hooks) { - this.data = data; - this.hooks = hooks; -} -Comment.prototype = Object.create(Template.prototype); -Comment.prototype.constructor = Comment; -Comment.prototype.get = function() { - return ''; -}; -Comment.prototype.appendTo = function(parent, context) { - var node = document.createComment(this.data); - parent.appendChild(node); - emitHooks(this.hooks, context, node); -}; -Comment.prototype.attachTo = function(parent, node, context) { - return attachComment(parent, node, this.data, this, context); -}; -Comment.prototype.type = 'Comment'; -Comment.prototype.serialize = function() { - return serializeObject.instance(this, this.data, this.hooks); -} - -function DynamicComment(expression, hooks) { - this.expression = expression; - this.hooks = hooks; -} -DynamicComment.prototype = Object.create(Template.prototype); -DynamicComment.prototype.constructor = DynamicComment; -DynamicComment.prototype.get = function(context) { - var value = getUnescapedValue(this.expression, context); - var data = this.stringify(value); - return ''; -}; -DynamicComment.prototype.appendTo = function(parent, context) { - var value = getUnescapedValue(this.expression, context); - var data = this.stringify(value); - var node = document.createComment(data); - parent.appendChild(node); - addNodeBinding(this, context, node); -}; -DynamicComment.prototype.attachTo = function(parent, node, context) { - var value = getUnescapedValue(this.expression, context); - var data = this.stringify(value); - return attachComment(parent, node, data, this, context); -}; -DynamicComment.prototype.update = function(context, binding) { - var value = getUnescapedValue(this.expression, context); - binding.node.data = this.stringify(value); -}; -DynamicComment.prototype.type = 'DynamicComment'; -DynamicComment.prototype.serialize = function() { - return serializeObject.instance(this, this.expression, this.hooks); -} - -function attachComment(parent, node, data, template, context) { - // Sometimes IE fails to create Comment nodes from HTML or innerHTML. - // This is an issue inside of , then once they've typed "1.0", - // the context value is set to `1`, triggering this update function to set the input value to - // "1". That means typing "1.01" would be impossible without special handling to avoid - // overwriting an existing input value of "1.0" with a new value of "1". - if (element.tagName === 'INPUT' && propertyName === 'value' && typeof value === 'number') { - if (parseFloat(element.value) === value) { - return; - } - } - var propertyValue = (STRING_PROPERTIES[binding.name]) ? - this.stringify(value) : value; - if (element[propertyName] === propertyValue) return; - element[propertyName] = propertyValue; - return; - } - if (value === false || value == null) { - if (this.ns) { - element.removeAttributeNS(this.ns, binding.name); - } else { - element.removeAttribute(binding.name); - } - return; - } - if (value === true) value = binding.name; - if (this.ns) { - element.setAttributeNS(this.ns, binding.name, value); - } else { - element.setAttribute(binding.name, value); - } -}; -DynamicAttribute.prototype.type = 'DynamicAttribute'; -DynamicAttribute.prototype.serialize = function() { - return serializeObject.instance(this, this.expression, this.ns); -}; - -function getUnescapedValue(expression, context) { - var unescaped = true; - var value = expression.get(context, unescaped); - while (value instanceof Template) { - value = value.get(context, unescaped); - } - return value; -} - -function Element(tagName, attributes, content, hooks, selfClosing, notClosed, ns) { - this.tagName = tagName; - this.attributes = attributes; - this.content = content; - this.hooks = hooks; - this.selfClosing = selfClosing; - this.notClosed = notClosed; - this.ns = ns; - - this.endTag = getEndTag(tagName, selfClosing, notClosed); - this.startClose = getStartClose(selfClosing); - var lowerTagName = tagName && tagName.toLowerCase(); - this.unescapedContent = (lowerTagName === 'script' || lowerTagName === 'style'); - this.bindContentToValue = (lowerTagName === 'textarea'); -} -Element.prototype = Object.create(Template.prototype); -Element.prototype.constructor = Element; -Element.prototype.getTagName = function() { - return this.tagName; -}; -Element.prototype.getEndTag = function() { - return this.endTag; -}; -Element.prototype.get = function(context) { - var tagName = this.getTagName(context); - var endTag = this.getEndTag(tagName); - var tagItems = [tagName]; - for (var key in this.attributes) { - var value = this.attributes[key].get(context); - if (value === true) { - tagItems.push(key); - } else if (value !== false && value != null) { - tagItems.push(key + '="' + escapeAttribute(value) + '"'); - } - } - var startTag = '<' + tagItems.join(' ') + this.startClose; - if (this.content) { - var inner = contentHtml(this.content, context, this.unescapedContent); - return startTag + inner + endTag; - } - return startTag + endTag; -}; -Element.prototype.appendTo = function(parent, context) { - var tagName = this.getTagName(context); - var element = (this.ns) ? - document.createElementNS(this.ns, tagName) : - document.createElement(tagName); - for (var key in this.attributes) { - var attribute = this.attributes[key]; - var value = attribute.getBound(context, element, key, this.ns); - if (value === false || value == null) continue; - var propertyName = !this.ns && CREATE_PROPERTIES[key]; - if (propertyName) { - element[propertyName] = value; - continue; - } - if (value === true) value = key; - if (attribute.ns) { - element.setAttributeNS(attribute.ns, key, value); - } else { - element.setAttribute(key, value); - } - } - if (this.content) { - this._bindContent(context, element); - appendContent(element, this.content, context); - } - parent.appendChild(element); - emitHooks(this.hooks, context, element); -}; -Element.prototype.attachTo = function(parent, node, context) { - var tagName = this.getTagName(context); - if ( - !node || - node.nodeType !== 1 || - node.tagName.toLowerCase() !== tagName.toLowerCase() - ) { - throw attachError(parent, node); - } - for (var key in this.attributes) { - // Get each attribute to create bindings - this.attributes[key].getBound(context, node, key, this.ns); - // TODO: Ideally, this would also check that the node's current attributes - // are equivalent, but there are some tricky edge cases - } - if (this.content) { - this._bindContent(context, node); - attachContent(node, node.firstChild, this.content, context); - } - emitHooks(this.hooks, context, node); - return node.nextSibling; -}; -Element.prototype._bindContent = function(context, element) { - // For textareas with dynamic text content, bind to the value property - var child = this.bindContentToValue && - this.content.length === 1 && - this.content[0]; - if (child instanceof DynamicText) { - child.unbound = true; - var template = new DynamicAttribute(child.expression); - context.addBinding(new AttributeBinding(template, context, element, 'value')); - } -}; -Element.prototype.type = 'Element'; -Element.prototype.serialize = function() { - return serializeObject.instance( - this, - this.tagName, - this.attributes, - this.content, - this.hooks, - this.selfClosing, - this.notClosed, - this.ns - ); -}; - -function DynamicElement(tagName, attributes, content, hooks, selfClosing, notClosed, ns) { - this.tagName = tagName; - this.attributes = attributes; - this.content = content; - this.hooks = hooks; - this.selfClosing = selfClosing; - this.notClosed = notClosed; - this.ns = ns; - - this.startClose = getStartClose(selfClosing); - this.unescapedContent = false; -} -DynamicElement.prototype = Object.create(Element.prototype); -DynamicElement.prototype.constructor = DynamicElement; -DynamicElement.prototype.getTagName = function(context) { - return getUnescapedValue(this.tagName, context); -}; -DynamicElement.prototype.getEndTag = function(tagName) { - return getEndTag(tagName, this.selfClosing, this.notClosed); -}; -DynamicElement.prototype.type = 'DynamicElement'; - -function getStartClose(selfClosing) { - return (selfClosing) ? ' />' : '>'; -} - -function getEndTag(tagName, selfClosing, notClosed) { - var lowerTagName = tagName && tagName.toLowerCase(); - var isVoid = VOID_ELEMENTS[lowerTagName]; - return (isVoid || selfClosing || notClosed) ? '' : ''; -} - -function getAttributeValue(element, name) { - var propertyName = UPDATE_PROPERTIES[name]; - return (propertyName) ? element[propertyName] : element.getAttribute(name); -} - -function emitHooks(hooks, context, value) { - if (!hooks) return; - context.queue(function queuedHooks() { - for (var i = 0, len = hooks.length; i < len; i++) { - hooks[i].emit(context, value); - } - }); -} - -function Block(expression, content) { - this.expression = expression; - this.ending = '/' + expression; - this.content = content; -} -Block.prototype = Object.create(Template.prototype); -Block.prototype.constructor = Block; -Block.prototype.get = function(context, unescaped) { - var blockContext = context.child(this.expression); - return contentHtml(this.content, blockContext, unescaped); -}; -Block.prototype.appendTo = function(parent, context, binding) { - var blockContext = context.child(this.expression); - var start = document.createComment(this.expression); - var end = document.createComment(this.ending); - var condition = this.getCondition(context); - parent.appendChild(start); - appendContent(parent, this.content, blockContext); - parent.appendChild(end); - updateRange(context, binding, this, start, end, null, condition); -}; -Block.prototype.attachTo = function(parent, node, context) { - var blockContext = context.child(this.expression); - var start = document.createComment(this.expression); - var end = document.createComment(this.ending); - var condition = this.getCondition(context); - parent.insertBefore(start, node || null); - node = attachContent(parent, node, this.content, blockContext); - parent.insertBefore(end, node || null); - updateRange(context, null, this, start, end, null, condition); - return node; -}; -Block.prototype.type = 'Block'; -Block.prototype.serialize = function() { - return serializeObject.instance(this, this.expression, this.content); -}; -Block.prototype.update = function(context, binding) { - if (!binding.start.parentNode) return; - var condition = this.getCondition(context); - // Cancel update if prior condition is equivalent to current value - if (equalConditions(condition, binding.condition)) return; - binding.condition = condition; - // Get start and end in advance, since binding is mutated in getFragment - var start = binding.start; - var end = binding.end; - var fragment = this.getFragment(context, binding); - replaceRange(context, start, end, fragment, binding); -}; -Block.prototype.getCondition = function(context) { - // We do an identity check to see if the value has changed before updating. - // With objects, the object would still be the same, so this identity check - // would fail to update enough. Thus, return NaN, which never equals anything - // including itself, so that we always update on objects. - // - // We could also JSON stringify or use some other hashing approach. However, - // that could be really expensive on gets of things that never change, and - // is probably not a good tradeoff. Perhaps there should be a separate block - // type that is only used in the case of dynamic updates - var value = this.expression.get(context); - return (typeof value === 'object') ? NaN : value; -}; -DynamicText.prototype._blockUpdate = Block.prototype.update; - -function ConditionalBlock(expressions, contents) { - this.expressions = expressions; - this.beginning = expressions.join('; '); - this.ending = '/' + this.beginning; - this.contents = contents; -} -ConditionalBlock.prototype = Object.create(Block.prototype); -ConditionalBlock.prototype.constructor = ConditionalBlock; -ConditionalBlock.prototype.get = function(context, unescaped) { - var condition = this.getCondition(context); - if (condition == null) return ''; - var expression = this.expressions[condition]; - var blockContext = context.child(expression); - return contentHtml(this.contents[condition], blockContext, unescaped); -}; -ConditionalBlock.prototype.appendTo = function(parent, context, binding) { - var start = document.createComment(this.beginning); - var end = document.createComment(this.ending); - parent.appendChild(start); - var condition = this.getCondition(context); - if (condition != null) { - var expression = this.expressions[condition]; - var blockContext = context.child(expression); - appendContent(parent, this.contents[condition], blockContext); - } - parent.appendChild(end); - updateRange(context, binding, this, start, end, null, condition); -}; -ConditionalBlock.prototype.attachTo = function(parent, node, context) { - var start = document.createComment(this.beginning); - var end = document.createComment(this.ending); - parent.insertBefore(start, node || null); - var condition = this.getCondition(context); - if (condition != null) { - var expression = this.expressions[condition]; - var blockContext = context.child(expression); - node = attachContent(parent, node, this.contents[condition], blockContext); - } - parent.insertBefore(end, node || null); - updateRange(context, null, this, start, end, null, condition); - return node; -}; -ConditionalBlock.prototype.type = 'ConditionalBlock'; -ConditionalBlock.prototype.serialize = function() { - return serializeObject.instance(this, this.expressions, this.contents); -}; -ConditionalBlock.prototype.update = function(context, binding) { - if (!binding.start.parentNode) return; - var condition = this.getCondition(context); - // Cancel update if prior condition is equivalent to current value - if (equalConditions(condition, binding.condition)) return; - binding.condition = condition; - // Get start and end in advance, since binding is mutated in getFragment - var start = binding.start; - var end = binding.end; - var fragment = this.getFragment(context, binding); - replaceRange(context, start, end, fragment, binding); -}; -ConditionalBlock.prototype.getCondition = function(context) { - for (var i = 0, len = this.expressions.length; i < len; i++) { - if (this.expressions[i].truthy(context)) { - return i; - } - } -}; - -function EachBlock(expression, content, elseContent) { - this.expression = expression; - this.ending = '/' + expression; - this.content = content; - this.elseContent = elseContent; -} -EachBlock.prototype = Object.create(Block.prototype); -EachBlock.prototype.constructor = EachBlock; -EachBlock.prototype.get = function(context, unescaped) { - var items = this.expression.get(context); - if (items && items.length) { - var html = ''; - for (var i = 0, len = items.length; i < len; i++) { - var itemContext = context.eachChild(this.expression, i); - html += contentHtml(this.content, itemContext, unescaped); - } - return html; - } else if (this.elseContent) { - return contentHtml(this.elseContent, context, unescaped); - } - return ''; -}; -EachBlock.prototype.appendTo = function(parent, context, binding) { - var items = this.expression.get(context); - var start = document.createComment(this.expression); - var end = document.createComment(this.ending); - parent.appendChild(start); - if (items && items.length) { - for (var i = 0, len = items.length; i < len; i++) { - var itemContext = context.eachChild(this.expression, i); - this.appendItemTo(parent, itemContext, start); - } - } else if (this.elseContent) { - appendContent(parent, this.elseContent, context); - } - parent.appendChild(end); - updateRange(context, binding, this, start, end); -}; -EachBlock.prototype.appendItemTo = function(parent, context, itemFor, binding) { - var before = parent.lastChild; - var start, end; - appendContent(parent, this.content, context); - if (before === parent.lastChild) { - start = end = document.createComment('empty'); - parent.appendChild(start); - } else { - start = (before && before.nextSibling) || parent.firstChild; - end = parent.lastChild; - } - updateRange(context, binding, this, start, end, itemFor); -}; -EachBlock.prototype.attachTo = function(parent, node, context) { - var items = this.expression.get(context); - var start = document.createComment(this.expression); - var end = document.createComment(this.ending); - parent.insertBefore(start, node || null); - if (items && items.length) { - for (var i = 0, len = items.length; i < len; i++) { - var itemContext = context.eachChild(this.expression, i); - node = this.attachItemTo(parent, node, itemContext, start); - } - } else if (this.elseContent) { - node = attachContent(parent, node, this.elseContent, context); - } - parent.insertBefore(end, node || null); - updateRange(context, null, this, start, end); - return node; -}; -EachBlock.prototype.attachItemTo = function(parent, node, context, itemFor) { - var start, end; - var oldPrevious = node && node.previousSibling; - var nextNode = attachContent(parent, node, this.content, context); - if (nextNode === node) { - start = end = document.createComment('empty'); - parent.insertBefore(start, node || null); - } else { - start = (oldPrevious && oldPrevious.nextSibling) || parent.firstChild; - end = (nextNode && nextNode.previousSibling) || parent.lastChild; - } - updateRange(context, null, this, start, end, itemFor); - return nextNode; -}; -EachBlock.prototype.update = function(context, binding) { - if (!binding.start.parentNode) return; - var start = binding.start; - var end = binding.end; - if (binding.itemFor) { - var fragment = document.createDocumentFragment(); - this.appendItemTo(fragment, context, binding.itemFor, binding); - } else { - var fragment = this.getFragment(context, binding); - } - replaceRange(context, start, end, fragment, binding); -}; -EachBlock.prototype.insert = function(context, binding, index, howMany) { - var parent = binding.start.parentNode; - if (!parent) return; - // In case we are inserting all of the items, update instead. This is needed - // when we were previously rendering elseContent so that it is replaced - if (index === 0 && this.expression.get(context).length === howMany) { - return this.update(context, binding); - } - var node = indexStartNode(binding, index); - var fragment = document.createDocumentFragment(); - for (var i = index, len = index + howMany; i < len; i++) { - var itemContext = context.eachChild(this.expression, i); - this.appendItemTo(fragment, itemContext, binding.start); - } - parent.insertBefore(fragment, node || null); -}; -EachBlock.prototype.remove = function(context, binding, index, howMany) { - var parent = binding.start.parentNode; - if (!parent) return; - // In case we are removing all of the items, update instead. This is needed - // when elseContent should be rendered - if (index === 0 && this.expression.get(context).length === 0) { - return this.update(context, binding); - } - var node = indexStartNode(binding, index); - var i = 0; - while (node) { - if (node === binding.end) return; - if (node.$bindItemStart && node.$bindItemStart.itemFor === binding.start) { - if (howMany === i++) return; - } - var nextNode = node.nextSibling; - parent.removeChild(node); - emitRemoved(context, node, binding); - node = nextNode; - } -}; -EachBlock.prototype.move = function(context, binding, from, to, howMany) { - var parent = binding.start.parentNode; - if (!parent) return; - var node = indexStartNode(binding, from); - var fragment = document.createDocumentFragment(); - var i = 0; - while (node) { - if (node === binding.end) break; - if (node.$bindItemStart && node.$bindItemStart.itemFor === binding.start) { - if (howMany === i++) break; - } - var nextNode = node.nextSibling; - fragment.appendChild(node); - node = nextNode; - } - node = indexStartNode(binding, to); - parent.insertBefore(fragment, node || null); -}; -EachBlock.prototype.type = 'EachBlock'; -EachBlock.prototype.serialize = function() { - return serializeObject.instance(this, this.expression, this.content, this.elseContent); -}; - -function indexStartNode(binding, index) { - var node = binding.start; - var i = 0; - while ((node = node.nextSibling)) { - if (node === binding.end) return node; - if (node.$bindItemStart && node.$bindItemStart.itemFor === binding.start) { - if (index === i) return node; - i++; - } - } -} - -function updateRange(context, binding, template, start, end, itemFor, condition) { - if (binding) { - binding.start = start; - binding.end = end; - binding.condition = condition; - setNodeBounds(binding, start, itemFor); - } else { - context.addBinding(new RangeBinding(template, context, start, end, itemFor, condition)); - } -} -function setNodeBounds(binding, start, itemFor) { - if (itemFor) { - setNodeProperty(start, '$bindItemStart', binding); - } else { - setNodeProperty(start, '$bindStart', binding); - } -} - -function appendContent(parent, content, context) { - for (var i = 0, len = content.length; i < len; i++) { - content[i].appendTo(parent, context); - } -} -function attachContent(parent, node, content, context) { - for (var i = 0, len = content.length; i < len; i++) { - while (node && node.hasAttribute && node.hasAttribute('data-no-attach')) { - node = node.nextSibling; - } - node = content[i].attachTo(parent, node, context); - } - return node; -} -function contentHtml(content, context, unescaped) { - var html = ''; - for (var i = 0, len = content.length; i < len; i++) { - html += content[i].get(context, unescaped); - } - return html; -} -function replaceRange(context, start, end, fragment, binding, innerOnly) { - // Note: the calling function must make sure to check that there is a parent - var parent = start.parentNode; - // Copy item binding from old start to fragment being inserted - if (start.$bindItemStart && fragment.firstChild) { - setNodeProperty(fragment.firstChild, '$bindItemStart', start.$bindItemStart); - start.$bindItemStart.start = fragment.firstChild; - } - // Fast path for single node replacements - if (start === end) { - parent.replaceChild(fragment, start); - emitRemoved(context, start, binding); - return; - } - // Remove all nodes from start to end - var node = (innerOnly) ? start.nextSibling : start; - var nextNode; - while (node) { - nextNode = node.nextSibling; - emitRemoved(context, node, binding); - if (innerOnly && node === end) { - nextNode = end; - break; - } - parent.removeChild(node); - if (node === end) break; - node = nextNode; - } - // This also works if nextNode is null, by doing an append - parent.insertBefore(fragment, nextNode || null); -} -function emitRemoved(context, node, ignore) { - context.removeNode(node); - emitRemovedBinding(context, ignore, node, '$bindNode'); - emitRemovedBinding(context, ignore, node, '$bindStart'); - emitRemovedBinding(context, ignore, node, '$bindItemStart'); - var attributes = node.$bindAttributes; - if (attributes) { - node.$bindAttributes = null; - for (var key in attributes) { - context.removeBinding(attributes[key]); - } - } - for (node = node.firstChild; node; node = node.nextSibling) { - emitRemoved(context, node, ignore); - } -} -function emitRemovedBinding(context, ignore, node, property) { - var binding = node[property]; - if (binding) { - node[property] = null; - if (binding !== ignore) { - context.removeBinding(binding); - } - } -} - -function attachError(parent, node) { - if (typeof console !== 'undefined') { - console.error('Attach failed for', node, 'within', parent); - } - return new Error('Attaching bindings failed, because HTML structure ' + - 'does not match client rendering.' - ); -} - -function Binding() { - this.meta = null; -} -Binding.prototype.type = 'Binding'; -Binding.prototype.update = function() { - this.context.pause(); - this.template.update(this.context, this); - this.context.unpause(); -}; -Binding.prototype.insert = function() { - this.update(); -}; -Binding.prototype.remove = function() { - this.update(); -}; -Binding.prototype.move = function() { - this.update(); -}; - -function NodeBinding(template, context, node) { - this.template = template; - this.context = context; - this.node = node; - this.meta = null; - setNodeProperty(node, '$bindNode', this); -} -NodeBinding.prototype = Object.create(Binding.prototype); -NodeBinding.prototype.constructor = NodeBinding; -NodeBinding.prototype.type = 'NodeBinding'; - -function AttributeBindingsMap() {} -function AttributeBinding(template, context, element, name) { - this.template = template; - this.context = context; - this.element = element; - this.name = name; - this.meta = null; - var map = element.$bindAttributes || - (element.$bindAttributes = new AttributeBindingsMap()); - map[name] = this; -} -AttributeBinding.prototype = Object.create(Binding.prototype); -AttributeBinding.prototype.constructor = AttributeBinding; -AttributeBinding.prototype.type = 'AttributeBinding'; - -function RangeBinding(template, context, start, end, itemFor, condition) { - this.template = template; - this.context = context; - this.start = start; - this.end = end; - this.itemFor = itemFor; - this.condition = condition; - this.meta = null; - setNodeBounds(this, start, itemFor); -} -RangeBinding.prototype = Object.create(Binding.prototype); -RangeBinding.prototype.constructor = RangeBinding; -RangeBinding.prototype.type = 'RangeBinding'; -RangeBinding.prototype.insert = function(index, howMany) { - this.context.pause(); - if (this.template.insert) { - this.template.insert(this.context, this, index, howMany); - } else { - this.template.update(this.context, this); - } - this.context.unpause(); -}; -RangeBinding.prototype.remove = function(index, howMany) { - this.context.pause(); - if (this.template.remove) { - this.template.remove(this.context, this, index, howMany); - } else { - this.template.update(this.context, this); - } - this.context.unpause(); -}; -RangeBinding.prototype.move = function(from, to, howMany) { - this.context.pause(); - if (this.template.move) { - this.template.move(this.context, this, from, to, howMany); - } else { - this.template.update(this.context, this); - } - this.context.unpause(); -}; - - -//// Utility functions //// - -function noop() {} - -function mergeInto(from, to) { - for (var key in from) { - to[key] = from[key]; - } -} - -function escapeHtml(string) { - string = string + ''; - return string.replace(/[&<]/g, function(match) { - return (match === '&') ? '&' : '<'; - }); -} - -function escapeAttribute(string) { - string = string + ''; - return string.replace(/[&"]/g, function(match) { - return (match === '&') ? '&' : '"'; - }); -} - -function equalConditions(a, b) { - // First, test for strict equality - if (a === b) return true; - // Failing that, allow for template objects used as a condition to define a - // custom `equals()` method to indicate equivalence - return (a instanceof Template) && a.equals(b); -} - - -//// Shims & workarounds //// - -// General notes: -// -// In all cases, Node.insertBefore should have `|| null` after its second -// argument. IE works correctly when the argument is ommitted or equal -// to null, but it throws and error if it is equal to undefined. - -if (!Array.isArray) { - Array.isArray = function(value) { - return Object.prototype.toString.call(value) === '[object Array]'; - }; -} - -// Equivalent to textNode.splitText, which is buggy in IE <=9 -function splitData(node, index) { - var newNode = node.cloneNode(false); - newNode.deleteData(0, index); - node.deleteData(index, node.length - index); - node.parentNode.insertBefore(newNode, node.nextSibling || null); - return newNode; -} - -// Defined so that it can be overriden in IE <=8 -function setNodeProperty(node, key, value) { - return node[key] = value; -} - -function normalizeLineBreaks(string) { - return string; -} - -(function() { - // Don't try to shim in Node.js environment - if (typeof document === 'undefined') return; - - var div = document.createElement('div'); - div.innerHTML = '\r\n
\n' - var windowsLength = div.firstChild.data.length; - var unixLength = div.lastChild.data.length; - if (windowsLength === 1 && unixLength === 1) { - normalizeLineBreaks = function(string) { - return string.replace(/\r\n/g, '\n'); - }; - } else if (windowsLength === 2 && unixLength === 2) { - normalizeLineBreaks = function(string) { - return string.replace(/(^|[^\r])(\n+)/g, function(match, value, newLines) { - for (var i = newLines.length; i--;) { - value += '\r\n'; - } - return value; - }); - }; - } - - // TODO: Shim createHtmlFragment for old IE - - // TODO: Shim setAttribute('style'), which doesn't work in IE <=7 - // http://webbugtrack.blogspot.com/2007/10/bug-245-setattribute-style-does-not.html - - // TODO: Investigate whether input name attribute works in IE <=7. We could - // override Element::appendTo to use IE's alternative createElement syntax: - // document.createElement('') - // http://webbugtrack.blogspot.com/2007/10/bug-235-createelement-is-broken-in-ie.html - - // In IE, input.defaultValue doesn't work correctly, so use input.value, - // which mistakenly but conveniently sets both the value property and attribute. - // - // Surprisingly, in IE <=7, input.defaultChecked must be used instead of - // input.checked before the input is in the document. - // http://webbugtrack.blogspot.com/2007/11/bug-299-setattribute-checked-does-not.html - var input = document.createElement('input'); - input.defaultValue = 'x'; - if (input.value !== 'x') { - CREATE_PROPERTIES.value = 'value'; - } - - try { - // TextNodes are not expando in IE <=8 - document.createTextNode('').$try = 0; - } catch (err) { - setNodeProperty = function(node, key, value) { - // If trying to set a property on a TextNode, create a proxy CommentNode - // and set the property on that node instead. Put the proxy after the - // TextNode if marking the end of a range, and before otherwise. - if (node.nodeType === 3) { - var proxyNode = node.previousSibling; - if (!proxyNode || proxyNode.$bindProxy !== node) { - proxyNode = document.createComment('proxy'); - proxyNode.$bindProxy = node; - node.parentNode.insertBefore(proxyNode, node || null); - } - return proxyNode[key] = value; - } - // Set the property directly on other node types - return node[key] = value; - }; - } -})(); - -exports.Marker = Marker; -exports.View = View; -exports.ViewInstance = ViewInstance; -exports.DynamicViewInstance = DynamicViewInstance; -exports.ViewParent = ViewParent; -exports.ContextClosure = ContextClosure; - -exports.Views = Views; - -exports.MarkupHook = MarkupHook; -exports.ElementOn = ElementOn; -exports.ComponentOn = ComponentOn; -exports.AsProperty = AsProperty; -exports.AsPropertyComponent = AsPropertyComponent; -exports.AsObject = AsObject; -exports.AsObjectComponent = AsObjectComponent; -exports.AsArray = AsArray; -exports.AsArrayComponent = AsArrayComponent; - -exports.emptyTemplate = new Template([]); - -exports.elementAddDestroyListener = elementAddDestroyListener; -exports.elementRemoveDestroyListener = elementRemoveDestroyListener; - -// Add ::isUnbound to Template && Binding -Template.prototype.isUnbound = function(context) { - return context.unbound; -}; -Binding.prototype.isUnbound = function() { - return this.template.expression.isUnbound(this.context); -}; - -// Add Template::resolve -Template.prototype.resolve = function() {}; - -// The Template::dependencies method is specific to how Derby bindings work, -// so extend all of the Saddle Template types here -Template.prototype.dependencies = function(context, options) { - if (DependencyOptions.shouldIgnoreTemplate(this, options)) return; - return concatArrayDependencies(null, this.content, context, options); -}; -Doctype.prototype.dependencies = function() {}; -Text.prototype.dependencies = function() {}; -DynamicText.prototype.dependencies = function(context, options) { - if (DependencyOptions.shouldIgnoreTemplate(this, options)) return; - return getDependencies(this.expression, context, options); -}; -Comment.prototype.dependencies = function() {}; -DynamicComment.prototype.dependencies = function(context, options) { - if (DependencyOptions.shouldIgnoreTemplate(this, options)) return; - return getDependencies(this.expression, context, options); -}; -Html.prototype.dependencies = function() {}; -DynamicHtml.prototype.dependencies = function(context, options) { - if (DependencyOptions.shouldIgnoreTemplate(this, options)) return; - return getDependencies(this.expression, context, options); -}; -Element.prototype.dependencies = function(context, options) { - if (DependencyOptions.shouldIgnoreTemplate(this, options)) return; - var dependencies = concatMapDependencies(null, this.attributes, context, options); - if (!this.content) return dependencies; - return concatArrayDependencies(dependencies, this.content, context, options); -}; -DynamicElement.prototype.dependencies = function(context, options) { - if (DependencyOptions.shouldIgnoreTemplate(this, options)) return; - var dependencies = Element.prototype.dependencies(context, options); - return concatDependencies(dependencies, this.tagName, context, options); -}; -Block.prototype.dependencies = function(context, options) { - if (DependencyOptions.shouldIgnoreTemplate(this, options)) return; - var dependencies = (this.expression.meta && this.expression.meta.blockType === 'on') ? - getDependencies(this.expression, context, options) : null; - var blockContext = context.child(this.expression); - return concatArrayDependencies(dependencies, this.content, blockContext, options); -}; -ConditionalBlock.prototype.dependencies = function(context, options) { - if (DependencyOptions.shouldIgnoreTemplate(this, options)) return; - var condition = this.getCondition(context); - if (condition == null) { - return getDependencies(this.expressions[0], context, options); - } - var dependencies = concatSubArrayDependencies(null, this.expressions, context, options, condition); - var expression = this.expressions[condition]; - var content = this.contents[condition]; - var blockContext = context.child(expression); - return concatArrayDependencies(dependencies, content, blockContext, options); -}; -EachBlock.prototype.dependencies = function(context, options) { - if (DependencyOptions.shouldIgnoreTemplate(this, options)) return; - var dependencies = getDependencies(this.expression, context, options); - var items = this.expression.get(context); - if (items && items.length) { - for (var i = 0; i < items.length; i++) { - var itemContext = context.eachChild(this.expression, i); - dependencies = concatArrayDependencies(dependencies, this.content, itemContext, options); - } - } else if (this.elseContent) { - dependencies = concatArrayDependencies(dependencies, this.elseContent, context, options); - } - return dependencies; -}; -Attribute.prototype.dependencies = function() {}; -DynamicAttribute.prototype.dependencies = function(context, options) { - if (DependencyOptions.shouldIgnoreTemplate(this, options)) return; - return getDependencies(this.expression, context, options); -}; - -function concatSubArrayDependencies(dependencies, expressions, context, options, end) { - for (var i = 0; i <= end; i++) { - dependencies = concatDependencies(dependencies, expressions[i], context, options); - } - return dependencies; -} -function concatArrayDependencies(dependencies, expressions, context, options) { - for (var i = 0; i < expressions.length; i++) { - dependencies = concatDependencies(dependencies, expressions[i], context, options); - } - return dependencies; -} -function concatMapDependencies(dependencies, expressions, context, options) { - for (var key in expressions) { - dependencies = concatDependencies(dependencies, expressions[key], context, options); - } - return dependencies; -} -function concatDependencies(dependencies, expression, context, options) { - var expressionDependencies = getDependencies(expression, context, options); - return concat(dependencies, expressionDependencies); -} -function getDependencies(expression, context, options) { - return expression.dependencies(context, options); -} - -var markerHooks = [{ - emit: function(context, node) { - node.$component = context.controller; - context.controller.markerNode = node; - } -}]; -function Marker(data) { - Comment.call(this, data, markerHooks); -} -Marker.prototype = Object.create(Comment.prototype); -Marker.prototype.constructor = Marker; -Marker.prototype.type = 'Marker'; -Marker.prototype.serialize = function() { - return serializeObject.instance(this, this.data); -}; -Marker.prototype.get = function() { - return ''; -}; - -function ViewAttributesMap(source) { - var items = source.split(/\s+/); - for (var i = 0, len = items.length; i < len; i++) { - this[items[i]] = true; - } -} -function ViewArraysMap(source) { - var items = source.split(/\s+/); - for (var i = 0, len = items.length; i < len; i++) { - var item = items[i].split('/'); - this[item[0]] = item[1] || item[0]; - } -} -function View(views, name, source, options) { - this.views = views; - this.name = name; - this.source = source; - this.options = options; - - var nameSegments = (this.name || '').split(':'); - var lastSegment = nameSegments.pop(); - this.namespace = nameSegments.join(':'); - this.registeredName = (lastSegment === 'index') ? this.namespace : this.name; - - this.attributesMap = options && options.attributes && - new ViewAttributesMap(options.attributes); - this.arraysMap = options && options.arrays && - new ViewArraysMap(options.arrays); - // The empty string is considered true for easier HTML attribute parsing - this.unminified = options && (options.unminified || options.unminified === ''); - this.string = options && (options.string || options.string === ''); - this.literal = options && (options.literal || options.literal === ''); - this.template = null; - this.componentFactory = null; - this.fromSerialized = false; -} -View.prototype = Object.create(Template.prototype); -View.prototype.constructor = View; -View.prototype.type = 'View'; -View.prototype.serialize = function() { - return null; -}; -View.prototype._isComponent = function(context) { - if (!this.componentFactory) return false; - if (context.attributes && context.attributes.extend) return false; - return true; -}; -View.prototype._initComponent = function(context) { - return (this._isComponent(context)) ? - this.componentFactory.init(context) : context; -}; -View.prototype._queueCreate = function(context, viewContext) { - if (this._isComponent(context)) { - var componentFactory = this.componentFactory; - context.queue(function queuedCreate() { - componentFactory.create(viewContext); - }); - - if (!context.hooks) return; - context.queue(function queuedComponentHooks() { - // Kick off hooks if view instance specified `on` or `as` attributes - for (var i = 0, len = context.hooks.length; i < len; i++) { - context.hooks[i].emit(context, viewContext.controller); - } - }); - } -}; -View.prototype.get = function(context, unescaped) { - var viewContext = this._initComponent(context); - var template = this.template || this.parse(); - return template.get(viewContext, unescaped); -}; -View.prototype.getFragment = function(context, binding) { - var viewContext = this._initComponent(context); - var template = this.template || this.parse(); - var fragment = template.getFragment(viewContext, binding); - this._queueCreate(context, viewContext); - return fragment; -}; -View.prototype.appendTo = function(parent, context) { - var viewContext = this._initComponent(context); - var template = this.template || this.parse(); - template.appendTo(parent, viewContext); - this._queueCreate(context, viewContext); -}; -View.prototype.attachTo = function(parent, node, context) { - var viewContext = this._initComponent(context); - var template = this.template || this.parse(); - var node = template.attachTo(parent, node, viewContext); - this._queueCreate(context, viewContext); - return node; -}; -View.prototype.dependencies = function(context, options) { - if (DependencyOptions.shouldIgnoreTemplate(this, options)) return; - var template = this.template || this.parse(); - // We can't figure out relative path dependencies within a component without - // rendering it, because each component instance's scope is dynamically set - // based on its unique `id` property. To represent this, set the context - // controller to `null`. - // - // Under normal rendering conditions, contexts should always have reference - // to a controller. Expression::get() methods use the reference to - // `context.controller.model.data` to lookup values, and paths are resolved - // based on `context.controller.model._scope`. - // - // To handle this, Expression methods guard against a null controller by not - // returning any dependencies for model paths. In addition, they return - // `undefined` from get, which affect dependencies computed for - // ConditionalBlock and EachBlock, as their dependencies will differ based - // on the value of model data. - // - // TODO: This likely under-estimates the true dependencies within a - // template. However, to provide a more complete view of dependencies, we'd - // need information we only have at render time, namely, the scope and data - // within the component model. This may indicate that Derby should use a - // more Functional Reactive Programming (FRP)-like approach of having - // dependencies be returned from getFragment and attach methods along with - // DOM nodes rather than computing dependencies separately from rendering. - var viewContext = (this._isComponent(context)) ? - context.componentChild(null) : context; - return template.dependencies(viewContext, options); -}; -View.prototype.parse = function() { - this._parse(); - if (this.componentFactory && !this.componentFactory.constructor.prototype.singleton) { - var marker = new Marker(this.name); - this.template.content.unshift(marker); - } - return this.template; -}; -// View.prototype._parse is defined in parsing.js, so that it doesn't have to -// be included in the client if templates are all parsed server-side -View.prototype._parse = function() { - throw new Error('View parsing not available'); -}; - -function ViewInstance(name, attributes, hooks, initHooks) { - this.name = name; - this.attributes = attributes; - this.hooks = hooks; - this.initHooks = initHooks; - this.view = null; -} -ViewInstance.prototype = Object.create(Template.prototype); -ViewInstance.prototype.constructor = ViewInstance; -ViewInstance.prototype.type = 'ViewInstance'; -ViewInstance.prototype.serialize = function() { - return serializeObject.instance(this, this.name, this.attributes, this.hooks, this.initHooks); -}; -ViewInstance.prototype.get = function(context, unescaped) { - var view = this._find(context); - var viewContext = context.viewChild(view, this.attributes, this.hooks, this.initHooks); - return view.get(viewContext, unescaped); -}; -ViewInstance.prototype.getFragment = function(context, binding) { - var view = this._find(context); - var viewContext = context.viewChild(view, this.attributes, this.hooks, this.initHooks); - return view.getFragment(viewContext, binding); -}; -ViewInstance.prototype.appendTo = function(parent, context) { - var view = this._find(context); - var viewContext = context.viewChild(view, this.attributes, this.hooks, this.initHooks); - view.appendTo(parent, viewContext); -}; -ViewInstance.prototype.attachTo = function(parent, node, context) { - var view = this._find(context); - var viewContext = context.viewChild(view, this.attributes, this.hooks, this.initHooks); - return view.attachTo(parent, node, viewContext); -}; -ViewInstance.prototype.dependencies = function(context, options) { - if (DependencyOptions.shouldIgnoreTemplate(this, options)) return; - var view = this._find(context); - var viewContext = context.viewChild(view, this.attributes, this.hooks, this.initHooks); - return view.dependencies(viewContext, options); -}; -ViewInstance.prototype._find = function(context) { - if (this.view) return this.view; - var contextView = context.getView(); - var namespace = contextView && contextView.namespace; - this.view = context.meta.views.find(this.name, namespace); - if (!this.view) { - var message = context.meta.views.findErrorMessage(this.name, contextView); - throw new Error(message); - } - return this.view; -}; - -function DynamicViewInstance(nameExpression, attributes, hooks, initHooks) { - this.nameExpression = nameExpression; - this.attributes = attributes; - this.hooks = hooks; - this.initHooks = initHooks; -} -DynamicViewInstance.prototype = Object.create(ViewInstance.prototype); -DynamicViewInstance.prototype.constructor = DynamicViewInstance; -DynamicViewInstance.prototype.type = 'DynamicViewInstance'; -DynamicViewInstance.prototype.serialize = function() { - return serializeObject.instance(this, this.nameExpression, this.attributes, this.hooks, this.initHooks); -}; -DynamicViewInstance.prototype._find = function(context) { - var name = this.nameExpression.get(context); - var contextView = context.getView(); - var namespace = contextView && contextView.namespace; - var view = name && context.meta.views.find(name, namespace); - return view || exports.emptyTemplate; -}; -DynamicViewInstance.prototype.dependencies = function(context, options) { - if (DependencyOptions.shouldIgnoreTemplate(this, options)) return; - var nameDependencies = this.nameExpression.dependencies(context); - var viewDependencies = ViewInstance.prototype.dependencies.call(this, context, options); - return concat(nameDependencies, viewDependencies); -}; - -// Without a ContextClosure, ViewParent will return the nearest context that -// is the parent of a view instance. When a context with a `closure` property -// is encountered first, ViewParent will find the specific referenced context, -// even if it is further up the context hierarchy. -function ViewParent(template) { - this.template = template; -} -ViewParent.prototype = Object.create(Template.prototype); -ViewParent.prototype.constructor = ViewParent; -ViewParent.prototype.type = 'ViewParent'; -ViewParent.prototype.serialize = function() { - return serializeObject.instance(this, this.template); -}; -ViewParent.prototype.get = function(context, unescaped) { - var parentContext = context.forViewParent(); - return this.template.get(parentContext, unescaped); -}; -ViewParent.prototype.getFragment = function(context, binding) { - var parentContext = context.forViewParent(); - return this.template.getFragment(parentContext, binding); -}; -ViewParent.prototype.appendTo = function(parent, context) { - var parentContext = context.forViewParent(); - this.template.appendTo(parent, parentContext); -}; -ViewParent.prototype.attachTo = function(parent, node, context) { - var parentContext = context.forViewParent(); - return this.template.attachTo(parent, node, parentContext); -}; -ViewParent.prototype.dependencies = function(context, options) { - if (DependencyOptions.shouldIgnoreTemplate(this, options)) return; - var parentContext = context.forViewParent(); - return this.template.dependencies(parentContext, options); -}; - -// At render time, this template creates a context child and sets its -// `closure` property to a fixed reference. It is used in combination with -// ViewParent in order to control which context is returned. -// -// Instances of this template cannot be serialized. It is intended for use -// dynamically during rendering only. -function ContextClosure(template, context) { - this.template = template; - this.context = context; -} -ContextClosure.prototype = Object.create(Template.prototype); -ContextClosure.prototype.constructor = ContextClosure; -ContextClosure.prototype.serialize = function() { - throw new Error('ContextClosure cannot be serialized'); -}; -ContextClosure.prototype.get = function(context, unescaped) { - var closureContext = context.closureChild(this.context); - return this.template.get(closureContext, unescaped); -}; -ContextClosure.prototype.getFragment = function(context, binding) { - var closureContext = context.closureChild(this.context); - return this.template.getFragment(closureContext, binding); -}; -ContextClosure.prototype.appendTo = function(parent, context) { - var closureContext = context.closureChild(this.context); - this.template.appendTo(parent, closureContext); -}; -ContextClosure.prototype.attachTo = function(parent, node, context) { - var closureContext = context.closureChild(this.context); - return this.template.attachTo(parent, node, closureContext); -}; -ContextClosure.prototype.dependencies = function(context, options) { - if (DependencyOptions.shouldIgnoreTemplate(this.template, options)) return; - var closureContext = context.closureChild(this.context); - return this.template.dependencies(closureContext, options); -}; -ContextClosure.prototype.equals = function(other) { - return (other instanceof ContextClosure) && - (this.context === other.context) && - (this.template.equals(other.template)); -}; - -function ViewsMap() {} -function Views() { - this.nameMap = new ViewsMap(); - this.tagMap = new ViewsMap(); - // TODO: elementMap is deprecated and should be removed with Derby 0.6.0 - this.elementMap = this.tagMap; -} -Views.prototype.find = function(name, namespace) { - var map = this.nameMap; - - // Exact match lookup - var exactName = (namespace) ? namespace + ':' + name : name; - var match = map[exactName]; - if (match) return match; - - // Relative lookup - var segments = name.split(':'); - var segmentsDepth = segments.length; - if (namespace) segments = namespace.split(':').concat(segments); - // Iterate through segments, leaving the `segmentsDepth` segments and - // removing the second to `segmentsDepth` segment to traverse up the - // namespaces. Decrease `segmentsDepth` if not found and repeat again. - while (segmentsDepth > 0) { - var testSegments = segments.slice(); - while (testSegments.length > segmentsDepth) { - testSegments.splice(-1 - segmentsDepth, 1); - var testName = testSegments.join(':'); - var match = map[testName]; - if (match) return match; - } - segmentsDepth--; - } -}; -Views.prototype.register = function(name, source, options) { - var mapName = name.replace(/:index$/, ''); - var view = this.nameMap[mapName]; - if (view) { - // Recreate the view if it already exists. We re-apply the constructor - // instead of creating a new view object so that references to object - // can be cached after finding the first time - var componentFactory = view.componentFactory; - View.call(view, this, name, source, options); - view.componentFactory = componentFactory; - } else { - view = new View(this, name, source, options); - } - this.nameMap[mapName] = view; - // TODO: element is deprecated and should be removed with Derby 0.6.0 - var tagName = options && (options.tag || options.element); - if (tagName) this.tagMap[tagName] = view; - return view; -}; -Views.prototype.deserialize = function(items) { - for (var i = 0; i < items.length; i++) { - var item = items[i]; - var setTemplate = item[0]; - var name = item[1]; - var source = item[2]; - var options = item[3]; - var view = this.register(name, source, options); - view.parse = setTemplate; - view.fromSerialized = true; - } -}; -Views.prototype.serialize = function(options) { - var forServer = options && options.server; - var minify = options && options.minify; - var items = []; - for (var name in this.nameMap) { - var view = this.nameMap[name]; - var template = view.template || view.parse(); - if (!forServer && view.options) { - // Do not serialize views with the `serverOnly` option, except when - // serializing for a server script - if (view.options.serverOnly) continue; - // For views with the `server` option, serialize them with a blank - // template body. This allows them to be used from other views on the - // browser, but they will output nothing on the browser - if (view.options.server) template = exports.emptyTemplate; - } - // Serializing views as a function allows them to be constructed lazily upon - // first use. This can improve initial load times of the application when - // there are many views - items.push( - '[function(){return this.template=' + - template.serialize() + '},' + - serializeObject.args([ - view.name, - (minify) ? null : view.source, - (hasKeys(view.options)) ? view.options : null - ]) + - ']' - ); - } - return 'function(derbyTemplates, views){' + - 'var expressions = derbyTemplates.expressions,' + - 'templates = derbyTemplates.templates;' + - 'views.deserialize([' + items.join(',') + '])}'; -}; -Views.prototype.findErrorMessage = function(name, contextView) { - var names = Object.keys(this.nameMap); - var message = 'Cannot find view "' + name + '" in' + - [''].concat(names).join('\n ') + '\n'; - if (contextView) { - message += '\nWithin template "' + contextView.name + '":\n' + contextView.source; - } - return message; -}; - - -function MarkupHook() {} -MarkupHook.prototype.module = Template.prototype.module; - -function ElementOn(name, expression) { - this.name = name; - this.expression = expression; -} -ElementOn.prototype = Object.create(MarkupHook.prototype); -ElementOn.prototype.constructor = ElementOn; -ElementOn.prototype.type = 'ElementOn'; -ElementOn.prototype.serialize = function() { - return serializeObject.instance(this, this.name, this.expression); -}; -ElementOn.prototype.emit = function(context, element) { - if (this.name === 'create') { - this.apply(context, element); - return; - } - var elementOn = this; - var listener = function elementOnListener(event) { - return elementOn.apply(context, element, event); - }; - // Using `context.controller.dom.on` would be better for garbage collection, - // but since it synchronously removes listeners on component destroy, it would - // break existing code relying on `on-*` listeners firing as a component is - // being destroyed. Even with `addEventListener`, browsers should still GC - // the listeners once there are no references to the element. - element.addEventListener(this.name, listener, false); - // context.controller.dom.on(this.name, element, listener, false); -}; -ElementOn.prototype.apply = function(context, element, event) { - var modelData = context.controller.model.data; - modelData.$event = event; - modelData.$element = element; - var out = this.expression.apply(context); - delete modelData.$event; - delete modelData.$element; - return out; -}; - -function ComponentOn(name, expression) { - this.name = name; - this.expression = expression; -} -ComponentOn.prototype = Object.create(MarkupHook.prototype); -ComponentOn.prototype.constructor = ComponentOn; -ComponentOn.prototype.type = 'ComponentOn'; -ComponentOn.prototype.serialize = function() { - return serializeObject.instance(this, this.name, this.expression); -}; -ComponentOn.prototype.emit = function(context, component) { - var expression = this.expression; - component.on(this.name, function componentOnListener() { - var args = arguments.length && Array.prototype.slice.call(arguments); - return expression.apply(context, args); - }); -}; - -function AsProperty(segments) { - this.segments = segments; - this.lastSegment = segments.pop(); -} -AsProperty.prototype = Object.create(MarkupHook.prototype); -AsProperty.prototype.constructor = AsProperty; -AsProperty.prototype.type = 'AsProperty'; -AsProperty.prototype.serialize = function() { - var segments = this.segments.concat(this.lastSegment); - return serializeObject.instance(this, segments); -}; -AsProperty.prototype.emit = function(context, target) { - var node = traverseAndCreate(context.controller, this.segments); - node[this.lastSegment] = target; - this.addListeners(target, node, this.lastSegment); -}; -AsProperty.prototype.addListeners = function(target, object, key) { - this.addDestroyListener(target, function asPropertyDestroy() { - // memoize initial reference so we dont destroy - // property that has been replaced with a different reference - var intialRef = object[key]; - process.nextTick(function deleteProperty() { - if (intialRef !== object[key]) { - return; - } - delete object[key]; - }); - }); -}; -AsProperty.prototype.addDestroyListener = elementAddDestroyListener; - -function AsPropertyComponent(segments) { - AsProperty.call(this, segments); -} -AsPropertyComponent.prototype = Object.create(AsProperty.prototype); -AsPropertyComponent.prototype.constructor = AsPropertyComponent; -AsPropertyComponent.prototype.type = 'AsPropertyComponent'; -AsPropertyComponent.prototype.addDestroyListener = componentAddDestroyListener; - -function AsObject(segments, keyExpression) { - AsProperty.call(this, segments); - this.keyExpression = keyExpression; -} -AsObject.prototype = Object.create(AsProperty.prototype); -AsObject.prototype.constructor = AsObject; -AsObject.prototype.type = 'AsObject'; -AsObject.prototype.serialize = function() { - var segments = this.segments.concat(this.lastSegment); - return serializeObject.instance(this, segments, this.keyExpression); -}; -AsObject.prototype.emit = function(context, target) { - var node = traverseAndCreate(context.controller, this.segments); - var object = node[this.lastSegment] || (node[this.lastSegment] = {}); - var key = this.keyExpression.get(context); - object[key] = target; - this.addListeners(target, object, key); -}; - -function AsObjectComponent(segments, keyExpression) { - AsObject.call(this, segments, keyExpression); -} -AsObjectComponent.prototype = Object.create(AsObject.prototype); -AsObjectComponent.prototype.constructor = AsObjectComponent; -AsObjectComponent.prototype.type = 'AsObjectComponent'; -AsObjectComponent.prototype.addDestroyListener = componentAddDestroyListener; - -function AsArray(segments) { - AsProperty.call(this, segments); -} -AsArray.prototype = Object.create(AsProperty.prototype); -AsArray.prototype.constructor = AsArray; -AsArray.prototype.type = 'AsArray'; -AsArray.prototype.emit = function(context, target) { - var node = traverseAndCreate(context.controller, this.segments); - var array = node[this.lastSegment] || (node[this.lastSegment] = []); - - // Iterate backwards, since rendering will usually append - for (var i = array.length; i--;) { - var item = array[i]; - // Don't add an item if already in the array - if (item === target) return; - var mask = this.comparePosition(target, item); - // If the emitted target is after the current item in the document, - // insert it next in the array - // Node.DOCUMENT_POSITION_FOLLOWING = 4 - if (mask & 4) { - array.splice(i + 1, 0, target); - this.addListeners(target, array); - return; - } - } - // Add to the beginning if before all items - array.unshift(target); - this.addListeners(target, array); -}; -AsArray.prototype.addListeners = function(target, array) { - this.addDestroyListener(target, function asArrayDestroy() { - removeArrayItem(array, target); - }); -}; -AsArray.prototype.comparePosition = function(target, item) { - return item.compareDocumentPosition(target); -}; - -function AsArrayComponent(segments) { - AsArray.call(this, segments); -} -AsArrayComponent.prototype = Object.create(AsArray.prototype); -AsArrayComponent.prototype.constructor = AsArrayComponent; -AsArrayComponent.prototype.type = 'AsArrayComponent'; -AsArrayComponent.prototype.comparePosition = function(target, item) { - return item.markerNode.compareDocumentPosition(target.markerNode); -}; -AsArrayComponent.prototype.addDestroyListener = componentAddDestroyListener; - -function elementAddDestroyListener(element, listener) { - var destroyListeners = element.$destroyListeners; - if (destroyListeners) { - if (destroyListeners.indexOf(listener) === -1) { - destroyListeners.push(listener); - } - } else { - element.$destroyListeners = [listener]; - } -} -function elementRemoveDestroyListener(element, listener) { - var destroyListeners = element.$destroyListeners; - if (destroyListeners) { - removeArrayItem(destroyListeners, listener); - } -} -function componentAddDestroyListener(target, listener) { - target.on('destroy', listener); -} -function removeArrayItem(array, item) { - var index = array.indexOf(item); - if (index > -1) { - array.splice(index, 1); - } -} diff --git a/lib/templates/util.js b/lib/templates/util.js deleted file mode 100644 index fff5ec357..000000000 --- a/lib/templates/util.js +++ /dev/null @@ -1,23 +0,0 @@ -exports.concat = function(a, b) { - if (!a) return b; - if (!b) return a; - return a.concat(b); -}; - -exports.hasKeys = function(value) { - if (!value) return false; - for (var key in value) { - return true; - } - return false; -}; - -exports.traverseAndCreate = function(node, segments) { - var len = segments.length; - if (!len) return node; - for (var i = 0; i < len; i++) { - var segment = segments[i]; - node = node[segment] || (node[segment] = {}); - } - return node; -}; diff --git a/package.json b/package.json index a6f9308a2..d42cee540 100644 --- a/package.json +++ b/package.json @@ -1,21 +1,37 @@ { "name": "derby", "description": "MVC framework making it easy to write realtime, collaborative applications that run in both Node.js and browsers.", - "version": "2.3.0", + "version": "3.0.0-beta.9", "homepage": "http://derbyjs.com/", "repository": { "type": "git", "url": "git://github.com/derbyjs/derby.git" }, - "main": "index.js", + "main": "dist/index.js", + "exports": { + ".": "./dist/index.js", + "./components": "./dist/components.js", + "./parsing": "./dist/parsing/index.js", + "./templates": "./dist/templates/index.js", + "./App": "./dist/App.js", + "./AppForServer": "./dist/AppForServer.js", + "./server": "./dist/server.js", + "./Page": "./dist/Page.js", + "./test-utils": "./test-utils/index.js", + "./test-utils/*": "./test-utils/*.js" + }, "files": [ - "*.js", - "lib/**/*.js", - "test-utils/**/*.js" + "dist/", + "test-utils/" ], "scripts": { + "build": "node_modules/.bin/tsc", "checks": "npm run lint && npm test", - "lint": "npx eslint '**/*.js'", + "lint": "npx eslint src/**/*.js test/**/*.js test-utils/**/*.js", + "lint:ts": "npx eslint src/**/*.ts", + "lint:fix": "npm run lint:ts -- --fix", + "prepare": "npm run build", + "pretest": "npm run build", "test": "npx mocha 'test/all/**/*.mocha.js' 'test/dom/**/*.mocha.js' 'test/server/**/*.mocha.js'", "test-browser": "node test/server.js" }, @@ -27,17 +43,27 @@ "racer": "^1.0.3", "resolve": "^1.22.1", "serialize-object": "^1.0.0", - "through": "^2.3.8", "tracks": "^0.5.8" }, "devDependencies": { + "@types/esprima-derby": "npm:@types/esprima@^4.0.3", + "@types/estree": "^1.0.1", + "@types/express": "^4.17.18", + "@types/node": "^20.3.1", + "@typescript-eslint/eslint-plugin": "^6.2.1", + "@typescript-eslint/parser": "^6.2.1", "async": "^3.2.4", "browserify": "^17.0.0", "chai": "^4.3.6", "eslint": "^8.37.0", + "eslint-config-prettier": "^9.0.0", + "eslint-plugin-import": "^2.28.0", + "eslint-plugin-prettier": "^5.0.0", "express": "^4.18.1", "jsdom": "^20.0.1", - "mocha": "^10.0.0" + "mocha": "^10.0.0", + "prettier": "^3.0.1", + "typescript": "~5.1.3" }, "optionalDependencies": {}, "bugs": { diff --git a/parsing.js b/parsing.js deleted file mode 100644 index 7238e9735..000000000 --- a/parsing.js +++ /dev/null @@ -1 +0,0 @@ -module.exports = require('./lib/parsing'); diff --git a/src/App.ts b/src/App.ts new file mode 100644 index 000000000..9e74b0ff0 --- /dev/null +++ b/src/App.ts @@ -0,0 +1,465 @@ +/* + * App.js + * + * Provides the glue between views, controllers, and routes for an + * application's functionality. Apps are responsible for creating pages. + * + */ +import { EventEmitter } from 'events'; +import { basename } from 'path'; + +import { type Model } from 'racer'; +import * as util from 'racer/lib/util'; + +import components = require('./components'); +import { type ComponentConstructor, type SingletonComponentConstructor } from './components'; +import { type Derby } from './Derby'; +import { Page, type PageBase } from './Page'; +import { PageParams, routes } from './routes'; +import * as derbyTemplates from './templates'; +import { type Views } from './templates/templates'; + +const { templates } = derbyTemplates; + +// TODO: Change to Map once we officially drop support for ES5. +global.APPS = global.APPS || {}; + +export function createAppPage(derby): typeof PageBase { + const pageCtor = ((derby && derby.Page) || Page) as typeof PageBase; + // Inherit from Page/PageForServer so that we can add controller functions as prototype + // methods on this app's pages + class AppPage extends pageCtor { } + return AppPage; +} + +interface AppOptions { + appMetadata?: Record, + scriptHash?: string, +} + +type OnRouteCallback = (arg0: Page, arg1: Page, model: Model, params: PageParams, done?: () => void) => void; + +type Routes = [string, string, any][]; + +export abstract class AppBase extends EventEmitter { + derby: Derby; + name: string; + filename: string; + scriptHash: string; + // bundledAt: string; + appMetadata: Record; + Page: typeof PageBase; + proto: any; + views: Views; + tracksRoutes: Routes; + model: Model; + page: PageBase; + protected _pendingComponentMap: Record; + protected _waitForAttach: boolean; + protected _cancelAttach: boolean; + + use = util.use; + serverUse = util.serverUse; + + constructor(derby, name, filename, options: AppOptions = {}) { + super(); + this.derby = derby; + this.name = name; + this.filename = filename; + this.scriptHash = options.scriptHash ?? ''; + this.appMetadata = options.appMetadata; + this.Page = createAppPage(derby); + this.proto = this.Page.prototype; + this.views = new templates.Views(); + this.tracksRoutes = routes(this); + this.model = null; + this.page = null; + this._pendingComponentMap = {}; + } + + abstract _init(options?: AppOptions); + loadViews(_viewFilename, _viewName) { } + loadStyles(_filename, _options) { } + + component(constructor: ComponentConstructor | SingletonComponentConstructor): this; + component(name: string, constructor: ComponentConstructor | SingletonComponentConstructor, isDependency?: boolean): this; + component(name: string | ComponentConstructor | SingletonComponentConstructor, constructor?: ComponentConstructor | SingletonComponentConstructor, isDependency?: boolean): this { + if (typeof name === 'function') { + constructor = name; + name = null; + } + if (typeof constructor !== 'function') { + throw new Error('Missing component constructor argument'); + } + + const viewProp = constructor.view; + let viewIs, viewFilename, viewSource, viewDependencies; + // Always using an object for the static `view` property is preferred + if (viewProp && typeof viewProp === 'object') { + viewIs = viewProp.is; + viewFilename = viewProp.file; + viewSource = viewProp.source; + viewDependencies = viewProp.dependencies; + } else { + // Ignore other properties when `view` is an object. It is possible that + // properties could be inherited from a parent component when extending it. + // + // DEPRECATED: constructor.prototype.name and constructor.prototype.view + // use the equivalent static properties instead + // @ts-expect-error Ignore deprecated props + viewIs = constructor.is || constructor.prototype.name; + viewFilename = constructor.view || constructor.prototype.view; + } + const viewName = name || viewIs || + (viewFilename && basename(viewFilename, '.html')); + + if (!viewName) { + throw new Error('No view specified for component'); + } + if (viewFilename && viewSource) { + throw new Error('Component may not specify both a view file and source'); + } + + // TODO: DRY. This is copy-pasted from ./templates + const mapName = viewName.replace(/:index$/, ''); + const currentView = this.views.nameMap[mapName]; + const currentConstructor = (currentView && currentView.componentFactory) ? + currentView.componentFactory.constructorFn : + this._pendingComponentMap[mapName]; + + // Avoid registering the same component twice; we want to avoid the overhead + // of loading view files from disk again. This is also what prevents + // circular dependencies from infinite looping + if (currentConstructor === constructor) return; + + // Calling app.component() overrides existing views or components. Prevent + // dependencies from doing this without warning + if (isDependency && currentView && !currentView.fromSerialized) { + throw new Error('Dependencies cannot override existing views. Already registered "' + viewName + '"'); + } + + // This map is used to prevent infinite loops from circular dependencies + this._pendingComponentMap[mapName] = constructor; + + // Recursively register component dependencies + if (viewDependencies) { + for (let i = 0; i < viewDependencies.length; i++) { + const dependency = viewDependencies[i]; + if (Array.isArray(dependency)) { + this.component(dependency[0], dependency[1], true); + } else { + this.component(null, dependency, true); + } + } + } + + // Register or find views specified by the component + let view; + if (viewFilename) { + this.loadViews(viewFilename, viewName); + view = this.views.find(viewName); + + } else if (viewSource) { + this.addViews(viewSource, viewName); + view = this.views.find(viewName); + + } else if (name) { + view = this.views.find(viewName); + + } else { + view = this.views.register(viewName, ''); + } + if (!view) { + const message = this.views.findErrorMessage(viewName); + throw new Error(message); + } + + // Inherit from Component + components.extendComponent(constructor); + // Associate the appropriate view with the component constructor + view.componentFactory = components.createFactory(constructor); + + delete this._pendingComponentMap[mapName]; + + // Make chainable + return this; + } + + // This function is overriden by requiring 'derby/parsing' + addViews(_viewFileName: string, _namespace: string) { + throw new Error( + 'Parsing not available. Registering a view from source should not be used ' + + 'in application code. Instead, specify a filename with view.file.' + ); + } + + onRoute(callback: OnRouteCallback, page: Page, next: () => void, done: () => void) { + if (this._waitForAttach) { + // Cancel any routing before the initial page attachment. Instead, do a + // render once derby is ready + this._cancelAttach = true; + return; + } + this.emit('route', page); + // HACK: To update render in transitional routes + page.model.set('$render.params', page.params); + page.model.set('$render.url', page.params.url); + page.model.set('$render.query', page.params.query); + // If transitional + if (done) { + const _done = () => { + this.emit('routeDone', page, 'transition'); + done(); + }; + callback.call(page, page, page.model, page.params, next, _done); + return; + } + callback.call(page, page, page.model, page.params, next); + } +} + +export class App extends AppBase { + page: Page; + history: { + refresh(): void, + push(): void, + replace(): void, + }; + + constructor(derby, name, filename, options: AppOptions) { + super(derby, name, filename, options); + this._init(options); + } + + // Overriden on server + _init(_options) { + this._waitForAttach = true; + this._cancelAttach = false; + this.model = new this.derby.Model(); + const serializedViews = this._views(); + serializedViews(derbyTemplates, this.views); + // Must init async so that app.on('model') listeners can be added. + // Must also wait for content ready so that bundle is fully downloaded. + this._contentReady(); + } + + private _views() { + return require('./_views'); + } + + private _finishInit() { + const data = this._getAppData(); + util.isProduction = data.nodeEnv === 'production'; + + let previousAppInfo; + if (!util.isProduction) { + previousAppInfo = global.APPS[this.name]; + if (previousAppInfo) { + previousAppInfo.app._destroyCurrentPage(); + } + global.APPS[this.name] = { + app: this, + initialState: data, + }; + } + + this.model.createConnection(data); + this.emit('model', this.model); + + if (!util.isProduction) this._autoRefresh(); + + this.model.unbundle(data); + + const page = this.createPage(); + page.params = this.model.get('$render.params'); + this.emit('ready', page); + + this._waitForAttach = false; + // Instead of attaching, do a route and render if a link was clicked before + // the page finished attaching, or if this is a new app from hot reload. + if (this._cancelAttach || previousAppInfo) { + this.history.refresh(); + return; + } + // Since an attachment failure is *fatal* and could happen as a result of a + // browser extension like AdBlock, an invalid template, or a small bug in + // Derby or Saddle, re-render from scratch on production failures + if (util.isProduction) { + try { + page.attach(); + } catch (err) { + this.history.refresh(); + console.warn('attachment error', err.stack); + } + } else { + page.attach(); + } + this.emit('load', page); + } + + private _getAppData() { + const script = this._getAppStateScript(); + if (script) { + return App._parseInitialData(script.textContent); + } else { + return global.APPS[this.name].initialState; + } + } + + // Modified from: https://github.com/addyosmani/jquery.parts/blob/master/jquery.documentReady.js + private _contentReady() { + // Is the DOM ready to be used? Set to true once it occurs. + let isReady = false; + + // The ready event handler + function onDOMContentLoaded() { + if (document.addEventListener) { + document.removeEventListener('DOMContentLoaded', onDOMContentLoaded, false); + } else { + // we're here because readyState !== 'loading' in oldIE + // which is good enough for us to call the dom ready! + // @ts-expect-error IE api + document.detachEvent('onreadystatechange', onDOMContentLoaded); + } + onDOMReady(); + } + + const finishInit = () => { + this._finishInit(); + } + + // Handle when the DOM is ready + function onDOMReady() { + // Make sure that the DOM is not already loaded + if (isReady) return; + // Make sure body exists, at least, in case IE gets a little overzealous (ticket #5443). + if (!document.body) return setTimeout(onDOMReady, 0); + // Remember that the DOM is ready + isReady = true; + // Make sure this is always async and then finishing init + setTimeout(finishInit, 0); + } + + // The DOM ready check for Internet Explorer + function doScrollCheck() { + if (isReady) return; + try { + // If IE is used, use the trick by Diego Perini + // http://javascript.nwbox.com/IEContentLoaded/ + // @ts-expect-error IE only api check + document.documentElement.doScroll('left'); + } catch (err) { + setTimeout(doScrollCheck, 0); + return; + } + // and execute any waiting functions + onDOMReady(); + } + + // Catch cases where called after the browser event has already occurred. + if (document.readyState !== 'loading') return onDOMReady(); + + // Mozilla, Opera and webkit nightlies currently support this event + if (document.addEventListener) { + // Use the handy event callback + document.addEventListener('DOMContentLoaded', onDOMContentLoaded, false); + // A fallback to window.onload, that will always work + window.addEventListener('load', onDOMContentLoaded, false); + // If IE event model is used + // @ts-expect-error IE event model + } else if (document.attachEvent) { + // ensure firing before onload, + // maybe late but safe also for iframes + // @ts-expect-error IE api + document.attachEvent('onreadystatechange', onDOMContentLoaded); + // A fallback to window.onload, that will always work + // @ts-expect-error `attachEvent` checked above + window.attachEvent('onload', onDOMContentLoaded); + // If IE and not a frame + // continually check to see if the document is ready + let toplevel; + try { + toplevel = window.frameElement == null; + } catch (err) { /* ignore, not IE */ } + // @ts-expect-error IE api + if (document.documentElement.doScroll && toplevel) { + doScrollCheck(); + } + } + } + + private _getAppStateScript() { + return document.querySelector('script[data-derby-app-state]'); + } + + createPage() { + this._destroyCurrentPage(); + const ClientPage = this.Page as unknown as typeof Page; + const page = new ClientPage(this, this.model); + this.page = page; + return page; + } + + private _destroyCurrentPage() { + if (this.page) { + this.emit('destroyPage', this.page); + this.page.destroy(); + } + } + + private _autoRefresh(_backend?: unknown) { + const connection = this.model.connection; + connection.on('connected', () => { + connection.send({ + derby: 'app', + name: this.name, + hash: this.scriptHash + }); + }); + connection.on('receive', (request) => { + if (request.data.derby) { + const message = request.data; + request.data = null; + this._handleMessage(message.derby, message); + } + }); + } + + private _handleMessage(action: string, message: { views: string, filename: string, css: string}) { + if (action === 'refreshViews') { + const fn = new Function('return ' + message.views)(); // jshint ignore:line + fn(derbyTemplates, this.views); + const ns = this.model.get('$render.ns'); + this.page.render(ns); + + } else if (action === 'refreshStyles') { + const styleElement = document.querySelector('style[data-filename="' + + message.filename + '"]'); + if (styleElement) styleElement.innerHTML = message.css; + + } else if (action === 'reload') { + this.model.whenNothingPending(function() { + const { location } = window; + window.location = location; + }); + } + } + + static _parseInitialData(jsonString: string) { + try { + return JSON.parse(jsonString); + } catch (error) { + const message = error.message || ''; + const match = message.match(/^Unexpected token/); + if (match) { + const p = parseInt(match[2], 10); + const stringContext = jsonString.substring( + Math.min(0, p - 30), + Math.max(p + 30, jsonString.length - 1) + ); + throw new Error('Parse failure: ' + error.message + ' context: \'' + stringContext + '\''); + } + throw error; + } + } +} diff --git a/src/AppForServer.ts b/src/AppForServer.ts new file mode 100644 index 000000000..3d652027d --- /dev/null +++ b/src/AppForServer.ts @@ -0,0 +1,374 @@ +/* + * App.server.js + * + * Application level functionality that is + * only applicable to the server. + * + */ + +import racer = require('racer'); + +const util = racer.util; +import { AppBase } from './App'; +import { PageForServer } from './PageForServer'; +import parsing = require('./parsing'); +import * as derbyTemplates from './templates'; + +interface Agent { + send(message: Record): void; +} + +// Avoid Browserifying these dependencies +let chokidar, files, fs, path; +if (module.require) { + chokidar = module.require('chokidar'); + files = module.require('./files'); + fs = module.require('fs'); + path = module.require('path'); +} + +const STYLE_EXTENSIONS = ['.css']; +const VIEW_EXTENSIONS = ['.html']; +const COMPILERS = { + '.css': cssCompiler, + '.html': htmlCompiler +}; + +function cssCompiler(file, filename, _options) { + return { css: file, files: [filename] }; +} + +function htmlCompiler(file) { + return file; +} + +type CompilerFunciton = (file: string, filename?: string, options?: unknown) => unknown; + +function watchOnce(filenames, callback) { + const watcher = chokidar.watch(filenames); + let closed = false; + watcher.on('change', function() { + if (closed) return; + closed = true; + // HACK: chokidar 3.1.1 crashes when you synchronously call close + // in the change event. Delaying appears to prevent the crash + process.nextTick(function() { + watcher.close(); + }); + callback(); + }); +} + +export class AppForServer extends AppBase { + agents: Record; + compilers: Record; + scriptBaseUrl: string; + scriptCrossOrigin: boolean; + scriptFilename: string; + scriptMapBaseUrl: string; + scriptMapFilename: string; + scriptMapUrl: string; + scriptUrl: string; + serializedBase: string; + serializedDir: string; + styleExtensions: string[]; + viewExtensions: string[]; + watchFiles: boolean; + router: any; + + constructor(derby, name: string, filename: string, options) { + super(derby, name, filename, options); + this._init(options); + } + + _init(options) { + this._initBundle(options); + this._initRefresh(); + this._initLoad(); + this._initViews(); + } + + private _initBundle(options) { + this.scriptFilename = null; + this.scriptMapFilename = null; + this.scriptBaseUrl = (options && options.scriptBaseUrl) || ''; + this.scriptMapBaseUrl = (options && options.scriptMapBaseUrl) || ''; + this.scriptCrossOrigin = (options && options.scriptCrossOrigin) || false; + this.scriptUrl = null; + this.scriptMapUrl = null; + } + + private _initRefresh() { + this.watchFiles = !util.isProduction; + this.agents = null; + } + + private _initLoad() { + this.styleExtensions = STYLE_EXTENSIONS.slice(); + this.viewExtensions = VIEW_EXTENSIONS.slice(); + this.compilers = util.copyObject(COMPILERS); + } + + private _initViews() { + this.serializedDir = path.dirname(this.filename || '') + '/derby-serialized'; + this.serializedBase = this.serializedDir + '/' + this.name; + if (fs.existsSync(this.serializedBase + '.json')) { + this.deserialize(); + this.loadViews = function(_filename, _namespace) { return this; }; + this.loadStyles = function(_filename, _options) { return this; }; + return; + } + + this.views.register('Page', + '' + + '' + + '' + + '' + + '' + + '', + { serverOnly: true } + ); + this.views.register('TitleElement', + '<view is="{{$render.prefix}}Title"></view>' + ); + this.views.register('BodyElement', + '' + + '' + ); + this.views.register('Title', 'Derby App'); + this.views.register('Styles', '', { serverOnly: true }); + this.views.register('Head', '', { serverOnly: true }); + this.views.register('Body', ''); + this.views.register('Tail', ''); + } + + // overload w different signatures, but different use cases + createPage(req, res, next) { + const model = req.model || new racer.Model(); + this.emit('model', model); + + const Page = this.Page as unknown as typeof PageForServer; + const page = new Page(this, model, req, res); + if (next) { + model.on('error', function(err) { + model.hasErrored = true; + next(err); + }); + page.on('error', next); + } + return page; + } + + bundle(_backend, _options, _cb) { + throw new Error( + 'bundle implementation missing; use racer-bundler for implementation, or remove call to this method and use another bundler', + ); + } + + writeScripts(_backend, _dir, _options, _cb) { + throw new Error( + 'writeScripts implementation missing; use racer-bundler for implementation, or remove call to this method and use another bundler', + ); + } + + private _viewsSource(options?) { + return `/*DERBY_SERIALIZED_VIEWS ${this.name}*/\n` + + 'module.exports = ' + this.views.serialize(options) + ';\n' + + `/*DERBY_SERIALIZED_VIEWS_END ${this.name}*/\n`; + } + + serialize() { + if (!fs.existsSync(this.serializedDir)) { + fs.mkdirSync(this.serializedDir); + } + // Don't minify the views (which doesn't include template source), since this + // is for use on the server + const viewsSource = this._viewsSource({ server: true, minify: true }); + fs.writeFileSync(this.serializedBase + '.views.js', viewsSource, 'utf8'); + const scriptUrl = (this.scriptUrl.indexOf(this.scriptBaseUrl) === 0) ? + this.scriptUrl.slice(this.scriptBaseUrl.length) : + this.scriptUrl; + const scriptMapUrl = (this.scriptMapUrl.indexOf(this.scriptMapBaseUrl) === 0) ? + this.scriptMapUrl.slice(this.scriptMapBaseUrl.length) : + this.scriptMapUrl; + const serialized = JSON.stringify({ + scriptBaseUrl: this.scriptBaseUrl, + scriptMapBaseUrl: this.scriptMapBaseUrl, + scriptUrl: scriptUrl, + scriptMapUrl: scriptMapUrl + }); + fs.writeFileSync(this.serializedBase + '.json', serialized, 'utf8'); + } + + deserialize() { + const serializedViews = module.require(this.serializedBase + '.views.js'); + const serialized = module.require(this.serializedBase + '.json'); + serializedViews(derbyTemplates, this.views); + this.scriptUrl = (this.scriptBaseUrl || serialized.scriptBaseUrl) + serialized.scriptUrl; + this.scriptMapUrl = (this.scriptMapBaseUrl || serialized.scriptMapBaseUrl) + serialized.scriptMapUrl; + } + + loadViews(filename, namespace) { + const data = files.loadViewsSync(this, filename, namespace); + parsing.registerParsedViews(this, data.views); + if (this.watchFiles) this._watchViews(data.files, filename, namespace); + // Make chainable + return this; + } + + loadStyles(filename, options) { + this._loadStyles(filename, options); + const stylesView = this.views.find('Styles'); + stylesView.source += ''; + // Make chainable + return this; + } + + private _loadStyles(filename, options) { + const styles = files.loadStylesSync(this, filename, options); + + let filepath = ''; + if (this.watchFiles) { + /** + * Mark the path to file as an attribute + * Used in development to add event watchers and autorefreshing of styles + * SEE: local file, method this._watchStyles + * SEE: file ./App.js, method App._autoRefresh() + */ + filepath = ' data-filename="' + filename + '"'; + } + const source = '' + styles.css + ''; + + this.views.register(filename, source, { + serverOnly: true + }); + + if (this.watchFiles) { + this._watchStyles(styles.files, filename, options); + } + + return styles; + } + + private _watchViews(filenames, filename, namespace) { + watchOnce(filenames, () => { + this.loadViews(filename, namespace); + this._updateScriptViews(); + this._refreshClients(); + }); + } + + private _watchStyles(filenames, filename, options) { + watchOnce(filenames, () => { + const styles = this._loadStyles(filename, options); + this._updateScriptViews(); + this._refreshStyles(filename, styles); + }); + } + + private _watchBundle(filenames) { + if (!process.send) return; + watchOnce(filenames, function() { + process.send({ type: 'reload' }); + }); + } + + + private _updateScriptViews() { + if (!this.scriptFilename) return; + const script = fs.readFileSync(this.scriptFilename, 'utf8'); + const startIndex = script.indexOf('/*DERBY_SERIALIZED_VIEWS*/'); + const before = script.slice(0, startIndex); + const endIndex = script.indexOf('/*DERBY_SERIALIZED_VIEWS_END*/'); + const after = script.slice(endIndex + 30); + const viewsSource = this._viewsSource(); + fs.writeFileSync(this.scriptFilename, before + viewsSource + after, 'utf8'); + } + + private _autoRefresh(backend) { + // already been setup if agents is defined + if (this.agents) return; + this.agents = {}; + + // Auto-refresh is implemented on top of ShareDB's messaging layer. + // + // However, ShareDB wasn't originally designed to support custom message types, so ShareDB's + // Agent class will log out "Invalid or unknown message" warnings if it encounters a message + // it doesn't recognize. + // + // A workaround is to register a "receive" middleware, which fires when a ShareDB server + // receives a message from a client. If the message is Derby-related, the middleware will + // "exit" the middleware chain early by not calling `next()`. That way, the custom message never + // gets to the ShareDB Agent and won't result in warnings. + // + // However, multiple Derby apps can run together on the same ShareDB backend, each adding a + // "receive" middleware, and they all need to be notified of incoming Derby messages. This + // solution combines the exit-early approach with a custom event to accomplish that. + backend.use('receive', function(request, next) { + const data = request.data; + if (data.derby) { + // Derby-related message, emit custom event and "exit" middleware chain early. + backend.emit('derby:_messageReceived', request.agent, data.derby, data); + return; + } else { + // Not a Derby-related message, pass to next middleware. + next(); + } + }); + + backend.on('derby:_messageReceived', (agent, action, message) => { + this._handleMessageServer(agent, action, message); + }); + } + + private _handleMessageServer(agent, action, message) { + if (action === 'app') { + if (message.name !== this.name) { + return; + } + if (message.hash !== this.scriptHash) { + return agent.send({ derby: 'reload' }); + } + this._addAgent(agent); + } + } + + private _addAgent(agent) { + this.agents[agent.clientId] = agent; + agent.stream.once('end', () => { + delete this.agents[agent.clientId]; + }); + } + + private _refreshClients() { + if (!this.agents) return; + const views = this.views.serialize({ minify: true }); + const message = { + derby: 'refreshViews', + views: views + }; + for (const id in this.agents) { + this.agents[id].send(message); + } + } + + private _refreshStyles(filename, styles) { + if (!this.agents) return; + const message = { + derby: 'refreshStyles', + filename: filename, + css: styles.css + }; + for (const id in this.agents) { + this.agents[id].send(message); + } + } + + middleware(backend) { + return [backend.modelMiddware(), this.router()]; + } + + initAutoRefresh(backend) { + this._autoRefresh(backend); + } +} diff --git a/src/Controller.ts b/src/Controller.ts new file mode 100644 index 000000000..4447af93c --- /dev/null +++ b/src/Controller.ts @@ -0,0 +1,54 @@ +import { EventEmitter } from 'events'; + +import { type Model } from 'racer'; + +import { type AppBase } from './App'; +import { Dom } from './Dom'; +import { PageBase } from './Page'; + +export class Controller extends EventEmitter { + dom: Dom; + app: AppBase; + page: PageBase; + model: Model; + markerNode: Node; + + constructor(app: AppBase, page: PageBase, model: Model) { + super(); + this.dom = new Dom(this); + this.app = app; + this.model = model; + this.page = page; + model.data.$controller = this; + } + + emitCancellable(...args: unknown[]) { + let cancelled = false; + function cancel() { + cancelled = true; + } + + args.push(cancel); + // eslint-disable-next-line prefer-spread + this.emit.apply(this, args); + + return cancelled; + } + + emitDelayable(...args: unknown[]) { + const callback: () => void = args.pop() as any; + + let delayed = false; + function delay() { + delayed = true; + return callback; + } + + args.push(delay); + // eslint-disable-next-line prefer-spread + this.emit.apply(this, args); + if (!delayed) callback(); + + return delayed; + } +} diff --git a/src/Derby.ts b/src/Derby.ts new file mode 100644 index 000000000..8abfc6735 --- /dev/null +++ b/src/Derby.ts @@ -0,0 +1,41 @@ +/* + * Derby.js + * Meant to be the entry point for the framework. + * + */ + +import { App, type AppBase } from './App'; +import { Component } from './components'; +import { Page } from './Page'; + +// eslint-disable-next-line @typescript-eslint/no-var-requires +const racer = require('racer'); +// eslint-disable-next-line @typescript-eslint/no-var-requires +const Racer = require('racer/lib/Racer'); + +export abstract class DerbyBase extends Racer { + Component = Component; + abstract createApp(name: string, filename: string, options): AppBase +} + +export class Derby extends DerbyBase { + App = App; + Page = Page; + Model: typeof racer.Model; + + createApp(name: string, filename: string, options) { + return new this.App(this, name, filename, options); + } + + use(plugin, options) { + return racer.util.use.call(this, plugin, options); + } + + serverUse(plugin, options) { + return racer.util.serverUse.call(this, plugin, options); + } +} + +if (!racer.util.isServer) { + module.require('./documentListeners').add(document); +} diff --git a/src/DerbyForServer.ts b/src/DerbyForServer.ts new file mode 100644 index 000000000..417a37891 --- /dev/null +++ b/src/DerbyForServer.ts @@ -0,0 +1,13 @@ +import { AppBase } from './App'; +import { AppForServer } from './AppForServer'; +import { DerbyBase } from './Derby'; +import { PageForServer } from './PageForServer'; + +export class DerbyForServer extends DerbyBase { + App: typeof AppForServer = AppForServer; + Page: typeof PageForServer = PageForServer; + + createApp(name: string, filename: string, options: any): AppBase { + return new this.App(this, name, filename, options); + } +} diff --git a/src/DerbyStandalone.ts b/src/DerbyStandalone.ts new file mode 100644 index 000000000..82a044985 --- /dev/null +++ b/src/DerbyStandalone.ts @@ -0,0 +1,33 @@ +import Model = require('racer/lib/Model/ModelStandalone'); +import util = require('racer/lib/util'); + +import { App } from './App'; +import * as components from './components'; +import { DerbyBase } from './Derby'; +import { Page } from './Page'; + + +// eslint-disable-next-line @typescript-eslint/no-var-requires +require('./documentListeners').add(document); + +// Standard Derby inherits from Racer, but we only set up the event emitter and +// expose the Model and util here instead +export class DerbyStandalone extends DerbyBase { + Model = Model; + util = util; + + App = AppStandalone; + Page = Page; + Component = components.Component; + + createApp() { + return new this.App(this, null, null, null); + } +} + +export class AppStandalone extends App { + _init() { + this.model = new this.derby.Model(); + this.createPage(); + } +} diff --git a/src/Dom.ts b/src/Dom.ts new file mode 100644 index 000000000..bb60a8ce6 --- /dev/null +++ b/src/Dom.ts @@ -0,0 +1,246 @@ +import { type Controller } from './Controller'; + +type ListenerFn = K extends 'destroy' + ? () => void + : (event: EventMap[K]) => void; + +interface EventMap extends DocumentEventMap { + 'destroy': never; +} + +export class Dom { + controller: Controller; + _listeners: DomListener[]; + + constructor(controller) { + this.controller = controller; + this._listeners = null; + } + + _initListeners() { + this.controller.on('destroy', () => { + const listeners = this._listeners; + if (!listeners) return; + for (let i = listeners.length; i--;) { + listeners[i].remove(); + } + this._listeners = null; + }); + return this._listeners = []; + } + + _listenerIndex(domListener) { + const listeners = this._listeners; + if (!listeners) return -1; + for (let i = listeners.length; i--;) { + if (listeners[i].equals(domListener)) return i; + } + return -1; + } + + /** + * Adds a DOM event listener that will get cleaned up when this component is cleaned up. + * + * @param type - Name of the DOM event to listen to + * @param target - Optional target to add the DOM listener to. If not provided, the target is `document`. + * @param listener - Listener to be called when the DOM event occurs + * @param useCapture - Optional, defaults to false. If true, add the listener as a capturing listener. + * + * @see https://developer.mozilla.org/en-US/docs/Web/API/EventTarget/addEventListener + */ + addListener( + type: K, + target: EventTarget, + listener: ListenerFn, + useCapture?: boolean + ): void; + addListener( + type: K, + listener: ListenerFn, + useCapture?: boolean + ): void; + addListener( + type: K, + target: EventTarget | (ListenerFn), + listener?: (ListenerFn) | boolean, + useCapture?: boolean, + ): void { + if (typeof target === 'function') { + useCapture = !!(listener as boolean); + listener = target as ListenerFn; + target = document; + } + const domListener = (type === 'destroy') + ? new DestroyListener(target, listener as ListenerFn<'destroy'>) + : new DomListener(type, target, listener as ListenerFn, useCapture); + if (-1 === this._listenerIndex(domListener)) { + const listeners = this._listeners || this._initListeners(); + listeners.push(domListener); + } + domListener.add(); + } + + /** + * Adds a DOM event listener that will get cleaned up when this component is cleaned up. + * + * @param type - Name of the DOM event to listen to + * @param target - Optional target to add the DOM listener to. If not provided, the target is `document`. + * @param listener - Listener to be called when the DOM event occurs + * @param useCapture - Optional, defaults to false. If true, add the listener as a capturing listener. + * + * @see https://developer.mozilla.org/en-US/docs/Web/API/EventTarget/addEventListener + */ + on( + type: K, + target: EventTarget, + listener: ListenerFn, + useCapture?: boolean + ): void; + on( + type: K, + listener: ListenerFn, + useCapture?: boolean + ): void; + on( + type: K, + target: EventTarget | (ListenerFn), + listener?: (ListenerFn) | boolean, + useCapture?: boolean, + ): void { + if (typeof target === 'function') { + listener = target as ListenerFn; + target = document; + } + this.addListener(type, target, listener as ListenerFn, useCapture); + } + + /** + * Adds a one-time DOM event listener that will get cleaned up when this component is cleaned up. + * + * @param type - Name of the DOM event to listen to + * @param target - Optional target to add the DOM listener to. If not provided, the target is `document`. + * @param listener - Listener to be called when the DOM event occurs + * @param useCapture - Optional, defaults to false. If true, add the listener as a capturing listener. + * + * @see https://developer.mozilla.org/en-US/docs/Web/API/EventTarget/addEventListener + */ + once( + type: K, + target: EventTarget, + listener: ListenerFn, + useCapture?: boolean + ): void; + once( + type: K, + listener: ListenerFn, + useCapture?: boolean + ): void; + once( + type: K, + target: EventTarget | (ListenerFn), + listener?: (ListenerFn) | boolean, + useCapture?: boolean, + ): void { + if (typeof target === 'function') { + useCapture = !!(listener); + listener = target as ListenerFn; + target = document; + } + const wrappedListener = ((...args) => { + this.removeListener(type, target as EventTarget, wrappedListener, useCapture); + return (listener as ListenerFn).apply(this, args); + }) as ListenerFn; + this.addListener(type, target, wrappedListener, useCapture); + } + + /** + * Removes a DOM event listener that was added via `#addListener`, `#on`, or `#once`, using the same + * parameters as those methods. + * + * @param type - Name of the DOM event + * @param target - Optional target for the DOM listener. If not provided, the target is `document`. + * @param listener - Listener function that was passed to `#addListener`, `#on`, or `#once`. + * @param useCapture - Optional, defaults to false. If true, removes a capturing listener. + * + * @see https://developer.mozilla.org/en-US/docs/Web/API/EventTarget/addEventListener + */ + removeListener( + type: K, + target: EventTarget, + listener: ListenerFn, + useCapture?: boolean + ): void; + removeListener( + type: K, + listener: ListenerFn, + useCapture?: boolean + ): void; + removeListener( + type: K, + target: EventTarget | ListenerFn, + listener?: (ListenerFn) | boolean, + useCapture?: boolean, + ): void { + if (typeof target === 'function') { + useCapture = !!(listener); + listener = target; + target = document; + } + const domListener = new DomListener(type, target, listener as ListenerFn, useCapture); + domListener.remove(); + const i = this._listenerIndex(domListener); + if (i > -1) this._listeners.splice(i, 1); + } +} + +export class DomListener{ + type: string; + target: EventTarget; + listener: ListenerFn; + useCapture: boolean; + + constructor(type: string, target: EventTarget, listener: ListenerFn, useCapture?: boolean) { + this.type = type; + this.target = target; + this.listener = listener; + this.useCapture = !!useCapture; + } + + equals(domListener) { + return this.listener === domListener.listener && + this.target === domListener.target && + this.type === domListener.type && + this.useCapture === domListener.useCapture; + } + + add() { + this.target.addEventListener(this.type, this.listener, this.useCapture); + } + + remove() { + this.target.removeEventListener(this.type, this.listener, this.useCapture); + } +} + +export class DestroyListener extends DomListener<'destroy'> { + constructor(target: EventTarget, listener: ListenerFn<'destroy'>) { + super('destroy', target, listener); + DomListener.call(this, 'destroy', target, listener); + } + + add() { + const listeners = this.target.$destroyListeners || (this.target.$destroyListeners = []); + if (listeners.indexOf(this.listener) === -1) { + listeners.push(this.listener); + } + } + + remove() { + const listeners = this.target.$destroyListeners; + if (!listeners) return; + const index = listeners.indexOf(this.listener); + if (index !== -1) { + listeners.splice(index, 1); + } + } +} diff --git a/src/Page.ts b/src/Page.ts new file mode 100644 index 000000000..9d6c963a3 --- /dev/null +++ b/src/Page.ts @@ -0,0 +1,459 @@ +import { type Model } from 'racer'; +import util = require('racer/lib/util'); + +import { type AppBase, type App } from './App'; +import components = require('./components'); +import { Controller } from './Controller'; +import documentListeners = require('./documentListeners'); +import EventModel = require('./eventmodel'); +import { type PageParams } from './routes'; +import * as derbyTemplates from './templates'; +import { Context } from './templates/contexts'; +import { Expression } from './templates/expressions'; +import textDiff = require('./textDiff'); + +const { + contexts, + DependencyOptions, + expressions, + templates, +} = derbyTemplates; + +export abstract class PageBase extends Controller { + params: Readonly; + context: Context; + create: (model: Model, dom: any) => void; + init?: (model: Model) => void; + _components: Record + _eventModel: any; + _removeModelListeners: () => void = () => {}; + page: PageBase; + + constructor(app: AppBase, model: Model) { + super(app, null, model); + this.params = null; + this._eventModel = null; + this._removeModelListeners = () => {}; + this._components = {}; + if (this.init) this.init(model); + this.context = this._createContext(); + this.page = this; + } + + $bodyClass(ns: string) { + if (!ns) return; + const classNames = []; + const segments = ns.split(':'); + for (let i = 0, len = segments.length; i < len; i++) { + const className = segments.slice(0, i + 1).join('-'); + classNames.push(className); + } + return classNames.join(' '); + } + + get(viewName: string, ns: string, unescaped?) { + this._setRenderPrefix(ns); + const view = this.getView(viewName, ns); + return view.get(this.context, unescaped); + } + + getFragment(viewName: string, ns: string) { + this._setRenderPrefix(ns); + const view = this.getView(viewName, ns); + return view.getFragment(this.context); + } + + getView(viewName: string, ns: string) { + return this.app.views.find(viewName, ns); + } + + destroy() { + this.emit('destroy'); + this._removeModelListeners(); + for (const id in this._components) { + const component = this._components[id]; + component.destroy(); + } + // Remove all data, refs, listeners, and reactive functions + // for the previous page + const silentModel = this.model.silent(); + silentModel.destroy('_page'); + silentModel.destroy('$components'); + // Unfetch and unsubscribe from all queries and documents + if (silentModel.unloadAll) { + silentModel.unloadAll(); + } + } + + render(_ns: string, _status?: number) {} + + _createContext() { + const contextMeta = new contexts.ContextMeta(); + contextMeta.views = this.app && this.app.views; + const context = new contexts.Context(contextMeta, this); + context.expression = new expressions.PathExpression([]); + context.alias = '#root'; + return context; + } + + _setRenderPrefix(ns: string) { + const prefix = (ns) ? ns + ':' : ''; + this.model.set('$render.prefix', prefix); + } + + _setRenderParams(ns) { + this.model.set('$render.ns', ns); + this.model.set('$render.params', this.params); + this.model.set('$render.url', this.params && this.params.url); + this.model.set('$render.query', this.params && this.params.query); + } +} + +export class Page extends PageBase { + constructor(app: App, model: Model) { + super(app, model); + this._addListeners(); + } + + $preventDefault(e: Event) { + e.preventDefault(); + } + + $stopPropagation(e: Event) { + e.stopPropagation(); + } + + attach() { + this.context.pause(); + const ns = this.model.get('$render.ns'); + const titleView = this.getView('TitleElement', ns); + const bodyView = this.getView('BodyElement', ns); + const titleElement = document.getElementsByTagName('title')[0]; + titleView.attachTo(titleElement.parentNode, titleElement, this.context); + bodyView.attachTo(document.body.parentNode, document.body, this.context); + this.context.unpause(); + if (this.create) { + this.create(this.model, this.dom); + } + } + + render(ns: string, _status?: number) { + this.app.emit('render', this); + this.context.pause(); + this._setRenderParams(ns); + const titleFragment = this.getFragment('TitleElement', ns); + const bodyFragment = this.getFragment('BodyElement', ns); + const titleElement = document.getElementsByTagName('title')[0]; + titleElement.parentNode.replaceChild(titleFragment, titleElement); + document.body.parentNode.replaceChild(bodyFragment, document.body); + this.context.unpause(); + if (this.create) { + this.create(this.model, this.dom); + } + this.app.emit('routeDone', this, 'render'); + } + + private _addListeners() { + const eventModel = this._eventModel = new EventModel(); + this._addModelListeners(eventModel); + this._addContextListeners(eventModel); + } + + private _addModelListeners(eventModel) { + const model = this.model; + if (!model) return; + // Registering model listeners with the *Immediate events helps to prevent + // a bug with binding updates where a model listener causes a change to the + // path being listened on, directly or indirectly. + + // TODO: Remove this when upgrading Racer to the next major version. Feature + // detect which type of event listener to register by emitting a test event + if (useLegacyListeners(model)) { + return this._addModelListenersLegacy(eventModel); + } + + // `util.castSegments(segments)` is needed to cast string segments into + // numbers, since EventModel#child does typeof checks against segments. This + // could be done once in Racer's Model#emit, instead of in every listener. + const changeListener = model.on('changeImmediate', function onChange(segments, event) { + // The pass parameter is passed in for special handling of updates + // resulting from stringInsert or stringRemove + segments = util.castSegments(segments.slice()); + eventModel.set(segments, event.previous, event.passed); + }); + const loadListener = model.on('loadImmediate', function onLoad(segments) { + segments = util.castSegments(segments.slice()); + eventModel.set(segments); + }); + const unloadListener = model.on('unloadImmediate', function onUnload(segments, event) { + segments = util.castSegments(segments.slice()); + eventModel.set(segments, event.previous); + }); + const insertListener = model.on('insertImmediate', function onInsert(segments, event) { + segments = util.castSegments(segments.slice()); + eventModel.insert(segments, event.index, event.values.length); + }); + const removeListener = model.on('removeImmediate', function onRemove(segments, event) { + segments = util.castSegments(segments.slice()); + eventModel.remove(segments, event.index, event.values.length); + }); + const moveListener = model.on('moveImmediate', function onMove(segments, event) { + segments = util.castSegments(segments.slice()); + eventModel.move(segments, event.from, event.to, event.howMany); + }); + + this._removeModelListeners = function() { + model.removeListener('changeImmediate', changeListener); + model.removeListener('loadImmediate', loadListener); + model.removeListener('unloadImmediate', unloadListener); + model.removeListener('insertImmediate', insertListener); + model.removeListener('removeImmediate', removeListener); + model.removeListener('moveImmediate', moveListener); + }; + } + + private _addModelListenersLegacy(eventModel) { + const model = this.model; + if (!model) return; + + // `util.castSegments(segments)` is needed to cast string segments into + // numbers, since EventModel#child does typeof checks against segments. This + // could be done once in Racer's Model#emit, instead of in every listener. + const changeListener = model.on('changeImmediate', function onChange(segments, eventArgs) { + // eventArgs[0] is the new value, which Derby bindings don't use directly. + // The pass parameter is passed in for special handling of updates + // resulting from stringInsert or stringRemove + const [ previous, pass ] = eventArgs; + segments = util.castSegments(segments.slice()); + eventModel.set(segments, previous, pass); + }); + const loadListener = model.on('loadImmediate', function onLoad(segments) { + segments = util.castSegments(segments.slice()); + eventModel.set(segments); + }); + const unloadListener = model.on('unloadImmediate', function onUnload(segments) { + segments = util.castSegments(segments.slice()); + eventModel.set(segments); + }); + const insertListener = model.on('insertImmediate', function onInsert(segments, eventArgs) { + const [index, values] = eventArgs; + segments = util.castSegments(segments.slice()); + eventModel.insert(segments, index, values.length); + }); + const removeListener = model.on('removeImmediate', function onRemove(segments, eventArgs) { + const [index, values] = eventArgs; + segments = util.castSegments(segments.slice()); + eventModel.remove(segments, index, values.length); + }); + const moveListener = model.on('moveImmediate', function onMove(segments, eventArgs) { + const [from, to, howMany] = eventArgs; + segments = util.castSegments(segments.slice()); + eventModel.move(segments, from, to, howMany); + }); + + this._removeModelListeners = function() { + model.removeListener('changeImmediate', changeListener); + model.removeListener('loadImmediate', loadListener); + model.removeListener('unloadImmediate', unloadListener); + model.removeListener('insertImmediate', insertListener); + model.removeListener('removeImmediate', removeListener); + model.removeListener('moveImmediate', moveListener); + }; + } + + private _addContextListeners(eventModel) { + this.context.meta.addBinding = addBinding; + this.context.meta.removeBinding = removeBinding; + this.context.meta.removeNode = removeNode; + this.context.meta.addItemContext = addItemContext; + this.context.meta.removeItemContext = removeItemContext; + + function addItemContext(context) { + const segments = context.expression.resolve(context); + eventModel.addItemContext(segments, context); + } + function removeItemContext(_context) { + // TODO + } + function addBinding(binding) { + patchTextBinding(binding); + const expressions = binding.template.expressions; + if (expressions) { + for (let i = 0, len = expressions.length; i < len; i++) { + addDependencies(eventModel, expressions[i], binding); + } + } else { + const expression = binding.template.expression; + addDependencies(eventModel, expression, binding); + } + } + function removeBinding(binding) { + const bindingWrappers = binding.meta; + if (!bindingWrappers) return; + for (let i = bindingWrappers.length; i--;) { + eventModel.removeBinding(bindingWrappers[i]); + } + } + function removeNode(node) { + const component = node.$component; + if (component) component.destroy(); + const destroyListeners = node.$destroyListeners; + if (destroyListeners) { + for (let i = 0; i < destroyListeners.length; i++) { + destroyListeners[i](); + } + } + } + } +} + +function useLegacyListeners(model) { + let useLegacy = true; + // model.once is broken in older racer, so manually remove event + const listener = model.on('changeImmediate', function(_segments, event) { + model.removeListener('changeImmediate', listener); + // Older Racer emits an array of eventArgs, whereas newer racer emits an event object + useLegacy = Array.isArray(event); + }); + model.set('$derby.testEvent', true); + return useLegacy; +} + +function addDependencies(eventModel, expression, binding) { + const bindingWrapper = new BindingWrapper(eventModel, expression, binding); + bindingWrapper.updateDependencies(); +} + +// The code here uses object-based set pattern where objects are keyed using +// sequentially generated IDs. +let nextId = 1; +export class BindingWrapper{ + binding: any; + dependencies: any; + eventModel: any; + eventModels: any; + expression: Expression; + id: number; + ignoreTemplateDependency: boolean; + + constructor(eventModel, expression, binding) { + this.eventModel = eventModel; + this.expression = expression; + this.binding = binding; + this.id = nextId++; + this.eventModels = null; + this.dependencies = null; + this.ignoreTemplateDependency = ( + binding instanceof components.ComponentAttributeBinding + ) || ( + (binding.template instanceof templates.DynamicText) && + (binding instanceof templates.RangeBinding) + ); + if (binding.meta) { + binding.meta.push(this); + } else { + binding.meta = [this]; + } + } + + updateDependencies = function() { + let dependencyOptions; + if (this.ignoreTemplateDependency && this.binding.condition instanceof templates.Template) { + dependencyOptions = new DependencyOptions(); + dependencyOptions.setIgnoreTemplate(this.binding.condition); + } + + const dependencies = this.expression.dependencies(this.binding.context, dependencyOptions); + if (this.dependencies) { + // Do nothing if dependencies haven't changed + if (equalDependencies(this.dependencies, dependencies)) return; + // Otherwise, remove current dependencies + this.eventModel.removeBinding(this); + } + + // Add new dependencies + if (!dependencies) return; + + this.dependencies = dependencies; + for (let i = 0, len = dependencies.length; i < len; i++) { + const dependency = dependencies[i]; + if (dependency) this.eventModel.addBinding(dependency, this); + } + }; + + update = function(previous, pass) { + this.binding.update(previous, pass); + this.updateDependencies(); + }; + + insert = function(index, howMany) { + this.binding.insert(index, howMany); + this.updateDependencies(); + }; + + remove = function(index, howMany) { + this.binding.remove(index, howMany); + this.updateDependencies(); + }; + + move = function(from, to, howMany) { + this.binding.move(from, to, howMany); + this.updateDependencies(); + }; +} + +function equalDependencies(a, b) { + const lenA = a ? a.length : -1; + const lenB = b ? b.length : -1; + if (lenA !== lenB) return false; + for (let i = 0; i < lenA; i++) { + const itemA = a[i]; + const itemB = b[i]; + const lenItemA = itemA ? itemA.length : -1; + const lenItemB = itemB ? itemB.length : -1; + if (lenItemA !== lenItemB) return false; + for (let j = 0; j < lenItemB; j++) { + if (itemA[j] !== itemB[j]) return false; + } + } + return true; +} + +function patchTextBinding(binding) { + if ( + binding instanceof templates.AttributeBinding && + binding.name === 'value' && + (binding.element.tagName === 'INPUT' || binding.element.tagName === 'TEXTAREA') && + documentListeners.inputSupportsSelection(binding.element) && + binding.template.expression.resolve(binding.context) + ) { + binding.update = textInputUpdate; + } +} + +function textInputUpdate(previous, pass) { + textUpdate(this, this.element, previous, pass); +} + +function textUpdate(binding, element, previous, pass) { + if (pass) { + if (pass.$event && pass.$event.target === element) { + return; + } else if (pass.$stringInsert) { + return textDiff.onStringInsert( + element, + previous, + pass.$stringInsert.index, + pass.$stringInsert.text + ); + } else if (pass.$stringRemove) { + return textDiff.onStringRemove( + element, + previous, + pass.$stringRemove.index, + pass.$stringRemove.howMany + ); + } + } + binding.template.update(binding.context, binding); +} diff --git a/src/PageForServer.ts b/src/PageForServer.ts new file mode 100644 index 000000000..0e9d20b21 --- /dev/null +++ b/src/PageForServer.ts @@ -0,0 +1,100 @@ +import type { Request, Response } from 'express'; +import { type Model } from 'racer'; + +import { type AppForServer } from './AppForServer'; +import { PageBase } from './Page'; +import { type PageParams } from './routes'; + +export class PageForServer extends PageBase { + req: Request; + res: Response; + page: PageForServer; + + constructor(app: AppForServer, model: Model, req: Request, res: Response) { + super(app, model); + this.req = req; + this.res = res; + this.page = this; + } + + render(ns?: string, status?: number) { + if (typeof status !== 'number') { + ns = status; + status = null; + } + this.app.emit('render', this); + + if (status) this.res.statusCode = status; + // Prevent the browser from storing the HTML response in its back cache, since + // that will cause it to render with the data from the initial load first + this.res.setHeader('Cache-Control', 'no-store'); + // Set HTML utf-8 content type unless already set + if (!this.res.getHeader('Content-Type')) { + this.res.setHeader('Content-Type', 'text/html; charset=utf-8'); + } + + this._setRenderParams(ns); + const pageHtml = this.get('Page', ns); + this.res.write(pageHtml); + this.app.emit('htmlDone', this); + + this.res.write('' + tailHtml); + this.app.emit('routeDone', this, 'render'); + }); + } + + renderStatic(status?: number, ns?: string) { + if (typeof status !== 'number') { + ns = status; + status = null; + } + this.app.emit('renderStatic', this); + + if (status) this.res.statusCode = status; + this.params = pageParams(this.req); + this._setRenderParams(ns); + const pageHtml = this.get('Page', ns); + const tailHtml = this.get('Tail', ns); + this.res.send(pageHtml + tailHtml); + this.app.emit('routeDone', this, 'renderStatic'); + } + + // Don't register any listeners on the server + // _addListeners() {} +} + +function stringifyBundle(bundle) { + const json = JSON.stringify(bundle); + return json.replace(/<[/!]/g, function(match) { + // Replace the end tag sequence with an equivalent JSON string to make + // sure the script is not prematurely closed + if (match === ' { + new(): Record +} + +type AnyVoidFunction = (...args: any[]) => void; + +export interface ComponentConstructor { + new(context: Context, data: ModelData): Component; + DataConstructor?: DataConstructor; + singleton?: boolean, + view?: { + dependencies?: any[], + file?: string, + is: string, + source?: string, + viewPartialDependencies?: string[], + } +} + +export interface SingletonComponentConstructor { + new(): Component + singleton: true; + view?: { + is: string, + dependencies?: ComponentConstructor[], + source?: string, + file?: string, + } +} + +export abstract class Component extends Controller { + context: Context; + id: string; + isDestroyed: boolean; + page: PageBase; + parent: Controller; + singleton?: true; + _scope: string[]; + // new style view prop + view?: { + dependencies: ComponentConstructor[], + file: string, + is: string, + source: string, + } + static DataConstructor?: DataConstructor; + + constructor(context: Context, data: ModelData) { + const parent = context.controller; + const id = context.id(); + const scope = ['$components', id]; + const model = parent.model.root.eventContext(id); + model._at = scope.join('.'); + data.id = id; + model._set(scope, data); + // Store a reference to the component's scope such that the expression + // getters are relative to the component + model.data = data; + // IMPORTANT: call super _after_ model created + super(context.controller.app, context.controller.page, model); + + this.parent = parent; + this.context = context.componentChild(this); + this.id = id; + this._scope = scope; + + // Add reference to this component on the page so that all components + // associated with a page can be destroyed when the page transitions + this.page._components[id] = this; + this.isDestroyed = false; + } + + init(_model: ChildModel): void {} + + destroy() { + this.emit('destroy'); + this.model.removeContextListeners(); + this.model.destroy(); + delete this.page._components[this.id]; + if (this.page._eventModel.object) { + const components = this.page._eventModel.object.$components; + if (components) delete components.object[this.id]; + } + this.isDestroyed = true; + } + + // Apply calls to the passed in function with the component as the context. + // Stop calling back once the component is destroyed, which avoids possible bugs + // and memory leaks. + bind(callback: (...args: unknown[]) => void) { + // eslint-disable-next-line @typescript-eslint/no-this-alias + let _component = this; + let _callback = callback; + this.on('destroy', function() { + // Reduce potential for memory leaks by removing references to the component + // and the passed in callback, which could have closure references + _component = null; + // Cease calling back after component is removed from the DOM + _callback = null; + }); + return function componentBindWrapper(...args) { + if (!_callback) return; + return _callback.apply(_component, ...args); + }; + } + + // When passing in a numeric delay, calls the function at most once per that + // many milliseconds. Like Underscore, the function will be called on the + // leading and the trailing edge of the delay as appropriate. Unlike Underscore, + // calls are consistently called via setTimeout and are never synchronous. This + // should be used for reducing the frequency of ongoing updates, such as scroll + // events or other continuous streams of events. + // + // Additionally, implements an interface intended to be used with + // window.requestAnimationFrame or process.nextTick. If one of these is passed, + // it will be used to create a single async call following any number of + // synchronous calls. This mode is typically used to coalesce many synchronous + // events (such as multiple model events) into a single async event. + // + // Like component.bind(), will no longer call back once the component is + // destroyed, which avoids possible bugs and memory leaks. + throttle(callback: (...args: unknown[]) => void, delayArg?: number | ((fn: () => void) => void)) { + // eslint-disable-next-line @typescript-eslint/no-this-alias + let _component = this; + this.on('destroy', function() { + // Reduce potential for memory leaks by removing references to the component + // and the passed in callback, which could have closure references + _component = null; + // Cease calling back after component is removed from the DOM + callback = null; + }); + + // throttle(callback) + // throttle(callback, 150) + if (delayArg == null || typeof delayArg === 'number') { + const delay = delayArg || 0; + let nextArgs; + let previous; + const boundCallback = function() { + const args = nextArgs; + nextArgs = null; + previous = +new Date(); + if (callback && args) { + callback.apply(_component, args); + } + }; + return function componentThrottleWrapper(...args) { + const queueCall = !nextArgs; + nextArgs = slice.call(args); + if (queueCall) { + const now = +new Date(); + const remaining = Math.max(previous + delay - now, 0); + setTimeout(boundCallback, remaining); + } + }; + } + + // throttle(callback, window.requestAnimationFrame) + // throttle(callback, process.nextTick) + if (typeof delayArg === 'function') { + let nextArgs; + const boundCallback = function() { + const args = nextArgs; + nextArgs = null; + if (callback && args) { + callback.apply(_component, args); + } + }; + return function componentThrottleWrapper(...args) { + const queueCall = !nextArgs; + nextArgs = slice.call(args); + if (queueCall) delayArg(boundCallback); + }; + } + + throw new Error('Second argument must be a delay function or number'); + } + + // Checks that component is not destroyed before calling callback function + // which avoids possible bugs and memory leaks. + requestAnimationFrame(callback: () => void) { + const safeCallback = _safeWrap(this, callback); + window.requestAnimationFrame(safeCallback); + } + + // Checks that component is not destroyed before calling callback function + // which avoids possible bugs and memory leaks. + nextTick(callback: () => void) { + const safeCallback = _safeWrap(this, callback); + process.nextTick(safeCallback); + } + + // Suppresses calls until the function is no longer called for that many + // milliseconds. This should be used for delaying updates triggered by user + // input, such as window resizing, or typing text that has a live preview or + // client-side validation. This should not be used for inputs that trigger + // server requests, such as search autocomplete; use debounceAsync for those + // cases instead. + // + // Like component.bind(), will no longer call back once the component is + // destroyed, which avoids possible bugs and memory leaks. + debounce(callback: (...args: Parameters) => void, delay?: number): (...args: Parameters) => void { + delay = delay || 0; + if (typeof delay !== 'number') { + throw new Error('Second argument must be a number'); + } + // eslint-disable-next-line @typescript-eslint/no-this-alias + let component = this; + this.on('destroy', function() { + // Reduce potential for memory leaks by removing references to the component + // and the passed in callback, which could have closure references + component = null; + // Cease calling back after component is removed from the DOM + callback = null; + }); + let nextArgs; + let timeout; + const boundCallback = function() { + const args = nextArgs; + nextArgs = null; + timeout = null; + if (callback && args) { + callback.apply(component, args); + } + }; + return function componentDebounceWrapper(...args: Parameters) { + nextArgs = slice.call(args); + if (timeout) clearTimeout(timeout); + timeout = setTimeout(boundCallback, delay); + }; + } + + // Forked from: https://github.com/juliangruber/async-debounce + // + // Like debounce(), suppresses calls until the function is no longer called for + // that many milliseconds. In addition, suppresses calls while the callback + // function is running. In other words, the callback will not be called again + // until the supplied done() argument is called. When the debounced function is + // called while the callback is running, the callback will be called again + // immediately after done() is called. Thus, the callback will always receive + // the last value passed to the debounced function. + // + // This avoids the potential for multiple callbacks to execute in parallel and + // complete out of order. It also acts as an adaptive rate limiter. Use this + // method to debounce any field that triggers an async call as the user types. + // + // Like component.bind(), will no longer call back once the component is + // destroyed, which avoids possible bugs and memory leaks. + debounceAsync(callback: (...args: Parameters) => void, delay?: number): (...args: Parameters) => void { + const applyArguments = callback.length !== 1; + delay = delay || 0; + if (typeof delay !== 'number') { + throw new Error('Second argument must be a number'); + } + // eslint-disable-next-line @typescript-eslint/no-this-alias + let component = this; + this.on('destroy', function() { + // Reduce potential for memory leaks by removing references to the component + // and the passed in callback, which could have closure references + component = null; + // Cease calling back after component is removed from the DOM + callback = null; + }); + let running = false; + let nextArgs; + let timeout; + function done() { + const args = nextArgs; + nextArgs = null; + timeout = null; + if (callback && args) { + running = true; + args.push(done); + callback.apply(component, args); + } else { + running = false; + } + } + return function componentDebounceAsyncWrapper(...args: Parameters) { + nextArgs = (applyArguments) ? slice.call(args) : []; + if (timeout) clearTimeout(timeout); + if (running) return; + timeout = setTimeout(done, delay); + }; + } + + get(viewName: string, unescaped: boolean) { + const view = this.getView(viewName); + return view.get(this.context, unescaped); + } + + getFragment(viewName: string, _ns?: string) { + const view = this.getView(viewName); + return view.getFragment(this.context); + } + + getView(viewName: string, _ns?: string) { + const contextView = this.context.getView(); + return (viewName) ? + this.app.views.find(viewName, contextView.namespace) : contextView; + } + + getAttribute(key: string) { + const attributeContext = this.context.forAttribute(key); + if (!attributeContext) return; + let value = attributeContext.attributes[key]; + if (value instanceof expressions.Expression) { + value = value.get(attributeContext); + } + return expressions.renderValue(value, this.context) as T; + } + + setAttribute(key: string, value: Attribute) { + this.context.parent.attributes[key] = value; + } + + setNullAttribute(key: string, value: Attribute) { + const attributes = this.context.parent.attributes; + if (attributes[key] == null) attributes[key] = value; + } +} + +function _safeWrap(component: Component, callback: () => void) { + return function() { + if (component.isDestroyed) return; + callback.call(component); + }; +} + +export class ComponentAttribute { + expression: Expression; + model: ChildModel; + key: string; + + constructor(expression: Expression, model: ChildModel, key: string) { + this.expression = expression; + this.model = model; + this.key = key; + } + + update(context: Context, binding: Binding) { + const value = this.expression.get(context); + // @ts-expect-error Unsure what type binding should be w condition + binding.condition = value; + this.model.setDiff(this.key, value); + } +} + +export class ComponentAttributeBinding extends Binding { + template: any; + context: Context; + condition: any; + + constructor(expression: Expression, model: any, key: any, context: Context) { + super(); + this.template = new ComponentAttribute(expression, model, key); + this.context = context; + this.condition = expression.get(context); + } +} + +function setModelAttributes(context: Context, model: ChildModel) { + const attributes = context.parent.attributes; + if (!attributes) return; + // Set attribute values on component model + for (const key in attributes) { + const value = attributes[key]; + setModelAttribute(context, model, key, value); + } +} + +function setModelAttribute(context: Context, model: ChildModel, key: string, value: unknown) { + // If an attribute is an Expression, set its current value in the model + // and keep it up to date. When it is a resolvable path, use a Racer ref, + // which makes it a two-way binding. Otherwise, set to the current value + // and create a binding that will set the value in the model as the + // expression's dependencies change. + if (value instanceof expressions.Expression) { + const segments = value.pathSegments(context); + if (segments) { + model.root.ref(model._at + '.' + key, segments.join('.'), {updateIndices: true}); + } else { + const binding = new ComponentAttributeBinding(value, model, key, context); + context.addBinding(binding); + model.set(key, binding.condition); + } + return; + } + + // If an attribute is a Template, set a template object in the model. + // Eagerly rendering a template can cause excessive rendering when the + // developer wants to pass in a complex chunk of HTML, and if we were to + // set a string in the model that represents the template value, we'd lose + // the ability to use the value in the component's template, since HTML + // would be escaped and we'd lose the ability to create proper bindings. + // + // This may be of surprise to developers, since it may not be intuitive + // whether a passed in value will produce an expression or a template. To + // get the rendered value consistently, the component's getAttribute(key) + // method may be used to get the value that would be rendered. + if (value instanceof templates.Template) { + const template = new templates.ContextClosure(value, context); + model.set(key, template); + return; + } + + // For all other value types, set the passed in value directly. Passed in + // values will only be set initially, so model paths should be used if + // bindings are desired. + model.set(key, value); +} + +export function createFactory(constructor: ComponentConstructor) { + // DEPRECATED: constructor.prototype.singleton is deprecated. "singleton" + // static property on the constructor is preferred + return (constructor.singleton || constructor.prototype.singleton) ? + new SingletonComponentFactory(constructor) : + new ComponentFactory(constructor); +} + +function emitInitHooks(context, component) { + if (!context.initHooks) return; + // Run initHooks for `on` listeners immediately before init + for (let i = 0, len = context.initHooks.length; i < len; i++) { + context.initHooks[i].emit(context, component); + } +} + +class ComponentModelData { + id = null; + $controller = null; +} + +export class ComponentFactory{ + constructorFn: ComponentConstructor; + + constructor(constructorFn: ComponentConstructor) { + this.constructorFn = constructorFn; + } + + init(context: Context) { + const DataConstructor = this.constructorFn.DataConstructor || ComponentModelData; + const data = new DataConstructor(); + // eslint-disable-next-line new-cap + const component = new this.constructorFn(context, data); + // Detect whether the component constructor already called super by checking + // for one of the properties it sets. If not, call the Component constructor + if (!component.context) { + Component.call(component, context, data); + } + setModelAttributes(component.context, component.model); + // Do the user-specific initialization. The component constructor should be + // an empty function and the actual initialization code should be done in the + // component's init method. This means that we don't have to rely on users + // properly calling the Component constructor method and avoids having to + // play nice with how CoffeeScript extends class constructors + emitInitHooks(context, component); + component.emit('init', component); + if (component.init) component.init(component.model); + + return component.context; + } + + create(context) { + const component = context.controller; + component.emit('create', component); + // Call the component's create function after its view is rendered + if (component.create) { + component.create(component.model, component.dom); + } + } +} + +function noop() {} + +class SingletonComponentFactory{ + constructorFn: SingletonComponentConstructor; + isSingleton: true; + component: Component; + + constructor(constructorFn) { + this.constructorFn = constructorFn; + this.component = null; + // Disable component from being destroyed, since it is intended to + // be used multiple times + constructorFn.prototype.destroy = noop; + } + + init(context) { + // eslint-disable-next-line new-cap + if (!this.component) this.component = new this.constructorFn(); + return context.componentChild(this.component); + } + + // Don't call the init or create methods for singleton components + create() {} +} + +function isBasePrototype(object) { + return (object === Object.prototype) || + (object === Function.prototype) || + (object === null); +} + +function getRootPrototype(object) { + // eslint-disable-next-line no-constant-condition + while (true) { + const prototype = Object.getPrototypeOf(object); + if (isBasePrototype(prototype)) return object; + object = prototype; + } +} + +const _extendComponent = (Object.setPrototypeOf && Object.getPrototypeOf) ? + // Modern version, which supports ES6 classes + function(constructor) { + // Find the end of the prototype chain + const rootPrototype = getRootPrototype(constructor.prototype); + + // This guard is a workaroud to a bug that has occurred in Chakra when + // app.component() is invoked twice on the same constructor. In that case, + // the `instanceof Component` check in extendComponent incorrectly returns + // false after the prototype has already been set to `Component.prototype`. + // Then, this code proceeds to set the prototype of Component.prototype + // to itself, which throws a "Cyclic __proto__ value" error. + // https://github.com/Microsoft/ChakraCore/issues/5915 + if (rootPrototype === Component.prototype) return; + + // Establish inheritance with the pattern that Node's util.inherits() uses + // if Object.setPrototypeOf() is available (all modern browsers & IE11). + // This inhertance pattern is not equivalent to class extends, but it does + // work to make instances of the constructor inherit the desired prototype + // https://github.com/nodejs/node/issues/4179 + Object.setPrototypeOf(rootPrototype, Component.prototype); + } : + // Fallback for older browsers + function(constructor) { + // In this version, we iterate over all of the properties on the + // constructor's prototype and merge them into a new prototype object. + // This flattens the prototype chain, meaning that instanceof will not + // work for classes from which the current component inherits + const prototype = constructor.prototype; + // Otherwise, modify constructor.prototype. This won't work with ES6 + // classes, since their prototype property is non-writeable. However, it + // does work in older browsers that don't support Object.setPrototypeOf(), + // and those browsers don't support ES6 classes either + constructor.prototype = Object.create(Component.prototype); + constructor.prototype.constructor = constructor; + util.mergeInto(constructor.prototype, prototype); + }; + +export function extendComponent(constructor: SingletonComponentConstructor | ComponentConstructor) { + // Don't do anything if the constructor already extends Component + if (constructor.prototype instanceof Component) return; + // Otherwise, append Component.prototype to constructor's prototype chain + _extendComponent(constructor); +} diff --git a/lib/documentListeners.js b/src/documentListeners.js similarity index 98% rename from lib/documentListeners.js rename to src/documentListeners.js index c0972f4b6..db031ccfe 100644 --- a/lib/documentListeners.js +++ b/src/documentListeners.js @@ -34,7 +34,7 @@ function addDocumentListeners(doc) { // the user clicks 'delete' from a context menu when right clicking on selected text. // So although this event fires overly aggressively, it's the only real way // to ensure that we can detect all changes to the input value in IE <= 9 - doc.addEventListener('selectionchange', function(e){ + doc.addEventListener('selectionchange', function(){ if (document.activeElement) { documentInput({target: document.activeElement}); // selectionchange evts don't have the e.target we need } diff --git a/lib/eventmodel.js b/src/eventmodel.js similarity index 100% rename from lib/eventmodel.js rename to src/eventmodel.js diff --git a/lib/files.js b/src/files.js similarity index 98% rename from lib/files.js rename to src/files.js index 8cc132c97..8055d0150 100644 --- a/lib/files.js +++ b/src/files.js @@ -8,7 +8,7 @@ var fs = require('fs'); var path = require('path'); var util = require('racer/lib/util'); var resolve = require('resolve'); -var parsing = require('../parsing'); +var parsing = require('./parsing'); exports.loadViewsSync = loadViewsSync; exports.loadStylesSync = loadStylesSync; diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 000000000..ca44af633 --- /dev/null +++ b/src/index.ts @@ -0,0 +1,9 @@ +import { Derby } from './Derby'; + +// eslint-disable-next-line @typescript-eslint/no-var-requires +const util = require('racer').util; + +const DerbyClass = util.isServer + ? util.serverRequire(module, './DerbyForServer').DerbyForServer + : Derby; +export = new DerbyClass(); diff --git a/lib/parsing/createPathExpression.js b/src/parsing/createPathExpression.ts similarity index 59% rename from lib/parsing/createPathExpression.js rename to src/parsing/createPathExpression.ts index c11968d5c..893ef1461 100644 --- a/lib/parsing/createPathExpression.js +++ b/src/parsing/createPathExpression.ts @@ -1,18 +1,18 @@ -var derbyTemplates = require('../templates'); -var expressions = derbyTemplates.expressions; -var operatorFns = derbyTemplates.operatorFns; -var esprima = require('esprima-derby'); -var Syntax = esprima.Syntax; +import * as esprima from 'esprima-derby'; +import type * as estree from 'estree'; -module.exports = createPathExpression; +import { expressions, operatorFns } from '../templates'; +const { Syntax } = esprima; -function createPathExpression(source) { - var node = esprima.parse(source).expression; +export function createPathExpression(source: string) { + // @ts-expect-error `parse` not declared in @types/esprima + const parsed = esprima.parse(source); + const node = parsed.expression; return reduce(node); } -function reduce(node) { - var type = node.type; +function reduce(node: estree.Node) { + const type = node.type; if (type === Syntax.MemberExpression) { return reduceMemberExpression(node); } else if (type === Syntax.Identifier) { @@ -41,14 +41,14 @@ function reduce(node) { unexpected(node); } -function reduceMemberExpression(node, afterSegments) { +function reduceMemberExpression(node, afterSegments?: string[]) { if (node.computed) { // Square brackets if (node.property.type === Syntax.Literal) { return reducePath(node, node.property.value, afterSegments); } - var before = reduce(node.object); - var inside = reduce(node.property); + const before = reduce(node.object); + const inside = reduce(node.property); return new expressions.BracketsExpression(before, inside, afterSegments); } // Dot notation @@ -58,10 +58,10 @@ function reduceMemberExpression(node, afterSegments) { unexpected(node); } -function reducePath(node, segment, afterSegments) { - var segments = [segment]; +function reducePath(node, segment: string, afterSegments?: string[]) { + let segments = [segment]; if (afterSegments) segments = segments.concat(afterSegments); - var relative = false; + let relative = false; while ((node = node.object)) { if (node.type === Syntax.MemberExpression) { if (node.computed) { @@ -91,26 +91,26 @@ function reducePath(node, segment, afterSegments) { } function reduceIdentifier(node) { - var segments = [node.name]; + const segments = [node.name]; return createSegmentsExpression(segments); } -function reduceThis(node) { - var segments = []; +function reduceThis(_node) { + const segments = []; return new expressions.RelativePathExpression(segments); } -function createSegmentsExpression(segments) { - var firstSegment = segments[0]; - var firstChar = firstSegment.charAt && firstSegment.charAt(0); +function createSegmentsExpression(segments: string[]) { + const firstSegment = segments[0]; + const firstChar = firstSegment.charAt && firstSegment.charAt(0); if (firstChar === '#') { - var alias = firstSegment; + const alias = firstSegment; segments.shift(); return new expressions.AliasPathExpression(alias, segments); } else if (firstChar === '@') { - var attribute = firstSegment.slice(1); + const attribute = firstSegment.slice(1); segments.shift(); return new expressions.AttributePathExpression(attribute, segments); @@ -119,84 +119,83 @@ function createSegmentsExpression(segments) { } } -function reduceCallExpression(node, afterSegments) { +function reduceCallExpression(node: estree.CallExpression, afterSegments?: string[]) { return reduceFnExpression(node, afterSegments, expressions.FnExpression); } -function reduceNewExpression(node, afterSegments) { +function reduceNewExpression(node: estree.NewExpression, afterSegments?: string[]) { return reduceFnExpression(node, afterSegments, expressions.NewExpression); } -function reduceFnExpression(node, afterSegments, Constructor) { - var args = node.arguments.map(reduce); - var callee = node.callee; +function reduceFnExpression(node: estree.CallExpression, afterSegments, Constructor) { + const args = node.arguments.map(reduce); + const callee = node.callee; if (callee.type === Syntax.Identifier) { if (callee.name === '$at') { return new expressions.ScopedModelExpression(args[0]); } - var segments = [callee.name]; - return new Constructor(segments, args, afterSegments); + return new Constructor([callee.name], args, afterSegments); } else if (callee.type === Syntax.MemberExpression) { - var segments = reduceMemberExpression(callee).segments; + const segments = reduceMemberExpression(callee).segments; return new Constructor(segments, args, afterSegments); } else { unexpected(node); } } -function reduceLiteral(node) { +function reduceLiteral(node: estree.Literal) { return new expressions.LiteralExpression(node.value); } -function reduceUnaryExpression(node) { +function reduceUnaryExpression(node: estree.UnaryExpression) { // `-` and `+` can be either unary or binary, so all unary operators are // postfixed with `U` to differentiate - var operator = node.operator + 'U'; - var expression = reduce(node.argument); + const operator = node.operator + 'U'; + const expression = reduce(node.argument); if (expression instanceof expressions.LiteralExpression) { - var fn = operatorFns.get[operator]; + const fn = operatorFns.get[operator]; expression.value = fn(expression.value); return expression; } return new expressions.OperatorExpression(operator, [expression]); } -function reduceBinaryExpression(node) { - var operator = node.operator; - var left = reduce(node.left); - var right = reduce(node.right); +function reduceBinaryExpression(node: estree.BinaryExpression | estree.LogicalExpression) { + const operator = node.operator; + const left = reduce(node.left); + const right = reduce(node.right); if ( left instanceof expressions.LiteralExpression && right instanceof expressions.LiteralExpression ) { - var fn = operatorFns.get[operator]; - var value = fn(left.value, right.value); + const fn = operatorFns.get[operator]; + const value = fn(left.value, right.value); return new expressions.LiteralExpression(value); } return new expressions.OperatorExpression(operator, [left, right]); } -function reduceConditionalExpression(node) { - var test = reduce(node.test); - var consequent = reduce(node.consequent); - var alternate = reduce(node.alternate); +function reduceConditionalExpression(node: estree.ConditionalExpression) { + const test = reduce(node.test); + const consequent = reduce(node.consequent); + const alternate = reduce(node.alternate); if ( test instanceof expressions.LiteralExpression && consequent instanceof expressions.LiteralExpression && alternate instanceof expressions.LiteralExpression ) { - var value = (test.value) ? consequent.value : alternate.value; + const value = (test.value) ? consequent.value : alternate.value; return new expressions.LiteralExpression(value); } return new expressions.OperatorExpression('?', [test, consequent, alternate]); } -function reduceArrayExpression(node) { - var literal = []; - var items = []; - var isLiteral = true; - for (var i = 0; i < node.elements.length; i++) { - var expression = reduce(node.elements[i]); +function reduceArrayExpression(node: estree.ArrayExpression) { + const literal = []; + const items = []; + let isLiteral = true; + for (let i = 0; i < node.elements.length; i++) { + const expression = reduce(node.elements[i]); items.push(expression); if (isLiteral && expression instanceof expressions.LiteralExpression) { literal.push(expression.value); @@ -209,19 +208,28 @@ function reduceArrayExpression(node) { new expressions.ArrayExpression(items); } -function reduceObjectExpression(node) { - var literal = {}; - var properties = {}; - var isLiteral = true; - for (var i = 0; i < node.properties.length; i++) { - var property = node.properties[i]; - var key = getKeyName(property.key); - var expression = reduce(property.value); - properties[key] = expression; - if (isLiteral && expression instanceof expressions.LiteralExpression) { - literal[key] = expression.value; +function isProperty(property: estree.Property | estree.SpreadElement): property is estree.Property { + return (property as estree.Property).type === Syntax.Property; +} + +function reduceObjectExpression(node: estree.ObjectExpression) { + const literal = {}; + const properties = {}; + let isLiteral = true; + for (let i = 0; i < node.properties.length; i++) { + const property = node.properties[i]; + if (isProperty(property)) { + const key = getKeyName(property.key); + const expression = reduce(property.value); + properties[key] = expression; + if (isLiteral && expression instanceof expressions.LiteralExpression) { + literal[key] = expression.value; + } else { + isLiteral = false; + } } else { - isLiteral = false; + // actually a estree.SpreadElement and not supported + unexpected(node); } } return (isLiteral) ? @@ -229,13 +237,13 @@ function reduceObjectExpression(node) { new expressions.ObjectExpression(properties); } -function getKeyName(key) { - return (key.type === Syntax.Identifier) ? key.name : - (key.type === Syntax.Literal) ? key.value : - unexpected(key); +function getKeyName(node): string { + return (node.type === Syntax.Identifier) ? node.name : + (node.type === Syntax.Literal) ? node.value : + unexpected(node); } -function reduceSequenceExpression(node, afterSegments) { +function reduceSequenceExpression(node: estree.SequenceExpression, afterSegments?: string[]) { // Note that sequence expressions are not reduced to a literal if they only // contain literals. There isn't any utility to such an expression, so it // isn't worth optimizing. @@ -243,10 +251,10 @@ function reduceSequenceExpression(node, afterSegments) { // The fact that expressions separated by commas always parse into a sequence // is relied upon in parsing template tags that have comma-separated // arguments following a keyword - var args = node.expressions.map(reduce); + const args = node.expressions.map(reduce); return new expressions.SequenceExpression(args, afterSegments); } -function unexpected(node) { +function unexpected(node: estree.Node) { throw new Error('Unexpected Esprima node: ' + JSON.stringify(node, null, 2)); } diff --git a/lib/parsing/index.js b/src/parsing/index.ts similarity index 63% rename from lib/parsing/index.js rename to src/parsing/index.ts index 16db0bc5b..663d1ccd3 100644 --- a/lib/parsing/index.js +++ b/src/parsing/index.ts @@ -1,39 +1,50 @@ -var derbyTemplates = require('../templates'); -var htmlUtil = require('html-util'); -var path = require('path'); -var App = require('../App'); -var createPathExpression = require('./createPathExpression'); -var markup = require('./markup'); - -var templates = derbyTemplates.templates; -var expressions = derbyTemplates.expressions; - -exports.createTemplate = createTemplate; -exports.createStringTemplate = createStringTemplate; -exports.createExpression = createExpression; -exports.createPathExpression = createPathExpression; -exports.markup = markup; +import * as path from 'path'; + +import htmlUtil = require('html-util'); + +import { createPathExpression } from './createPathExpression'; +import { markup } from './markup'; +import { App, AppBase } from '../App'; +import { templates, expressions } from '../templates'; +import { Expression } from '../templates/expressions'; +import { MarkupHook, View } from '../templates/templates'; + +export { createPathExpression } from './createPathExpression'; +export { markup } from './markup'; + +declare module '../App' { + interface App { + addViews(file: string, namespace: string): void; + } +} + +interface ParsedView { + name: string; + source: string; + options: unknown; + filename?: string; +} // View.prototype._parse is defined here, so that it doesn't have to // be included in the client if templates are all parsed server-side templates.View.prototype._parse = function() { // Wrap parsing in a try / catch to add context to message when throwing - var template; + let template; try { if (this.literal) { - var source = (this.unminified) ? this.source : + const source = (this.unminified) ? this.source : // Remove leading and trailing whitespace only lines by default this.source.replace(/^\s*\n/, '').replace(/\s*$/, ''); template = new templates.Text(source); } else if (this.string) { template = createStringTemplate(this.source, this); } else { - var source = (this.unminified) ? this.source : + const source = (this.unminified) ? this.source : htmlUtil.minify(this.source).replace(/&sp;/g, ' '); template = createTemplate(source, this); } } catch (err) { - var message = '\n\nWithin template "' + this.name + '":\n' + this.source; + const message = '\n\nWithin template "' + this.name + '":\n' + this.source; throw appendErrorMessage(err, message); } this.template = template; @@ -42,9 +53,9 @@ templates.View.prototype._parse = function() { // Modified and shared among the following parse functions. It's OK for this // to be shared at the module level, since it is only used by synchronous code -var parseNode; +let parseNode: ParseNode; -function createTemplate(source, view) { +export function createTemplate(source: string, view: View) { source = escapeBraced(source); parseNode = new ParseNode(view); htmlUtil.parse(source, { @@ -59,7 +70,7 @@ function createTemplate(source, view) { // since Derby sends an additional script tag after the HTML for the page while (parseNode.parent) { parseNode = parseNode.parent; - var last = parseNode.last(); + const last = parseNode.last(); if (last instanceof templates.Element) { if (last.tagName === 'body' || last.tagName === 'html') { last.notClosed = true; @@ -74,48 +85,48 @@ function createTemplate(source, view) { return new templates.Template(parseNode.content); } -function createStringTemplate(source, view) { +export function createStringTemplate(source: string, view: View) { source = escapeBraced(source); parseNode = new ParseNode(view); parseText(source, parseTextLiteral, parseTextExpression, 'string'); return new templates.Template(parseNode.content); } -function parseHtmlStart(tag, tagName, attributes, selfClosing) { - var lowerTagName = tagName.toLowerCase(); - var hooks; +function parseHtmlStart(tag: string, tagName: string, attributes: Record, selfClosing: boolean) { + const lowerTagName = tagName.toLowerCase(); + let hooks; if (lowerTagName !== 'view' && !viewForTagName(lowerTagName)) { hooks = elementHooksFromAttributes(attributes); } - var attributesMap = parseAttributes(attributes); - var namespaceUri = (lowerTagName === 'svg') ? + const attributesMap = parseAttributes(attributes); + const namespaceUri = (lowerTagName === 'svg') ? templates.NAMESPACE_URIS.svg : parseNode.namespaceUri; - var Constructor = templates.Element; + let Constructor: any = templates.Element; if (lowerTagName === 'tag') { Constructor = templates.DynamicElement; tagName = attributesMap.is; delete attributesMap.is; } if (selfClosing || templates.VOID_ELEMENTS[lowerTagName]) { - var element = new Constructor(tagName, attributesMap, null, hooks, selfClosing, null, namespaceUri); + const element = new Constructor(tagName, attributesMap, null, hooks, selfClosing, null, namespaceUri); parseNode.content.push(element); parseElementClose(lowerTagName); } else { parseNode = parseNode.child(); parseNode.namespaceUri = namespaceUri; - var element = new Constructor(tagName, attributesMap, parseNode.content, hooks, selfClosing, null, namespaceUri); + const element = new Constructor(tagName, attributesMap, parseNode.content, hooks, selfClosing, null, namespaceUri); parseNode.parent.content.push(element); } } -function parseAttributes(attributes) { - var attributesMap; - for (var key in attributes) { +function parseAttributes(attributes: Record) { + let attributesMap; + for (const key in attributes) { if (!attributesMap) attributesMap = {}; - var value = attributes[key]; - var match = /([^:]+):[^:]/.exec(key); - var nsUri = match && templates.NAMESPACE_URIS[match[1]]; + const value = attributes[key]; + const match = /([^:]+):[^:]/.exec(key); + const nsUri = match && templates.NAMESPACE_URIS[match[1]]; if (value === '' || typeof value !== 'string') { attributesMap[key] = new templates.Attribute(value, nsUri); continue; @@ -125,7 +136,7 @@ function parseAttributes(attributes) { parseText(value, parseTextLiteral, parseTextExpression, 'attribute'); if (parseNode.content.length === 1) { - var item = parseNode.content[0]; + const item = parseNode.content[0]; attributesMap[key] = (item instanceof templates.Text) ? new templates.Attribute(item.data, nsUri) : @@ -137,7 +148,8 @@ function parseAttributes(attributes) { new templates.DynamicAttribute(item, nsUri); } else if (parseNode.content.length > 1) { - var template = new templates.Template(parseNode.content, value); + const template = new templates.Template(parseNode.content, value); + // @ts-expect-error template can be Expression or Template attributesMap[key] = new templates.DynamicAttribute(template, nsUri); } else { @@ -149,9 +161,9 @@ function parseAttributes(attributes) { return attributesMap; } -function parseHtmlEnd(tag, tagName) { +function parseHtmlEnd(tag: string, tagName: string) { parseNode = parseNode.parent; - var last = parseNode.last(); + const last = parseNode.last(); if (!( (last instanceof templates.DynamicElement && tagName.toLowerCase() === 'tag') || (last instanceof templates.Element && last.tagName === tagName) @@ -161,89 +173,89 @@ function parseHtmlEnd(tag, tagName) { parseElementClose(tagName); } -function parseElementClose(tagName) { +function parseElementClose(tagName: string) { if (tagName === 'view') { - var element = parseNode.content.pop(); + const element = parseNode.content.pop(); parseViewElement(element); return; } - var view = viewForTagName(tagName); + const view = viewForTagName(tagName); if (view) { - var element = parseNode.content.pop(); + const element = parseNode.content.pop(); parseNamedViewElement(element, view, view.name); return; } - var element = parseNode.last(); + const element = parseNode.last(); markup.emit('element', element); markup.emit('element:' + tagName, element); } -function viewForTagName(tagName) { +function viewForTagName(tagName: string) { return parseNode.view && parseNode.view.views.tagMap[tagName]; } -function parseHtmlText(data, isRawText) { - var environment = (isRawText) ? 'string' : 'html'; +function parseHtmlText(data: string, isRawText: boolean) { + const environment = (isRawText) ? 'string' : 'html'; parseText(data, parseTextLiteral, parseTextExpression, environment); } -function parseHtmlComment(tag, data) { +function parseHtmlComment(tag: string, data: string) { // Only output comments that start with `` if (!htmlUtil.isConditionalComment(tag)) return; - var comment = new templates.Comment(data); + const comment = new templates.Comment(data); parseNode.content.push(comment); } -var doctypeRegExp = /^/i; +const doctypeRegExp = /^/i; -function parseHtmlOther(tag) { - var match = doctypeRegExp.exec(tag); +function parseHtmlOther(tag: string) { + const match = doctypeRegExp.exec(tag); if (match) { - var name = match[1]; - var idType = match[2] && match[2].toLowerCase(); - var publicId, systemId; + const name = match[1]; + const idType = match[2] && match[2].toLowerCase(); + let publicId, systemId; if (idType === 'public') { publicId = match[3]; systemId = match[4]; } else if (idType === 'system') { systemId = match[3]; } - var doctype = new templates.Doctype(name, publicId, systemId); + const doctype = new templates.Doctype(name, publicId, systemId); parseNode.content.push(doctype); } else { unexpected(tag); } } -function parseTextLiteral(data) { - var text = new templates.Text(data); +function parseTextLiteral(data: string) { + const text = new templates.Text(data); parseNode.content.push(text); } -function parseTextExpression(source, environment) { - var expression = createExpression(source); +function parseTextExpression(source: string, environment: string) { + const expression = createExpression(source); if (expression.meta.blockType) { parseBlockExpression(expression); } else if (expression.meta.valueType === 'view') { parseViewExpression(expression); } else if (expression.meta.unescaped && environment === 'html') { - var html = new templates.DynamicHtml(expression); + const html = new templates.DynamicHtml(expression); parseNode.content.push(html); } else { - var text = new templates.DynamicText(expression); + const text = new templates.DynamicText(expression); parseNode.content.push(text); } } -function parseBlockExpression(expression) { - var blockType = expression.meta.blockType; +function parseBlockExpression(expression: Expression) { + const blockType = expression.meta.blockType; // Block ending if (expression.meta.isEnd) { parseNode = parseNode.parent; // Validate that the block ending matches an appropriate block start - var last = parseNode.last(); - var lastExpression = last && (last.expression || (last.expressions && last.expressions[0])); + const last = parseNode.last(); + const lastExpression = last && (last.expression || (last.expressions && last.expressions[0])); if (!( lastExpression && (blockType === 'end' && lastExpression.meta.blockType) || @@ -252,10 +264,10 @@ function parseBlockExpression(expression) { throw new Error('Mismatched closing template tag: ' + expression.meta.source); } - // Continuing block + // Continuing block } else if (blockType === 'else' || blockType === 'else if') { parseNode = parseNode.parent; - var last = parseNode.last(); + const last = parseNode.last(); parseNode = parseNode.child(); if (last instanceof templates.ConditionalBlock) { @@ -268,10 +280,10 @@ function parseBlockExpression(expression) { unexpected(expression.meta.source); } - // Block start + // Block start } else { - var nextNode = parseNode.child(); - var block; + const nextNode = parseNode.child(); + let block; if (blockType === 'if' || blockType === 'unless') { block = new templates.ConditionalBlock([expression], [nextNode.content]); } else if (blockType === 'each') { @@ -284,9 +296,9 @@ function parseBlockExpression(expression) { } } -function parseViewElement(element) { +function parseViewElement(element: any) { // TODO: "name" is deprecated in lieu of "is". Remove "name" in Derby 0.6.0 - var nameAttribute = element.attributes.is || element.attributes.name; + const nameAttribute = element.attributes.is || element.attributes.name; if (!nameAttribute) { throw new Error('The element requires an "is" attribute'); } @@ -294,37 +306,37 @@ function parseViewElement(element) { delete element.attributes.name; if (nameAttribute.expression) { - var viewAttributes = viewAttributesFromElement(element); - var componentHooks = componentHooksFromAttributes(viewAttributes); - var remaining = element.content || []; - var viewInstance = createDynamicViewInstance(nameAttribute.expression, viewAttributes, componentHooks.hooks, componentHooks.initHooks); + const viewAttributes = viewAttributesFromElement(element); + const componentHooks = componentHooksFromAttributes(viewAttributes); + const remaining = element.content || []; + const viewInstance = createDynamicViewInstance(nameAttribute.expression, viewAttributes, componentHooks.hooks, componentHooks.initHooks); finishParseViewElement(viewAttributes, remaining, viewInstance); } else { - var name = nameAttribute.data; - var view = findView(name); + const name = nameAttribute.data; + const view = findView(name); parseNamedViewElement(element, view, name); } } -function findView(name) { - var view = parseNode.view.views.find(name, parseNode.view.namespace); +function findView(name: string) { + const view = parseNode.view.views.find(name, parseNode.view.namespace); if (!view) { - var message = parseNode.view.views.findErrorMessage(name); + const message = parseNode.view.views.findErrorMessage(name); throw new Error(message); } return view; } -function parseNamedViewElement(element, view, name) { - var viewAttributes = viewAttributesFromElement(element); - var componentHooks = componentHooksFromAttributes(viewAttributes); - var remaining = parseContentAttributes(element.content, view, viewAttributes); - var viewInstance = new templates.ViewInstance(view.registeredName, viewAttributes, componentHooks.hooks, componentHooks.initHooks); +function parseNamedViewElement(element, view: View, _name: string) { + const viewAttributes = viewAttributesFromElement(element); + const componentHooks = componentHooksFromAttributes(viewAttributes); + const remaining = parseContentAttributes(element.content, view, viewAttributes); + const viewInstance = new templates.ViewInstance(view.registeredName, viewAttributes, componentHooks.hooks, componentHooks.initHooks); finishParseViewElement(viewAttributes, remaining, viewInstance); } -function createDynamicViewInstance(expression, attributes, hooks, initHooks) { - var viewInstance = new templates.DynamicViewInstance(expression, attributes, hooks, initHooks); +function createDynamicViewInstance(expression: Expression, attributes: Record, hooks: MarkupHook[], initHooks: MarkupHook[]) { + const viewInstance = new templates.DynamicViewInstance(expression, attributes, hooks, initHooks); // Wrap the viewInstance in a block with the same expression, so that it is // re-rendered when any of its dependencies change return new templates.Block(expression, [viewInstance]); @@ -337,7 +349,7 @@ function finishParseViewElement(viewAttributes, remaining, viewInstance) { } function setContentAttribute(attributes, content) { - if (attributes.hasOwnProperty('content')) return; + if (Object.prototype.hasOwnProperty.call(attributes, ['content'])) return; if (!content.length) return; attributes.content = attributeValueFromContent(content, attributes.within); } @@ -345,12 +357,12 @@ function setContentAttribute(attributes, content) { function attributeValueFromContent(content, isWithin) { // Optimize common cases where content can be a literal or a single expression if (content.length === 1) { - var item = content[0]; + const item = content[0]; if (item instanceof templates.Text) { return item.data; } if (item instanceof templates.DynamicText) { - var expression = item.expression; + const expression = item.expression; if (expression instanceof expressions.LiteralExpression) { return expression.value; } @@ -365,15 +377,15 @@ function attributeValueFromContent(content, isWithin) { } } // Otherwise, wrap a template as needed for the context - var template = new templates.Template(content); + const template = new templates.Template(content); return (isWithin) ? template : new templates.ViewParent(template); } function viewAttributesFromElement(element) { - var viewAttributes = {}; - for (var key in element.attributes) { - var attribute = element.attributes[key]; - var camelCased = dashToCamelCase(key); + const viewAttributes = {}; + for (const key in element.attributes) { + const attribute = element.attributes[key]; + const camelCased = dashToCamelCase(key); viewAttributes[camelCased] = (attribute.expression instanceof templates.Template) ? new templates.ViewParent(attribute.expression) : @@ -385,7 +397,7 @@ function viewAttributesFromElement(element) { } function parseAsAttribute(key, value) { - var expression = createPathExpression(value); + const expression = createPathExpression(value); if (!(expression instanceof expressions.PathExpression)) { throw new Error(key + ' attribute must be a path: ' + key + '="' + value + '"'); } @@ -393,7 +405,7 @@ function parseAsAttribute(key, value) { } function parseAsObjectAttribute(key, value) { - var expression = createPathExpression(value); + let expression = createPathExpression(value); if (!( expression instanceof expressions.SequenceExpression && expression.args.length === 2 && @@ -401,9 +413,9 @@ function parseAsObjectAttribute(key, value) { )) { throw new Error(key + ' attribute requires a path and a key argument: ' + key + '="' + value + '"'); } - var segments = expression.args[0].segments; - var expression = expression.args[1]; - return {segments: segments, expression: expression}; + const segments = expression.args[0].segments; + expression = expression.args[1]; + return { segments: segments, expression: expression }; } function parseOnAttribute(key, value) { @@ -411,38 +423,38 @@ function parseOnAttribute(key, value) { return createPathExpression(value); } -function elementHooksFromAttributes(attributes, type) { +function elementHooksFromAttributes(attributes, _type?) { if (!attributes) return; - var hooks = []; + const hooks = []; - for (var key in attributes) { - var value = attributes[key]; + for (const key in attributes) { + const value = attributes[key]; // Parse `as` assignments if (key === 'as') { - var segments = parseAsAttribute(key, value); + const segments = parseAsAttribute(key, value); hooks.push(new templates.AsProperty(segments)); delete attributes[key]; continue; } if (key === 'as-array') { - var segments = parseAsAttribute(key, value); + const segments = parseAsAttribute(key, value); hooks.push(new templates.AsArray(segments)); delete attributes[key]; continue; } if (key === 'as-object') { - var parsed = parseAsObjectAttribute(key, value); + const parsed = parseAsObjectAttribute(key, value); hooks.push(new templates.AsObject(parsed.segments, parsed.expression)); delete attributes[key]; continue; } // Parse event listeners - var match = /^on-(.+)/.exec(key); - var eventName = match && match[1]; + const match = /^on-(.+)/.exec(key); + const eventName = match && match[1]; if (eventName) { - var expression = parseOnAttribute(key, value); + const expression = parseOnAttribute(key, value); hooks.push(new templates.ElementOn(eventName, expression)); delete attributes[key]; } @@ -453,37 +465,37 @@ function elementHooksFromAttributes(attributes, type) { function componentHooksFromAttributes(attributes) { if (!attributes) return {}; - var hooks = []; - var initHooks = []; + const hooks = []; + const initHooks = []; - for (var key in attributes) { - var value = attributes[key]; + for (const key in attributes) { + const value = attributes[key]; // Parse `as` assignments if (key === 'as') { - var segments = parseAsAttribute(key, value); + const segments = parseAsAttribute(key, value); hooks.push(new templates.AsPropertyComponent(segments)); delete attributes[key]; continue; } if (key === 'asArray') { - var segments = parseAsAttribute('as-array', value); + const segments = parseAsAttribute('as-array', value); hooks.push(new templates.AsArrayComponent(segments)); delete attributes[key]; continue; } if (key === 'asObject') { - var parsed = parseAsObjectAttribute('as-object', value); + const parsed = parseAsObjectAttribute('as-object', value); hooks.push(new templates.AsObjectComponent(parsed.segments, parsed.expression)); delete attributes[key]; continue; } // Parse event listeners - var match = /^on([A-Z_].*)/.exec(key); - var eventName = match && match[1].charAt(0).toLowerCase() + match[1].slice(1); + const match = /^on([A-Z_].*)/.exec(key); + const eventName = match && match[1].charAt(0).toLowerCase() + match[1].slice(1); if (eventName) { - var expression = parseOnAttribute(key, value); + const expression = parseOnAttribute(key, value); initHooks.push(new templates.ComponentOn(eventName, expression)); delete attributes[key]; } @@ -502,21 +514,21 @@ function dashToCamelCase(string) { } function parseContentAttributes(content, view, viewAttributes) { - var remaining = []; + const remaining = []; if (!content) return remaining; - for (var i = 0, len = content.length; i < len; i++) { - var item = content[i]; - var name = (item instanceof templates.Element) && item.tagName; + for (let i = 0, len = content.length; i < len; i++) { + const item = content[i]; + let name = (item instanceof templates.Element) && item.tagName; if (name === 'attribute') { - var name = parseNameAttribute(item); + name = parseNameAttribute(item); parseAttributeElement(item, name, viewAttributes); } else if (view.attributesMap && view.attributesMap[name]) { parseAttributeElement(item, name, viewAttributes); } else if (name === 'array') { - var name = parseNameAttribute(item); + name = parseNameAttribute(item); parseArrayElement(item, name, viewAttributes); } else if (view.arraysMap && view.arraysMap[name]) { @@ -531,8 +543,8 @@ function parseContentAttributes(content, view, viewAttributes) { function parseNameAttribute(element) { // TODO: "name" is deprecated in lieu of "is". Remove "name" in Derby 0.6.0 - var nameAttribute = element.attributes.is || element.attributes.name; - var name = nameAttribute.data; + const nameAttribute = element.attributes.is || element.attributes.name; + const name = nameAttribute.data; if (!name) { throw new Error('The <' + element.tagName + '> element requires a literal "is" attribute'); } @@ -542,17 +554,17 @@ function parseNameAttribute(element) { } function parseAttributeElement(element, name, viewAttributes) { - var camelName = dashToCamelCase(name); - var isWithin = element.attributes && element.attributes.within; + const camelName = dashToCamelCase(name); + const isWithin = element.attributes && element.attributes.within; viewAttributes[camelName] = attributeValueFromContent(element.content, isWithin); } function createAttributesExpression(attributes) { - var dynamicAttributes = {}; - var literalAttributes = {}; - var isLiteral = true; - for (var key in attributes) { - var attribute = attributes[key]; + const dynamicAttributes = {}; + const literalAttributes = {}; + let isLiteral = true; + for (const key in attributes) { + const attribute = attributes[key]; if (attribute instanceof expressions.Expression) { dynamicAttributes[key] = attribute; isLiteral = false; @@ -570,20 +582,21 @@ function createAttributesExpression(attributes) { } function parseArrayElement(element, name, viewAttributes) { - var attributes = viewAttributesFromElement(element); + const attributes = viewAttributesFromElement(element); setContentAttribute(attributes, element.content); + // @ts-expect-error Attribute `within` does not exist on {} delete attributes.within; - var expression = createAttributesExpression(attributes); - var camelName = dashToCamelCase(name); - var viewAttribute = viewAttributes[camelName]; + const expression = createAttributesExpression(attributes); + const camelName = dashToCamelCase(name); + const viewAttribute = viewAttributes[camelName]; // If viewAttribute is already an ArrayExpression, push the expression for // the current array element if (viewAttribute instanceof expressions.ArrayExpression) { viewAttribute.items.push(expression); - // Alternatively, viewAttribute will be an array if its items have all been - // literal values thus far + // Alternatively, viewAttribute will be an array if its items have all been + // literal values thus far } else if (Array.isArray(viewAttribute)) { if (expression instanceof expressions.LiteralExpression) { // If the current array element continues to be a literal value, push it @@ -593,17 +606,17 @@ function parseArrayElement(element, name, viewAttributes) { // However, if the array element produces a non-literal expression, // convert the values in the array into an equivalent ArrayExpression of // LiteralExpressions, then push on this expression as well - var items = []; - for (var i = 0; i < viewAttribute.length; i++) { + const items = []; + for (let i = 0; i < viewAttribute.length; i++) { items[i] = new expressions.LiteralExpression(viewAttribute[i]); } items.push(expression); viewAttributes[camelName] = new expressions.ArrayExpression(items); } - // For the first array element encountered, create a containing array or - // ArrayExpression. Create an array of raw values in the literal case and an - // ArrayExpression of expressions in the non-literal case + // For the first array element encountered, create a containing array or + // ArrayExpression. Create an array of raw values in the literal case and an + // ArrayExpression of expressions in the non-literal case } else if (viewAttribute == null) { viewAttributes[camelName] = (expression instanceof expressions.LiteralExpression) ? [expression.value] : new expressions.ArrayExpression([expression]); @@ -616,7 +629,7 @@ function parseArrayElement(element, name, viewAttributes) { function parseViewExpression(expression) { // If there are multiple arguments separated by commas, they will get parsed // as a SequenceExpression - var nameExpression, attributesExpression; + let nameExpression, attributesExpression; if (expression instanceof expressions.SequenceExpression) { nameExpression = expression.args[0]; attributesExpression = expression.args[1]; @@ -624,14 +637,14 @@ function parseViewExpression(expression) { nameExpression = expression; } - var viewAttributes = viewAttributesFromExpression(attributesExpression); - var componentHooks = componentHooksFromAttributes(viewAttributes); + const viewAttributes = viewAttributesFromExpression(attributesExpression); + const componentHooks = componentHooksFromAttributes(viewAttributes); // A ViewInstance has a static name, and a DynamicViewInstance gets its name // at render time - var viewInstance; + let viewInstance; if (nameExpression instanceof expressions.LiteralExpression) { - var name = nameExpression.get(); + const name = nameExpression.get(); // Will throw if the view can't be found immediately findView(name); viewInstance = new templates.ViewInstance(name, viewAttributes, componentHooks.hooks, componentHooks.initHooks); @@ -643,13 +656,13 @@ function parseViewExpression(expression) { function viewAttributesFromExpression(expression) { if (!expression) return; - var object = (expression instanceof expressions.ObjectExpression) ? expression.properties : + const object = (expression instanceof expressions.ObjectExpression) ? expression.properties : (expression instanceof expressions.LiteralExpression) ? expression.value : null; if (typeof object !== 'object') unexpected(); - var viewAttributes = {}; - for (var key in object) { - var value = object[key]; + const viewAttributes = {}; + for (const key in object) { + const value = object[key]; viewAttributes[key] = (value instanceof expressions.LiteralExpression) ? value.value : (value instanceof expressions.Expression) ? new expressions.ViewParentExpression(value) : @@ -658,27 +671,36 @@ function viewAttributesFromExpression(expression) { return viewAttributes; } -function ParseNode(view, parent) { - this.view = view; - this.parent = parent; - this.content = []; - this.namespaceUri = parent && parent.namespaceUri; +class ParseNode { + view: View; + parent?: ParseNode; + content: any[]; + namespaceUri?: string; + + constructor(view: View, parent?: ParseNode) { + this.view = view; + this.parent = parent; + this.content = []; + this.namespaceUri = parent && parent.namespaceUri; + } + + child() { + return new ParseNode(this.view, this); + } + + last() { + return this.content[this.content.length - 1]; + } } -ParseNode.prototype.child = function() { - return new ParseNode(this.view, this); -}; -ParseNode.prototype.last = function() { - return this.content[this.content.length - 1]; -}; -function escapeBraced(source) { - var out = ''; +function escapeBraced(source: string) { + let out = ''; parseText(source, onLiteral, onExpression, 'string'); function onLiteral(text) { out += text; } function onExpression(text) { - var escaped = text.replace(/[&<]/g, function(match) { + const escaped = text.replace(/[&<]/g, function(match) { return (match === '&') ? '&' : '<'; }); out += '{{' + escaped + '}}'; @@ -686,44 +708,44 @@ function escapeBraced(source) { return out; } -function unescapeBraced(source) { +function unescapeBraced(source: string) { return source.replace(/(?:&|<)/g, function(match) { return (match === '&') ? '&' : '<'; }); } -function unescapeTextLiteral(text, environment) { +function unescapeTextLiteral(text: string, environment: string) { return (environment === 'html' || environment === 'attribute') ? htmlUtil.unescapeEntities(text) : text; } -function parseText(data, onLiteral, onExpression, environment) { - var current = data; - var last; +function parseText(data: string, onLiteral, onExpression, environment: string) { + let current = data; + let last; while (current) { if (current === last) throw new Error('Error parsing template text: ' + data); last = current; - var start = current.indexOf('{{'); + const start = current.indexOf('{{'); if (start === -1) { - var unescapedCurrent = unescapeTextLiteral(current, environment); + const unescapedCurrent = unescapeTextLiteral(current, environment); onLiteral(unescapedCurrent); return; } - var end = matchBraces(current, 2, start, '{', '}'); + const end = matchBraces(current, 2, start, '{', '}'); if (end === -1) throw new Error('Mismatched braces in: ' + data); if (start > 0) { - var before = current.slice(0, start); - var unescapedBefore = unescapeTextLiteral(before, environment); + const before = current.slice(0, start); + const unescapedBefore = unescapeTextLiteral(before, environment); onLiteral(unescapedBefore); } - var inside = current.slice(start + 2, end - 2); + const inside = current.slice(start + 2, end - 2); if (inside) { - var unescapedInside = unescapeBraced(inside); + let unescapedInside = unescapeBraced(inside); unescapedInside = unescapeTextLiteral(unescapedInside, environment); onExpression(unescapedInside, environment); } @@ -735,10 +757,10 @@ function parseText(data, onLiteral, onExpression, environment) { function matchBraces(text, num, i, openChar, closeChar) { i += num; while (num) { - var close = text.indexOf(closeChar, i); - var open = text.indexOf(openChar, i); - var hasClose = close !== -1; - var hasOpen = open !== -1; + const close = text.indexOf(closeChar, i); + const open = text.indexOf(openChar, i); + const hasClose = close !== -1; + const hasOpen = open !== -1; if (hasClose && (!hasOpen || (close < open))) { i = close + 1; num--; @@ -754,42 +776,42 @@ function matchBraces(text, num, i, openChar, closeChar) { return i; } -var blockRegExp = /^(if|unless|else if|each|with|on)\s+([\s\S]+?)(?:\s+as\s+([^,\s]+)\s*(?:,\s*(\S+))?)?$/; -var valueRegExp = /^(?:(view|unbound|bound|unescaped)\s+)?([\s\S]*)/; +const blockRegExp = /^(if|unless|else if|each|with|on)\s+([\s\S]+?)(?:\s+as\s+([^,\s]+)\s*(?:,\s*(\S+))?)?$/; +const valueRegExp = /^(?:(view|unbound|bound|unescaped)\s+)?([\s\S]*)/; -function createExpression(source) { +export function createExpression(source: string) { source = source.trim(); - var meta = new expressions.ExpressionMeta(source); + const meta = new expressions.ExpressionMeta(source); // Parse block expression // // The block expressions `if`, `unless`, `else if`, `each`, `with`, and `on` // must have a single blockType keyword and a path. They may have an optional // alias assignment - var match = blockRegExp.exec(source); - var path, as, keyAs; + let match = blockRegExp.exec(source); + let path, as, keyAs; if (match) { meta.blockType = match[1]; path = match[2]; as = match[3]; keyAs = match[4]; - // The blocks `else`, `unbound`, and `bound` may not have a path or alias + // The blocks `else`, `unbound`, and `bound` may not have a path or alias } else if (source === 'else' || source === 'unbound' || source === 'bound') { meta.blockType = source; - // Any source that starts with a `/` is treated as an end block. Either a - // `{{/}}` with no following characters or a `{{/if}}` style ending is valid + // Any source that starts with a `/` is treated as an end block. Either a + // `{{/}}` with no following characters or a `{{/if}}` style ending is valid } else if (source.charAt(0) === '/') { meta.isEnd = true; meta.blockType = source.slice(1).trim() || 'end'; - // Parse value expression // - // A value expression has zero or many keywords and an expression + // Parse value expression // + // A value expression has zero or many keywords and an expression } else { path = source; - var keyword; + let keyword; do { match = valueRegExp.exec(path); keyword = match[1]; @@ -805,7 +827,7 @@ function createExpression(source) { } // Wrap parsing in a try / catch to add context to message when throwing - var expression; + let expression; try { expression = (path) ? createPathExpression(path) : @@ -817,18 +839,18 @@ function createExpression(source) { meta.keyAs = parseAlias(keyAs); } } catch (err) { - var message = '\n\nWithin expression: ' + source; + const message = '\n\nWithin expression: ' + source; throw appendErrorMessage(err, message); } expression.meta = meta; return expression; } -function unexpected(source) { - throw new Error('Error parsing template: ' + source); +function unexpected(source?: unknown) { + throw new Error('Error parsing template: ' + JSON.stringify(source)); } -function appendErrorMessage(err, message) { +function appendErrorMessage(err: unknown, message: string) { if (err instanceof Error) { err.message += message; return err; @@ -836,9 +858,9 @@ function appendErrorMessage(err, message) { return new Error(err + message); } -function parseAlias(source) { +function parseAlias(source: string) { // Try parsing into a path expression. This throws on invalid expressions. - var expression = createPathExpression(source); + const expression = createPathExpression(source); // Verify that it's an AliasPathExpression with no segments, i.e. that // it has the format "#IDENTIFIER". if (expression instanceof expressions.AliasPathExpression) { @@ -850,47 +872,47 @@ function parseAlias(source) { throw new Error('Alias must be an identifier starting with "#": ' + source); } -App.prototype.addViews = function(file, namespace) { - var views = exports.parseViews(file, namespace); - exports.registerParsedViews(this, views); +App.prototype.addViews = function(file: string, namespace: string) { + const views = parseViews(file, namespace); + registerParsedViews(this, views); }; -exports.getImportNamespace = function(namespace, attrs, importFilename) { - var extension = path.extname(importFilename); - var relativeNamespace = (attrs.ns == null) ? +export function getImportNamespace(namespace: string, attrs: Record, importFilename: string) { + const extension = path.extname(importFilename); + const relativeNamespace = (attrs.ns == null) ? path.basename(attrs.src, extension) : attrs.ns; return (namespace && relativeNamespace) ? namespace + ':' + relativeNamespace : namespace || relativeNamespace || ''; -}; +} -exports.parseViews = function(file, namespace, filename, onImport) { - var views = []; - var prefix = (namespace) ? namespace + ':' : ''; +export function parseViews(file: string, namespace: string, filename?: string, onImport?: (attrs) => void) { + const views: ParsedView[] = []; + const prefix = (namespace) ? namespace + ':' : ''; htmlUtil.parse(file + '\n', { // Force view tags to be treated as raw tags, // meaning their contents are not parsed as HTML - rawTags: /^(?:[^\s=\/!>]+:|style|script)$/i, + rawTags: /^(?:[^\s=/!>]+:|style|script)$/i, matchEnd: matchEnd, start: onStart, text: onText }); - function matchEnd(tagName) { + function matchEnd(tagName: string) { if (tagName.slice(-1) === ':') { - return /<\/?[^\s=\/!>]+:[\s>]/i; + return /<\/?[^\s=/!>]+:[\s>]/i; } return new RegExp(' { + /** Previous URL path + querystring */ + previous?: string; + /** Current URL path + querystring */ + url: string; + /** + * Parsed query parameters + * @see https://www.npmjs.com/package/qs + */ + query: Readonly; + /** HTTP method for the currently rendered page */ + method: string; + routes: unknown; +} + +export interface QueryParams { + [param: string]: unknown; +} + +export interface TransitionalRoute { + from: string; + to: string; +} + +export interface RouteMethod { + (routePattern: string, routeHandler: RouteHandler): void; + (routePattern: TransitionalRoute, routeHandler: TransitionalRouteHandler): void; +} + +export interface RouteHandler { + (page: PageBase, model: Model, params: PageParams, next: (err?: Error) => void): void; +} + +export interface TransitionalRouteHandler { + ( + page: PageBase, + model: Model, + params: PageParams, + next: (err?: Error) => void, + done: () => void + ): void; +} + +declare module './App' { + interface AppBase { + del: RouteMethod; + get: RouteMethod; + history: { + push: (url: string, render?: boolean, state?: object, e?: any) => void, + replace: (url: string, render?: boolean, state?: object, e?: any) => void, + refresh: () => void, + }; + post: RouteMethod; + put: RouteMethod; + } +} + +declare module './Page' { + interface PageBase { + redirect(url: string, status?: number): void; + } +} diff --git a/src/server.ts b/src/server.ts new file mode 100644 index 000000000..d5761d0f4 --- /dev/null +++ b/src/server.ts @@ -0,0 +1,31 @@ +import cluster from 'cluster'; + +const isProduction = process.env.NODE_ENV === 'production'; + +export function run(createServer: () => void) { + // In production + if (isProduction) return createServer(); + if (cluster.isPrimary) { + console.log('Primary PID ', process.pid); + startWorker(); + } else { + createServer(); + } +} + +function startWorker() { + const worker = cluster.fork(); + + worker.once('disconnect', function () { + worker.process.kill(); + }); + + worker.on('message', function(message) { + if (message.type === 'reload') { + if (worker.isDead()) return; + console.log('Killing %d', worker.process.pid); + worker.process.kill(); + startWorker(); + } + }); +} diff --git a/src/templates/contexts.ts b/src/templates/contexts.ts new file mode 100644 index 000000000..0737f10f6 --- /dev/null +++ b/src/templates/contexts.ts @@ -0,0 +1,256 @@ +import { type Expression } from './expressions'; +import { + type Attributes, + type MarkupHook, + type View, +} from './templates'; +import { Controller } from '../Controller'; + +function noop() { } + +export class ContextMeta { + addBinding: (binding: any) => void = noop; + removeBinding: (binding: any) => void = noop; + removeNode: (node: Node) => void = noop; + addItemContext: (context: Context) => void = noop; + removeItemContext: (context: Context) => void = noop; + views = null; + idNamespace = ''; + idCount = 0; + pending = []; + pauseCount = 0; +} + +export class Context { + meta: ContextMeta; + controller: Controller; + parent?: Context; + unbound?: boolean; + expression?: Expression; + alias?: string; + keyAlias?: string; + item?: number; + view?: View; + attributes?: Attributes; + hooks?: MarkupHook[]; + initHooks?: MarkupHook[]; + closure?: Context; + _id?: number; + _eventModels?: any; + + constructor(meta: ContextMeta, controller: Controller, parent?: Context, unbound?: boolean, expression?: Expression) { + // Required properties // + + // Properties which are globally inherited for the entire page + this.meta = meta; + // The page or component. Must have a `model` property with a `data` property + this.controller = controller; + + // Optional properties // + + // Containing context + this.parent = parent; + // Boolean set to true when bindings should be ignored + this.unbound = unbound; + // The expression for a block + this.expression = expression; + // Alias name for the given expression + this.alias = expression && expression.meta && expression.meta.as; + // Alias name for the index or iterated key + this.keyAlias = expression && expression.meta && expression.meta.keyAs; + + // For Context::eachChild + // The context of the each at render time + this.item = null; + + // For Context::viewChild + // Reference to the current view + this.view = null; + // Attribute values passed to the view instance + this.attributes = null; + // MarkupHooks to be called after insert into DOM of component + this.hooks = null; + // MarkupHooks to be called immediately before init of component + this.initHooks = null; + + // For Context::closureChild + // Reference to another context established at render time by ContextClosure + this.closure = null; + + // Used in EventModel + this._id = null; + this._eventModels = null; + } + + id() { + const count = ++this.meta.idCount; + return this.meta.idNamespace + '_' + count.toString(36); + } + + addBinding(binding) { + // Don't add bindings that wrap list items. Only their outer range is needed + if (binding.itemFor) return; + const expression = binding.template.expression; + // Don't rerender in unbound sections + if (expression ? expression.isUnbound(this) : this.unbound) return; + // Don't rerender to changes in a with expression + if (expression && expression.meta && expression.meta.blockType === 'with') return; + this.meta.addBinding(binding); + } + + removeBinding(binding) { + this.meta.removeBinding(binding); + } + + removeNode(node) { + const bindItemStart = node.$bindItemStart; + if (bindItemStart) { + this.meta.removeItemContext(bindItemStart.context); + } + const component = node.$component; + if (component) { + node.$component = null; + if (!component.singleton) { + component.destroy(); + } + } + const destroyListeners = node.$destroyListeners; + if (destroyListeners) { + node.$destroyListeners = null; + for (let i = 0, len = destroyListeners.length; i < len; i++) { + destroyListeners[i](); + } + } + } + + child(expression) { + // Set or inherit the binding mode + const blockType = expression.meta && expression.meta.blockType; + const unbound = (blockType === 'unbound') ? true : + (blockType === 'bound') ? false : + this.unbound; + return new Context(this.meta, this.controller, this, unbound, expression); + } + + componentChild(component) { + return new Context(this.meta, component, this, this.unbound); + } + + // Make a context for an item in an each block + eachChild(expression, item) { + const context = new Context(this.meta, this.controller, this, this.unbound, expression); + context.item = item; + this.meta.addItemContext(context); + return context; + } + + viewChild(view, attributes, hooks, initHooks) { + const context = new Context(this.meta, this.controller, this, this.unbound); + context.view = view; + context.attributes = attributes; + context.hooks = hooks; + context.initHooks = initHooks; + return context; + } + + closureChild(closure) { + const context = new Context(this.meta, this.controller, this, this.unbound); + context.closure = closure; + return context; + } + + forRelative(expression: Expression) { + // eslint-disable-next-line @typescript-eslint/no-this-alias + let context: Context = this; + while (context && context.expression === expression || context.view) { + context = context.parent; + } + return context; + } + + // Returns the closest context which defined the named alias + forAlias(alias: string) { + // eslint-disable-next-line @typescript-eslint/no-this-alias + let context: Context = this; + while (context) { + if (context.alias === alias || context.keyAlias === alias) return context; + context = context.parent; + } + } + + // Returns the closest containing context for a view attribute name or nothing + forAttribute(attribute: string) { + // eslint-disable-next-line @typescript-eslint/no-this-alias + let context: Context = this; + while (context) { + // Find the closest context associated with a view + if (context.view) { + const attributes = context.attributes; + if (!attributes) return; + if (Object.prototype.hasOwnProperty.call(attributes, attribute)) return context; + // If the attribute isn't found, but the attributes inherit, continue + // looking in the next closest view context + if (!attributes.inherit && !attributes.extend) return; + } + context = context.parent; + } + } + + forViewParent() { + // eslint-disable-next-line @typescript-eslint/no-this-alias + let context: Context = this; + while (context) { + // When a context with a `closure` property is encountered, skip to its + // parent context rather than returning the nearest view's. This reference + // is created by wrapping a template in a ContextClosure template + if (context.closure) return context.closure.parent; + // Find the closest view and return the containing context + if (context.view) return context.parent; + context = context.parent; + } + } + + getView() { + // eslint-disable-next-line @typescript-eslint/no-this-alias + let context: Context = this; + while (context) { + // Find the closest view + if (context.view) return context.view; + context = context.parent; + } + } + + // Returns the `this` value for a context + get() { + const value = (this.expression) ? + this.expression.get(this) : + this.controller.model.data; + if (this.item != null) { + return value && value[this.item]; + } + return value; + } + + pause() { + this.meta.pauseCount++; + } + + unpause() { + if (--this.meta.pauseCount) return; + this.flush(); + } + + flush() { + const pending = this.meta.pending; + const len = pending.length; + if (!len) return; + this.meta.pending = []; + for (let i = 0; i < len; i++) { + pending[i](); + } + } + + queue(cb) { + this.meta.pending.push(cb); + } +} diff --git a/src/templates/dependencyOptions.ts b/src/templates/dependencyOptions.ts new file mode 100644 index 000000000..3f3546ca9 --- /dev/null +++ b/src/templates/dependencyOptions.ts @@ -0,0 +1,20 @@ +import { ContextClosure, type Template } from './templates'; + +export class DependencyOptions { + ignoreTemplate?: Template; + + constructor(options?: { ignoreTemplate: Template }) { + this.setIgnoreTemplate(options && options.ignoreTemplate); + } + + static shouldIgnoreTemplate(template, options?: { ignoreTemplate?: Template }) { + return (options) ? options.ignoreTemplate === template : false; + } + + setIgnoreTemplate(template) { + while (template instanceof ContextClosure) { + template = template.template; + } + this.ignoreTemplate = template; + } +} diff --git a/src/templates/expressions.ts b/src/templates/expressions.ts new file mode 100644 index 000000000..e0ca9ae5d --- /dev/null +++ b/src/templates/expressions.ts @@ -0,0 +1,886 @@ +import * as serializeObject from 'serialize-object'; + +import { type Context } from './contexts'; +import { DependencyOptions } from './dependencyOptions'; +import * as operatorFns from './operatorFns'; +import { ContextClosure, Dependency, Template } from './templates'; +import { concat } from './util'; +import { Component } from '../components'; + +type SegmentOrContext = string | number | { item: number } | Context; +type Segment = string | number; +type Value = any; // global | Page | ModelData + +export function lookup(segments: Segment[] | undefined, value: Value) { + if (!segments) return value; + + for (let i = 0, len = segments.length; i < len; i++) { + if (value == null) return value; + value = value[segments[i]]; + } + return value; +} + +// Unlike JS, `[]` is falsey. Otherwise, truthiness is the same as JS +export function templateTruthy(value: Value[] | PrimitiveValue): boolean { + return (Array.isArray(value)) ? value.length > 0 : !!value; +} + +export function pathSegments(segments: SegmentOrContext[]): Segment[] { + const result = []; + for (let i = 0; i < segments.length; i++) { + const segment = segments[i]; + result[i] = (typeof segment === 'object') ? segment.item : segment; + } + return result; +} + +//#region Render functions + +type PrimitiveValue = string | number | boolean; +type Renderable = PrimitiveValue | Template | Record; + +export function renderValue(value: Renderable, context: Context) { + return (typeof value !== 'object') ? value : + (value instanceof Template) ? renderTemplate(value, context) : + (Array.isArray(value)) ? renderArray(value, context) : + renderObject(value, context); +} + +export function renderTemplate(template: Renderable, context: Context): PrimitiveValue | Record { + let i = 1000; + let value: Renderable = template; + while (value instanceof Template) { + if (--i < 0) throw new Error('Maximum template render passes exceeded'); + value = value.get(context, true); + } + return value; +} + +export function renderArray(array: Renderable[], context: Context): Renderable[] { + for (let i = 0; i < array.length; i++) { + if (hasTemplateProperty(array[i])) { + return renderArrayProperties(array, context); + } + } + return array; +} + +export function renderObject(object: Record, context: Context) { + return (hasTemplateProperty(object)) ? + renderObjectProperties(object, context) : object; +} + +function hasTemplateProperty(object: Renderable): boolean { + if (!object) return false; + if (object.constructor !== Object) return false; + return Object.values(object).some((value) => value instanceof Template) +} + +function renderArrayProperties(array: Renderable[], context: Context) { + const out = new Array(array.length); + for (let i = 0; i < array.length; i++) { + out[i] = renderValue(array[i], context); + } + return out; +} + +function renderObjectProperties(object: Record, context: Context): Record { + const out = {}; + for (const key in object) { + out[key] = renderValue(object[key], context); + } + return out; +} + +//#endregion + +type BindType = 'bound' | 'unbound'; // 'unbound' | 'bound' // parsing/index.js#799 +type ValueType = 'view' | undefined; +type BlockType = string; // 'if' | 'else if' | 'else' | 'unless' | 'on' | 'end' | 'each' + +export class ExpressionMeta { + as: string; + bindType: BindType; + blockType: BlockType; + isEnd: boolean; + keyAs: string; + module = 'expressions'; + source: string; + type = 'ExpressionMeta'; + unescaped: boolean; + valueType: ValueType; + + constructor(source: string, blockType?: string, isEnd?: boolean, as?: string, keyAs?: string, unescaped?: boolean, bindType?: BindType, valueType?: ValueType) { + this.source = source; + this.blockType = blockType; + this.isEnd = isEnd; + this.as = as; + this.keyAs = keyAs; + this.unescaped = unescaped; + this.bindType = bindType; + this.valueType = valueType; + } + + serialize(): string { + return serializeObject.instance( + this, + this.source, + this.blockType, + this.isEnd, + this.as, + this.keyAs, + this.unescaped, + this.bindType, + this.valueType + ); + } +} + +export class Expression { + module = 'expressions'; + type = 'Expression'; + meta?: ExpressionMeta; + segments: Array; + + constructor(meta?: ExpressionMeta) { + this.meta = meta; + } + + serialize(): string { + return serializeObject.instance(this, this.meta); + } + + toString(): string { + return this.meta && this.meta.source; + } + + truthy(context: Context): boolean { + const blockType = this.meta.blockType; + if (blockType === 'else') return true; + const value = this.get(context, true); + const truthy = templateTruthy(value); + return (blockType === 'unless') ? !truthy : truthy; + } + + get(_context: Context, _flag?: boolean): any { return undefined; } + + // Return the expression's segment list with context objects + resolve(_context: Context): SegmentOrContext[] | undefined { return undefined; } + + // Return a list of segment lists or null + dependencies(_context: Context, _options: DependencyOptions): Dependency[] | undefined { return undefined; } + + // Return the pathSegments that the expression currently resolves to or null + pathSegments(context: Context): Segment[] | undefined { + const segments = this.resolve(context); + return segments && pathSegments(segments); + } + + set(context: Context, value: Value): void { + const segments = this.pathSegments(context); + if (!segments) throw new Error('Expression does not support setting'); + context.controller.model._set(segments, value); + } + + _resolvePatch(context: Context, segments) { + return (context && context.expression === this && context.item != null) ? + segments.concat(context) : segments; + } + + isUnbound(context: Pick): boolean { + // If the template being rendered has an explicit bindType keyword, such as: + // {{unbound #item.text}} + const bindType = this.meta && this.meta.bindType; + if (bindType === 'unbound') return true; + if (bindType === 'bound') return false; + // Otherwise, inherit from the context + return context.unbound; + } + + _lookupAndContextifyValue(value: Renderable, context: Context): any { + if (this.segments && this.segments.length) { + // If expression has segments, e.g. `bar.baz` in `#foo.bar.baz`, then + // render the base value (e.g. `#foo`) if it's a template and look up the + // value at the indicated path. + value = renderTemplate(value, context); + value = lookup(this.segments, value); + } + if (value instanceof Template && !(value instanceof ContextClosure)) { + // If we're not immediately rendering the template, then create a ContextClosure + // so that the value renders with the correct context later. + value = new ContextClosure(value, context); + } + return value; + } +} + +export class LiteralExpression extends Expression { + type = 'LiteralExpression'; + value: Value; + + constructor(value: Value, meta?: ExpressionMeta) { + super(meta); + this.value = value; + } + + serialize(): string { + return serializeObject.instance(this, this.value, this.meta); + } + + get(): any { + return this.value; + } +} + +export class PathExpression extends Expression { + type = 'PathExpression'; + segments: Segment[]; + + constructor(segments: Segment[], meta?: ExpressionMeta) { + super(meta); + this.segments = segments; + } + + serialize(): string { + return serializeObject.instance(this, this.segments, this.meta); + } + + get(context: Context): Record { + // See View::dependencies. This is needed in order to handle the case of + // getting dependencies within a component template, in which case we cannot + // access model data separate from rendering. + if (!context.controller) return; + return lookup(this.segments, context.controller.model.data); + } + + resolve(context: Context) { + // See View::dependencies. This is needed in order to handle the case of + // getting dependencies within a component template, in which case we cannot + // access model data separate from rendering. + if (!context.controller) return; + const component = context.controller as Component + const segments = concat(component._scope, this.segments); + return this._resolvePatch(context, segments); + } + + dependencies(context: Context, options: DependencyOptions): any { + // See View::dependencies. This is needed in order to handle the case of + // getting dependencies within a component template, in which case we cannot + // access model data separate from rendering. + if (!context.controller) return; + const value = lookup(this.segments, context.controller.model.data); + const dependencies = getDependencies(value, context, options); + return appendDependency(dependencies, this, context); + } +} + +export class RelativePathExpression extends Expression { + type = 'RelativePathExpression'; + + constructor(segments: Segment[], meta?: ExpressionMeta) { + super(meta); + this.segments = segments; + this.meta = meta; + } + + serialize(): string { + return serializeObject.instance(this, this.segments, this.meta); + } + + get(context: Context): any { + const relativeContext = context.forRelative(this); + const value = relativeContext.get(); + return this._lookupAndContextifyValue(value, relativeContext); + } + + resolve(context: Context) { + const relativeContext = context.forRelative(this); + const base = (relativeContext.expression) ? + relativeContext.expression.resolve(relativeContext) : + []; + if (!base) return; + const segments = base.concat(this.segments); + return this._resolvePatch(context, segments); + } + + dependencies(context: Context, options: DependencyOptions): any[] { + // Return inner dependencies from our ancestor + // (e.g., {{ with foo[bar] }} ... {{ this.x }} has 'bar' as a dependency.) + const relativeContext = context.forRelative(this); + const dependencies = relativeContext.expression && + relativeContext.expression.dependencies(relativeContext, options); + return swapLastDependency(dependencies, this, context); + } +} + +export class AliasPathExpression extends Expression { + type = 'AliasPathExpression'; + alias: string; + + constructor(alias: string, segments: Segment[], meta?: ExpressionMeta) { + super(meta); + this.alias = alias; + this.segments = segments; + this.meta = meta; + } + + serialize(): string { + return serializeObject.instance(this, this.alias, this.segments, this.meta); + } + + get(context: Context) { + const aliasContext = context.forAlias(this.alias); + if (!aliasContext) return; + if (aliasContext.keyAlias === this.alias) { + return aliasContext.item; + } + const value = aliasContext.get(); + return this._lookupAndContextifyValue(value, aliasContext); + } + + resolve(context: Context) { + const aliasContext = context.forAlias(this.alias); + if (!aliasContext) return; + if (aliasContext.keyAlias === this.alias) return; + const base = aliasContext.expression.resolve(aliasContext); + if (!base) return; + const segments = base.concat(this.segments); + return this._resolvePatch(context, segments); + } + + dependencies(context: Context, options: DependencyOptions) { + const aliasContext = context.forAlias(this.alias); + if (!aliasContext) return; + if (aliasContext.keyAlias === this.alias) { + // For keyAliases, use a dependency of the entire list, so that it will + // always update when the list itself changes. This is over-binding, but + // would otherwise be much more complex + const base = aliasContext.expression.resolve(aliasContext.parent); + if (!base) return; + return [base]; + } + + const dependencies = aliasContext.expression.dependencies(aliasContext, options); + return swapLastDependency(dependencies, this, context); + } +} + +export class AttributePathExpression extends Expression { + type = 'AttributePathExpression'; + attribute: any; + + constructor(attribute: any, segments: Segment[], meta?: ExpressionMeta) { + super(meta); + this.attribute = attribute; + this.segments = segments; + } + + serialize(): string { + return serializeObject.instance(this, this.attribute, this.segments, this.meta); + } + + get(context: Context) { + const attributeContext = context.forAttribute(this.attribute); + if (!attributeContext) return; + let value = attributeContext.attributes[this.attribute]; + if (value instanceof Expression) { + value = value.get(attributeContext); + } + return this._lookupAndContextifyValue(value, attributeContext); + } + + resolve(context: Context) { + const attributeContext = context.forAttribute(this.attribute); + if (!attributeContext) return; + // Attributes may be a template, an expression, or a literal value + let base: any[]; + const value = attributeContext.attributes[this.attribute]; + if (value instanceof Expression || value instanceof Template) { + base = value.resolve(attributeContext); + } + if (!base) return; + const segments = base.concat(this.segments); + return this._resolvePatch(context, segments); + } + + dependencies(context: Context, options: DependencyOptions) { + const attributeContext = context.forAttribute(this.attribute); + if (!attributeContext) return; + + // Attributes may be a template, an expression, or a literal value + const value = attributeContext.attributes[this.attribute]; + const dependencies = getDependencies(value, attributeContext, options); + return swapLastDependency(dependencies, this, context); + } +} + +export class BracketsExpression extends Expression { + type = 'BracketsExpression'; + before: any; + inside: any; + afterSegments: any; + + constructor(before: any, inside: any, afterSegments?: any, meta?: ExpressionMeta) { + super(meta); + this.before = before; + this.inside = inside; + this.afterSegments = afterSegments; + this.meta = meta; + } + + serialize = function() { + return serializeObject.instance(this, this.before, this.inside, this.afterSegments, this.meta); + }; + + get(context: Context) { + const inside = this.inside.get(context); + if (inside == null) return; + const before = this.before.get(context); + if (!before) return; + const base = before[inside]; + return (this.afterSegments) ? lookup(this.afterSegments, base) : base; + } + + resolve(context: Context) { + // Get and split the current value of the expression inside the brackets + const inside = this.inside.get(context); + if (inside == null) return; + + // Concat the before, inside, and optional after segments + const base = this.before.resolve(context); + if (!base) return; + const segments = (this.afterSegments) ? + base.concat(inside, this.afterSegments) : + base.concat(inside); + return this._resolvePatch(context, segments); + } + + dependencies(context: Context, options: any) { + const before = this.before.dependencies(context, options); + if (before) before.pop(); + const inner = this.inside.dependencies(context, options); + const dependencies = concat(before, inner); + return appendDependency(dependencies, this, context); + } +} + +// This Expression is used to wrap a template so that when its containing +// Expression--such as an ObjectExpression or ArrayExpression--is evaluated, +// it returns the template unrendered and wrapped in the current context. +// Separating evaluation of the containing expression from template rendering +// is used to support array attributes of views. This way, we can evaluate an +// array and iterate through it separately from rendering template content +export class DeferRenderExpression extends Expression { + template: Template; + type = 'DeferRenderExpression'; + + constructor(template: Template, meta?: ExpressionMeta) { + super(meta); + if (!(template instanceof Template)) { + throw new Error('DeferRenderExpression requires a Template argument'); + } + this.template = template; + this.meta = meta; + } + + serialize(): string { + return serializeObject.instance(this, this.template, this.meta); + } + + get(context: Context) { + return new ContextClosure(this.template, context); + } +} + +export class ArrayExpression extends Expression { + items: any; + afterSegments: any; + type = 'ArrayExpression'; + + constructor(items: any, afterSegments?: any, meta?: ExpressionMeta) { + super(meta); + this.items = items; + this.afterSegments = afterSegments; + this.meta = meta; + } + + serialize(): string { + return serializeObject.instance(this, this.items, this.afterSegments, this.meta); + } + + get(context: Context) { + const items = new Array(this.items.length); + for (let i = 0; i < this.items.length; i++) { + const value = this.items[i].get(context); + items[i] = value; + } + return (this.afterSegments) ? lookup(this.afterSegments, items) : items; + } + + dependencies(context: Context, options: DependencyOptions) { + if (!this.items) return; + let dependencies: any; + for (let i = 0; i < this.items.length; i++) { + const itemDependencies = this.items[i].dependencies(context, options); + dependencies = concat(dependencies, itemDependencies); + } + return dependencies; + } +} + +export class ObjectExpression extends Expression { + properties: any; + afterSegments: any; + type = 'ObjectExpression'; + + constructor(properties: any, afterSegments?: any, meta?: ExpressionMeta) { + super(meta); + this.properties = properties; + this.afterSegments = afterSegments; + } + + serialize(): string { + return serializeObject.instance(this, this.properties, this.afterSegments, this.meta); + } + + get(context: Context) { + const object = {}; + for (const key in this.properties) { + const value = this.properties[key].get(context); + object[key] = value; + } + return (this.afterSegments) ? lookup(this.afterSegments, object) : object; + } + + dependencies(context: Context, options: DependencyOptions) { + if (!this.properties) return; + let dependencies: any; + for (const key in this.properties) { + const propertyDependencies = this.properties[key].dependencies(context, options); + dependencies = concat(dependencies, propertyDependencies); + } + return dependencies; + } +} + +export class FnExpression extends Expression { + args: any; + afterSegments: any; + lastSegment: Segment; + parentSegments: Segment[] | null; + type = 'FnExpression'; + + constructor(segments: Segment[], args: any, afterSegments?: any, meta?: ExpressionMeta) { + super(meta); + this.segments = segments; + this.args = args; + this.afterSegments = afterSegments; + this.meta = meta; + const parentSegments = segments && segments.slice(); + this.lastSegment = parentSegments && parentSegments.pop(); + this.parentSegments = (parentSegments && parentSegments.length) ? parentSegments : null; + } + + serialize(): string { + return serializeObject.instance(this, this.segments, this.args, this.afterSegments, this.meta); + } + + get(context: Context) { + const value = this.apply(context); + // Lookup property underneath computed value if needed + return (this.afterSegments) ? lookup(this.afterSegments, value) : value; + } + + apply(context: Context, extraInputs?: any[]) { + // See View::dependencies. This is needed in order to handle the case of + // getting dependencies within a component template, in which case we cannot + // access model data separate from rendering. + if (!context.controller) return; + const parent = this._lookupParent(context); + const fn = parent[this.lastSegment]; + const getFn = fn.get || fn; + const out = this._applyFn(getFn, context, extraInputs, parent); + return out; + } + + _lookupParent(context: Context) { + // Lookup function on current controller + const controller = context.controller; + const segments = this.parentSegments; + let parent = (segments) ? lookup(segments, controller) : controller; + if (parent && parent[this.lastSegment]) return parent; + // Otherwise lookup function on page + const page = controller.page; + if (controller !== page) { + parent = (segments) ? lookup(segments, page) : page; + if (parent && parent[this.lastSegment]) return parent; + } + // Otherwise lookup function on global + parent = (segments) ? lookup(segments, global) : global; + if (parent && parent[this.lastSegment]) return parent; + // Throw if not found + throw new Error('Function not found for: ' + this.segments.join('.')); + } + + _getInputs(context: Context) { + const inputs = []; + for (let i = 0, len = this.args.length; i < len; i++) { + const value = this.args[i].get(context); + inputs.push(renderValue(value, context)); + } + return inputs; + } + + _applyFn(fn: { apply: (arg0: any, arg1: any[]) => any; call: (arg0: any) => any; }, context: Context, extraInputs: any[], thisArg: any) { + // Apply if there are no path inputs + if (!this.args) { + return (extraInputs) ? + fn.apply(thisArg, extraInputs) : + fn.call(thisArg); + } + // Otherwise, get the current value for path inputs and apply + const inputs = this._getInputs(context); + if (extraInputs) { + for (let i = 0, len = extraInputs.length; i < len; i++) { + inputs.push(extraInputs[i]); + } + } + return fn.apply(thisArg, inputs); + } + + dependencies(context: Context, options: DependencyOptions): any[] { + const dependencies = []; + if (!this.args) return dependencies; + for (let i = 0, len = this.args.length; i < len; i++) { + const argDependencies = this.args[i].dependencies(context, options); + if (!argDependencies || argDependencies.length < 1) continue; + const end = argDependencies.length - 1; + for (let j = 0; j < end; j++) { + dependencies.push(argDependencies[j]); + } + let last = argDependencies[end]; + if (last[last.length - 1] !== '*') { + last = last.concat('*'); + } + dependencies.push(last); + } + return dependencies; + } + + set(context: Context, value: Value) { + let controller = context.controller; + let fn: { set: any; }, parent: { [x: string]: any; }; + while (controller) { + parent = (this.parentSegments) ? + lookup(this.parentSegments, controller) : + controller; + fn = parent && parent[this.lastSegment]; + if (fn) { + break; + } + // controller could be a Component or a PageBase in practice, + // using `as Component` to avoid a runtime instanceof check. + controller = (controller as Component).parent; + } + const setFn = fn && fn.set; + if (!setFn) throw new Error('No setter function for: ' + this.segments.join('.')); + const inputs = this._getInputs(context); + inputs.unshift(value); + const out = setFn.apply(parent, inputs); + for (const i in out) { + this.args[i].set(context, out[i]); + } + } +} + +export class NewExpression extends FnExpression { + type = 'NewExpression'; + + constructor(segments: any, args: any, afterSegments?: any, meta?: ExpressionMeta) { + super(segments, args, afterSegments, meta); + } + + _applyFn(Fn: { new(): any; bind: { apply: (arg0: any, arg1: any[]) => any; }; }, context: Context) { + // Apply if there are no path inputs + if (!this.args) return new Fn(); + // Otherwise, get the current value for path inputs and apply + const inputs = this._getInputs(context); + inputs.unshift(null); + // eslint-disable-next-line prefer-spread + return new (Fn.bind.apply(Fn, inputs))(); + } +} + +export class OperatorExpression extends FnExpression { + type = 'OperatorExpression'; + name: string; + getFn: any; + setFn: any; + + constructor(name: string, args: any, afterSegments?: any, meta?: ExpressionMeta) { + super(null, args, afterSegments, meta); + this.name = name; + this.getFn = operatorFns.get[name]; + this.setFn = operatorFns.set[name]; + } + + serialize(): string { + return serializeObject.instance(this, this.name, this.args, this.afterSegments, this.meta); + } + + apply(context: Context) { + const inputs = this._getInputs(context); + return this.getFn.apply(null, inputs); + } + + set(context: Context, value: Value) { + const inputs = this._getInputs(context); + inputs.unshift(value); + const out = this.setFn.apply(null, inputs); + for (const i in out) { + this.args[i].set(context, out[i]); + } + } +} + +export class SequenceExpression extends OperatorExpression { + type = 'SequenceExpression'; + constructor(args: any, afterSegments?: any, meta?: ExpressionMeta) { + super(',', args, afterSegments, meta); + this.args = args; + this.afterSegments = afterSegments; + this.meta = meta; + } + + serialize(): string { + return serializeObject.instance(this, this.args, this.afterSegments, this.meta); + } + + getFn = operatorFns.get[',']; + + resolve(context: Context) { + const last = this.args[this.args.length - 1]; + return last.resolve(context); + } + + dependencies(context: Context, options: DependencyOptions) { + const dependencies = []; + for (let i = 0, len = this.args.length; i < len; i++) { + const argDependencies = this.args[i].dependencies(context, options); + for (let j = 0, jLen = argDependencies.length; j < jLen; j++) { + dependencies.push(argDependencies[j]); + } + } + return dependencies; + } +} + +// For each method that takes a context argument, get the nearest parent view +// context, then delegate methods to the inner expression +export class ViewParentExpression extends Expression { + type = 'ViewParentExpression'; + expression: Expression; + + constructor(expression: Expression, meta?: ExpressionMeta) { + super(meta); + this.expression = expression; + } + + serialize(): string { + return serializeObject.instance(this, this.expression, this.meta); + } + + get(context: Context) { + const parentContext = context.forViewParent(); + return this.expression.get(parentContext); + } + + resolve(context: Context) { + const parentContext = context.forViewParent(); + return this.expression.resolve(parentContext); + } + + dependencies(context: Context, options: any) { + const parentContext = context.forViewParent(); + return this.expression.dependencies(parentContext, options); + } + + pathSegments(context: Context) { + const parentContext = context.forViewParent(); + return this.expression.pathSegments(parentContext); + } + + set(context: Context, value: Value) { + const parentContext = context.forViewParent(); + return this.expression.set(parentContext, value); + } +} + +export class ScopedModelExpression extends Expression { + expression: Expression; + type = 'ScopedModelExpression'; + constructor(expression: Expression, meta?: ExpressionMeta) { + super(meta); + this.expression = expression; + this.meta = meta; + } + + serialize() { + return serializeObject.instance(this, this.expression, this.meta); + } + + // Return a scoped model instead of the value + get(context: Context) { + const segments = this.pathSegments(context); + if (!segments) return; + return context.controller.model.scope(segments.join('.')); + } + + // Delegate other methods to the inner expression + resolve(context: Context) { + return this.expression.resolve(context); + } + + dependencies(context: Context, options: any) { + return this.expression.dependencies(context, options); + } + + pathSegments(context: Context) { + return this.expression.pathSegments(context); + } + + set(context: Context, value: Value) { + return this.expression.set(context, value); + } +} + +function getDependencies(value: Record, context: Context, options: any): Dependency[] | undefined { + if (value instanceof Expression || value instanceof Template) { + return value.dependencies(context, options); + } +} + +function appendDependency(dependencies: any[], expression: Expression, context: Context) { + const segments = expression.resolve(context); + if (!segments) return dependencies; + if (dependencies) { + dependencies.push(segments); + return dependencies; + } + return [segments]; +} + +function swapLastDependency(dependencies: any[], expression: Expression, context: Context) { + if (!expression.segments.length) { + return dependencies; + } + const segments = expression.resolve(context); + if (!segments) return dependencies; + if (dependencies) { + dependencies.pop(); + dependencies.push(segments); + return dependencies; + } + return [segments]; +} diff --git a/src/templates/index.ts b/src/templates/index.ts new file mode 100644 index 000000000..1e93d0eb9 --- /dev/null +++ b/src/templates/index.ts @@ -0,0 +1,5 @@ +export * as contexts from './contexts'; +export * as expressions from './expressions'; +export * as operatorFns from './operatorFns'; +export * as templates from './templates'; +export { DependencyOptions } from './dependencyOptions'; diff --git a/lib/templates/operatorFns.js b/src/templates/operatorFns.ts similarity index 96% rename from lib/templates/operatorFns.js rename to src/templates/operatorFns.ts index 2bba53b1a..80bba88fe 100644 --- a/lib/templates/operatorFns.js +++ b/src/templates/operatorFns.ts @@ -1,7 +1,7 @@ // `-` and `+` can be either unary or binary, so all unary operators are // postfixed with `U` to differentiate -exports.get = { +export const get = { // Unary operators '!U': function(value) { return !value; @@ -97,12 +97,12 @@ exports.get = { return (test) ? consequent : alternate; }, // Sequence - ',': function() { - return arguments[arguments.length - 1]; + ',': function(...args) { + return args[args.length - 1]; } }; -exports.set = { +export const set = { // Unary operators '!U': function(value) { return [!value]; diff --git a/src/templates/templates.ts b/src/templates/templates.ts new file mode 100644 index 000000000..8b62004d2 --- /dev/null +++ b/src/templates/templates.ts @@ -0,0 +1,2488 @@ +let serializeObject; +if (typeof require === 'function') { + serializeObject = require('serialize-object'); +} + +import { type Context } from './contexts'; +import { DependencyOptions } from './dependencyOptions'; +import { type Expression } from './expressions'; +import { concat, hasKeys, traverseAndCreate } from './util'; +import { Component } from '../components'; +import { Controller } from '../Controller'; + +export type Attributes = Record; +type PathSegment = string | number; +export type Dependency = Array; + +// namespace these are exported under; used when serializing views +const NAMESPACE = 'templates' + +declare global { + interface Node { + $bindItemStart?: RangeBinding; + $bindStart?: RangeBinding; + $bindAttributes?: AttributeBindingsMap; + } + + interface EventTarget { + $destroyListeners?: any[]; + } +} + +// UPDATE_PROPERTIES map HTML attribute names to an Element DOM property that +// should be used for setting on bindings updates instead of setAttribute. +// +// https://github.com/jquery/jquery/blob/1.x-master/src/attributes/prop.js +// https://github.com/jquery/jquery/blob/master/src/attributes/prop.js +// http://webbugtrack.blogspot.com/2007/08/bug-242-setattribute-doesnt-always-work.html +export const BOOLEAN_PROPERTIES = { + checked: 'checked', + disabled: 'disabled', + indeterminate: 'indeterminate', + readonly: 'readOnly', + selected: 'selected' +}; + +export const INTEGER_PROPERTIES = { + colspan: 'colSpan', + maxlength: 'maxLength', + rowspan: 'rowSpan', + tabindex: 'tabIndex' +}; + +export const STRING_PROPERTIES = { + cellpadding: 'cellPadding', + cellspacing: 'cellSpacing', + 'class': 'className', + contenteditable: 'contentEditable', + enctype: 'encoding', + 'for': 'htmlFor', + frameborder: 'frameBorder', + id: 'id', + title: 'title', + type: 'type', + usemap: 'useMap', + value: 'value' +}; + +export const UPDATE_PROPERTIES = { + ...BOOLEAN_PROPERTIES, + ...INTEGER_PROPERTIES, + ...STRING_PROPERTIES, +}; + +// CREATE_PROPERTIES map HTML attribute names to an Element DOM property that +// should be used for setting on Element rendering instead of setAttribute. +// input.defaultChecked and input.defaultValue affect the attribute, so we want +// to use these for initial dynamic rendering. For binding updates, +// input.checked and input.value are modified. +export const CREATE_PROPERTIES = { + ...UPDATE_PROPERTIES, + checked: 'defaultChecked', + value: 'defaultValue', +}; + +// http://www.w3.org/html/wg/drafts/html/master/syntax.html#void-elements +export const VOID_ELEMENTS = { + area: true, + base: true, + br: true, + col: true, + embed: true, + hr: true, + img: true, + input: true, + keygen: true, + link: true, + menuitem: true, + meta: true, + param: true, + source: true, + track: true, + wbr: true +}; + +export const NAMESPACE_URIS = { + svg: 'http://www.w3.org/2000/svg', + xlink: 'http://www.w3.org/1999/xlink', + xmlns: 'http://www.w3.org/2000/xmlns/' +}; + +export class Template { + module = NAMESPACE; + type = 'Template'; + content: Template[]; + source: string; + expression?: Expression; + unbound?: boolean; + hooks: MarkupHook[]; + + constructor(content?: Template[], source?: string) { + this.content = content; + this.source = source; + } + + toString() { + return this.source; + } + + get(context: Context, unescaped: boolean): string | boolean { + return contentHtml(this.content, context, unescaped); + } + + getFragment(context: Context, binding?: Binding) { + const fragment = document.createDocumentFragment(); + this.appendTo(fragment, context, binding); + return fragment; + } + + appendTo(parent: Node, context: Context, _binding?: Binding) { + context.pause(); + appendContent(parent, this.content, context); + context.unpause(); + } + + attachTo(parent: Node, node: Node, context: Context) { + context.pause(); + node = attachContent(parent, node, this.content, context); + context.unpause(); + return node; + } + + update(_context: Context, _binding: Binding) { } + + stringify(value: string) { + return (value == null) ? '' : value + ''; + } + + equals(other: unknown) { + return this === other; + } + + serialize(): string { + return serializeObject.instance(this, this.content, this.source); + } + + isUnbound(context: Context): boolean { + return context.unbound; + } + + resolve(_context: Context): any { } + + dependencies(context: Context, options?: { ignoreTemplate?: Template }): Dependency[] | undefined { + if (DependencyOptions.shouldIgnoreTemplate(this, options)) return; + return concatArrayDependencies(null, this.content, context, options); + } +} + +export class Doctype extends Template { + type = 'Doctype'; + name: string; + publicId: string; + systemId: string; + + constructor(name: string, publicId: string, systemId: string) { + super(); + this.name = name; + this.publicId = publicId; + this.systemId = systemId; + } + + get() { + const publicText = (this.publicId) ? + ' PUBLIC "' + this.publicId + '"' : + ''; + const systemText = (this.systemId) ? + (this.publicId) ? + ' "' + this.systemId + '"' : + ' SYSTEM "' + this.systemId + '"' : + ''; + return ''; + } + + appendTo() { + // Doctype could be created via: + // document.implementation.createDocumentType(this.name, this.publicId, this.systemId) + // However, it does not appear possible or useful to append it to the + // document fragment. Therefore, just don't render it in the browser + } + + attachTo(parent: Node, node: Node) { + if (!hasNodeType(node, 10)) { + throw attachError(parent, node); + } + return node.nextSibling; + } + + serialize(): string { + return serializeObject.instance(this, this.name, this.publicId, this.systemId); + } + + dependencies() { return undefined; } +} + +export class Text extends Template { + type = 'Text'; + data: string; + escaped: string; + + constructor(data: string) { + super(); + this.data = data; + this.escaped = escapeHtml(data); + } + + get(context: Context, unescaped: boolean) { + return (unescaped) ? this.data : this.escaped; + } + + appendTo(parent: Node) { + const node = document.createTextNode(this.data); + parent.appendChild(node); + } + + attachTo(parent: Node, node: Node) { + return attachText(parent, node, this.data, this); + } + + serialize(): string { + return serializeObject.instance(this, this.data); + } + + dependencies() { return undefined; } +} + +// DynamicText might be more accurately named DynamicContent. When its +// expression returns a template, it acts similar to a Block, and it renders +// the template surrounded by comment markers for range replacement. When its +// expression returns any other type, it renders a DOM Text node with no +// markers. Text nodes are bound by updating their data property dynamically. +// The update method must take care to switch between these types of bindings +// in case the expression return type changes dynamically. +export class DynamicText extends Template { + expression: Expression; + unbound: boolean; + + constructor(expression: Expression) { + super(); + this.expression = expression; + this.unbound = false; + } + + get(context: Context, unescaped: boolean) { + let value = this.expression.get(context); + if (value instanceof Template) { + do { + value = value.get(context, unescaped); + } while (value instanceof Template); + return value; + } + const data = this.stringify(value); + return (unescaped) ? data : escapeHtml(data); + } + + appendTo(parent: Node, context: Context, binding: RangeBinding) { + const value = this.expression.get(context); + if (value instanceof Template) { + const start = document.createComment(this.expression.toString()); + const end = document.createComment('/' + this.expression); + const condition = this.getCondition(context); + parent.appendChild(start); + value.appendTo(parent, context); + parent.appendChild(end); + updateRange(context, binding, this, start, end, null, condition); + return; + } + const data = this.stringify(value); + const node = document.createTextNode(data); + parent.appendChild(node); + addNodeBinding(this, context, node); + } + + attachTo(parent: Node, node: Node, context: Context) { + const value = this.expression.get(context); + if (value instanceof Template) { + const start = document.createComment(this.expression.toString()); + const end = document.createComment('/' + this.expression); + const condition = this.getCondition(context); + parent.insertBefore(start, node || null); + node = value.attachTo(parent, node, context); + parent.insertBefore(end, node || null); + updateRange(context, null, this, start, end, null, condition); + return node; + } + const data = this.stringify(value); + return attachText(parent, node, data, this, context); + } + type = 'DynamicText'; + + update(context: Context, binding: Binding) { + if (binding instanceof RangeBinding) { + this._blockUpdate(context, binding); + return; + } + if (!(binding instanceof NodeBinding)) { + // TODO: Confirm this won't ever happen in practice. + throw new Error('DynamicText must be bound with a NodeBinding'); + } + const value = this.expression.get(context); + if (value instanceof Template) { + const start = binding.node; + if (!start.parentNode) return; + const end = start; + const fragment = this.getFragment(context); + replaceRange(context, start, end, fragment, binding); + return; + } + (binding.node as globalThis.Text).data = this.stringify(value); + } + + getCondition(context: Context) { + return this.expression.get(context); + } + + serialize(): string { + return serializeObject.instance(this, this.expression); + } + + _blockUpdate = Block.prototype.update; + + dependencies(context: Context, options?: { ignoreTemplate?: Template }) { + if (DependencyOptions.shouldIgnoreTemplate(this, options)) return; + return getDependencies(this.expression, context, options); + } +} + +function attachText(parent: Node, node: Node, data: string, template: Template, context?: Context) { + if (!node) { + const newNode = document.createTextNode(data); + parent.appendChild(newNode); + addNodeBinding(template, context, newNode); + return; + } + if (hasNodeType(node, 3)) { + // Proceed if nodes already match + if (node.data === data) { + addNodeBinding(template, context, node); + return node.nextSibling; + } + data = normalizeLineBreaks(data); + // Split adjacent text nodes that would have been merged together in HTML + const nextNode = splitData(node, data.length); + if (node.data !== data) { + throw attachError(parent, node); + } + addNodeBinding(template, context, node); + return nextNode; + } + // An empty text node might not be created at the end of some text + if (data === '') { + const newNode = document.createTextNode(''); + parent.insertBefore(newNode, node || null); + addNodeBinding(template, context, newNode); + return node; + } + throw attachError(parent, node); +} + +export class Comment extends Template { + data: string; + hooks: MarkupHook[]; + type = 'Comment'; + + constructor(data: string, hooks?: MarkupHook[]) { + super(); + this.data = data; + this.hooks = hooks; + } + + get() { + return ''; + } + + appendTo(parent: Node, context: Context) { + const node = document.createComment(this.data); + parent.appendChild(node); + emitHooks(this.hooks, context, node); + } + + attachTo(parent: Node, node: Node, context: Context) { + return attachComment(parent, node, this.data, this, context); + } + + serialize(): string { + return serializeObject.instance(this, this.data, this.hooks); + } + + dependencies() { return undefined; } +} + +export class DynamicComment extends Template { + expression: Expression; + hooks: MarkupHook[]; + type = 'DynamicComment'; + + constructor(expression: Expression, hooks: MarkupHook[]) { + super(); + this.expression = expression; + this.hooks = hooks; + } + + get(context: Context) { + const value = getUnescapedValue(this.expression, context); + const data = this.stringify(value); + return ''; + } + + appendTo(parent: Node, context: Context) { + const value = getUnescapedValue(this.expression, context); + const data = this.stringify(value); + const node = document.createComment(data); + parent.appendChild(node); + addNodeBinding(this, context, node); + } + + attachTo(parent: Node, node: Node, context: Context) { + const value = getUnescapedValue(this.expression, context); + const data = this.stringify(value); + return attachComment(parent, node, data, this, context); + } + + update(context: Context, binding: NodeBinding) { + const value = getUnescapedValue(this.expression, context); + (binding.node as globalThis.Comment).data = this.stringify(value); + } + + serialize(): string { + return serializeObject.instance(this, this.expression, this.hooks); + } + + dependencies(context: Context, options: { ignoreTemplate?: Template; }) { + if (DependencyOptions.shouldIgnoreTemplate(this, options)) return; + return getDependencies(this.expression, context, options); + } +} + +function attachComment(parent: Node, node: Node, data: string, template: Template, context: Context) { + // Sometimes IE fails to create Comment nodes from HTML or innerHTML. + // This is an issue inside of , then once they've typed "1.0", + // the context value is set to `1`, triggering this update function to set the input value to + // "1". That means typing "1.01" would be impossible without special handling to avoid + // overwriting an existing input value of "1.0" with a new value of "1". + if (element.tagName === 'INPUT' && propertyName === 'value' && typeof value === 'number') { + if (parseFloat((element as HTMLInputElement).value) === value) { + return; + } + } + const propertyValue = (STRING_PROPERTIES[binding.name]) ? + this.stringify(value) : value; + if (element[propertyName] === propertyValue) return; + element[propertyName] = propertyValue; + return; + } + if (value === false || value == null) { + if (this.ns) { + element.removeAttributeNS(this.ns, binding.name); + } else { + element.removeAttribute(binding.name); + } + return; + } + if (value === true) value = binding.name; + if (this.ns) { + element.setAttributeNS(this.ns, binding.name, value); + } else { + element.setAttribute(binding.name, value); + } + } + + serialize(): string { + return serializeObject.instance(this, this.expression, this.ns); + } + + dependencies(context: Context, options: { ignoreTemplate?: Template; }) { + if (DependencyOptions.shouldIgnoreTemplate(this, options)) return; + return getDependencies(this.expression, context, options); + } +} + +function getUnescapedValue(expression: Expression, context: Context) { + const unescaped = true; + let value = expression.get(context, unescaped); + while (value instanceof Template) { + value = value.get(context, unescaped); + } + return value; +} + +abstract class BaseElement extends Template { + attributes: Attributes; + bindContentToValue: boolean; + hooks: MarkupHook[]; + notClosed: boolean; + ns: string; + selfClosing: boolean; + startClose: string; + tagName: T; + unescapedContent: boolean; + + constructor(attributes: Attributes, content: Template[], hooks: MarkupHook[], selfClosing: boolean, notClosed: boolean, ns: string) { + super(); + this.attributes = attributes; + this.content = content; + this.hooks = hooks; + this.selfClosing = selfClosing; + this.notClosed = notClosed; + this.ns = ns; + } + + abstract getTagName(context: Context): string; + + abstract getEndTag(tagName: string): string; + + get(context: Context) { + const tagName = this.getTagName(context); + const endTag = this.getEndTag(tagName); + const tagItems = [tagName]; + for (const key in this.attributes) { + const value = this.attributes[key].get(context); + if (value === true) { + tagItems.push(key); + } else if (value !== false && value != null) { + tagItems.push(key + '="' + escapeAttribute(value) + '"'); + } + } + const startTag = '<' + tagItems.join(' ') + this.startClose; + if (this.content) { + const inner = contentHtml(this.content, context, this.unescapedContent); + return startTag + inner + endTag; + } + return startTag + endTag; + } + + appendTo(parent: Node, context: Context) { + const tagName = this.getTagName(context); + const element = (this.ns) ? + document.createElementNS(this.ns, tagName) : + document.createElement(tagName); + for (const key in this.attributes) { + const attribute = this.attributes[key]; + let value = attribute.getBound(context, element, key, this.ns); + if (value === false || value == null) continue; + const propertyName = !this.ns && CREATE_PROPERTIES[key]; + if (propertyName) { + element[propertyName] = value; + continue; + } + if (value === true) value = key; + if (attribute.ns) { + element.setAttributeNS(attribute.ns, key, value); + } else { + element.setAttribute(key, value); + } + } + if (this.content) { + this._bindContent(context, element); + appendContent(element, this.content, context); + } + parent.appendChild(element); + emitHooks(this.hooks, context, element); + } + + attachTo(parent: Node, node: Node, context: Context) { + const tagName = this.getTagName(context); + if ( + !hasNodeType(node, 1) || + node.tagName.toLowerCase() !== tagName.toLowerCase() + ) { + throw attachError(parent, node); + } + for (const key in this.attributes) { + // Get each attribute to create bindings + this.attributes[key].getBound(context, node, key, this.ns); + // TODO: Ideally, this would also check that the node's current attributes + // are equivalent, but there are some tricky edge cases + } + if (this.content) { + this._bindContent(context, node); + attachContent(node, node.firstChild, this.content, context); + } + emitHooks(this.hooks, context, node); + return node.nextSibling; + } + + _bindContent(context: Context, element: globalThis.Element) { + // For textareas with dynamic text content, bind to the value property + const child = this.bindContentToValue && + this.content.length === 1 && + this.content[0]; + if (child instanceof DynamicText) { + child.unbound = true; + const template = new DynamicAttribute(child.expression); + context.addBinding(new AttributeBinding(template, context, element, 'value')); + } + } + + serialize(): string { + return serializeObject.instance( + this, + this.tagName, + this.attributes, + this.content, + this.hooks, + this.selfClosing, + this.notClosed, + this.ns + ); + } + + dependencies(context: Context, options: { ignoreTemplate?: Template; }) { + if (DependencyOptions.shouldIgnoreTemplate(this, options)) return; + const dependencies = concatMapDependencies(null, this.attributes, context, options); + if (!this.content) return dependencies; + return concatArrayDependencies(dependencies, this.content, context, options); + } +} + +export class Element extends BaseElement { + type = 'Element'; + endTag: string; + + constructor(tagName: string, attributes: Record, content: Template[], hooks: MarkupHook[], selfClosing: boolean, notClosed: boolean, ns: string) { + super(attributes, content, hooks, selfClosing, notClosed, ns); + this.tagName = tagName; + this.endTag = getEndTag(tagName, selfClosing, notClosed); + this.startClose = getStartClose(selfClosing); + const lowerTagName = tagName && tagName.toLowerCase(); + this.unescapedContent = (lowerTagName === 'script' || lowerTagName === 'style'); + this.bindContentToValue = (lowerTagName === 'textarea'); + } + + getTagName(_context: Context) { + return this.tagName; + } + + getEndTag(_tagName: string) { + return this.endTag; + } +} + +export class DynamicElement extends BaseElement { + type = 'DynamicElement'; + content: Template[]; + attributes: Attributes; + + constructor(tagName: Expression, attributes: Attributes, content: Template[], hooks: any, selfClosing: boolean, notClosed: any, ns: any) { + super(attributes, content, hooks, selfClosing, notClosed, ns); + this.content = content; + this.attributes = attributes; + this.startClose = getStartClose(selfClosing); + this.unescapedContent = false; + this.tagName = tagName; + } + + getTagName(context: Context) { + return getUnescapedValue(this.tagName, context); + } + + getEndTag(tagName: string) { + return getEndTag(tagName, this.selfClosing, this.notClosed); + } + + dependencies(context: Context, options: { ignoreTemplate?: Template; }) { + if (DependencyOptions.shouldIgnoreTemplate(this, options)) return; + const dependencies = super.dependencies(context, options); + return concatDependencies(dependencies, this.tagName, context, options); + } +} + +function getStartClose(selfClosing: boolean) { + return (selfClosing) ? ' />' : '>'; +} + +function getEndTag(tagName: string, selfClosing: boolean, notClosed: boolean) { + const lowerTagName = tagName && tagName.toLowerCase(); + const isVoid = VOID_ELEMENTS[lowerTagName]; + return (isVoid || selfClosing || notClosed) ? '' : ''; +} + +function emitHooks(hooks: MarkupHook[], context: Context, value: Node) { + if (!hooks) return; + context.queue(function queuedHooks() { + for (let i = 0, len = hooks.length; i < len; i++) { + hooks[i].emit(context, value); + } + }); +} + +abstract class BaseBlock extends Template { + ending: string; +} + +export class Block extends BaseBlock { + type = 'Block'; + expression: Expression; + + constructor(expression: Expression, content: Template[]) { + super(); + this.expression = expression; + this.ending = '/' + expression; + this.content = content; + } + + get(context: Context, unescaped: boolean) { + const blockContext = context.child(this.expression); + return contentHtml(this.content, blockContext, unescaped); + } + + appendTo(parent: Node, context: Context, binding: RangeBinding) { + const blockContext = context.child(this.expression); + const start = document.createComment(this.expression.toString()); + const end = document.createComment(this.ending); + const condition = this.getCondition(context); + parent.appendChild(start); + appendContent(parent, this.content, blockContext); + parent.appendChild(end); + updateRange(context, binding, this, start, end, null, condition); + } + + attachTo(parent: Node, node: Node, context: Context) { + const blockContext = context.child(this.expression); + const start = document.createComment(this.expression.toString()); + const end = document.createComment(this.ending); + const condition = this.getCondition(context); + parent.insertBefore(start, node || null); + node = attachContent(parent, node, this.content, blockContext); + parent.insertBefore(end, node || null); + updateRange(context, null, this, start, end, null, condition); + return node; + } + + serialize(): string { + return serializeObject.instance(this, this.expression, this.content); + } + + update(context: Context, binding: RangeBinding) { + if (!binding.start.parentNode) return; + const condition = this.getCondition(context); + // Cancel update if prior condition is equivalent to current value + if (equalConditions(condition, binding.condition)) return; + binding.condition = condition; + // Get start and end in advance, since binding is mutated in getFragment + const start = binding.start; + const end = binding.end; + const fragment = this.getFragment(context, binding); + replaceRange(context, start, end, fragment, binding); + } + + getCondition(context: Context) { + // We do an identity check to see if the value has changed before updating. + // With objects, the object would still be the same, so this identity check + // would fail to update enough. Thus, return NaN, which never equals anything + // including itself, so that we always update on objects. + // + // We could also JSON stringify or use some other hashing approach. However, + // that could be really expensive on gets of things that never change, and + // is probably not a good tradeoff. Perhaps there should be a separate block + // type that is only used in the case of dynamic updates + const value = this.expression.get(context); + return (typeof value === 'object') ? NaN : value; + } + + dependencies(context: Context, options: { ignoreTemplate?: Template; }) { + if (DependencyOptions.shouldIgnoreTemplate(this, options)) return; + const dependencies = (this.expression.meta && this.expression.meta.blockType === 'on') ? + getDependencies(this.expression, context, options) : null; + const blockContext = context.child(this.expression); + return concatArrayDependencies(dependencies, this.content, blockContext, options); + } +} + +export class ConditionalBlock extends BaseBlock { + beginning: string; + contents: Template[][]; + expressions: Expression[]; + type = 'ConditionalBlock'; + + // @TODO: resolve expressions and contents (plural) with Block super call + constructor(expressions: Expression[], contents: Template[][]) { + super(); + this.expressions = expressions; + this.beginning = expressions.join('; '); + this.ending = '/' + this.beginning; + this.contents = contents; + } + + get(context: Context, unescaped: boolean) { + const condition = this.getCondition(context); + if (condition == null) return ''; + const expression = this.expressions[condition]; + const blockContext = context.child(expression); + return contentHtml(this.contents[condition], blockContext, unescaped); + } + + appendTo(parent: Node, context: Context, binding: RangeBinding) { + const start = document.createComment(this.beginning); + const end = document.createComment(this.ending); + parent.appendChild(start); + const condition = this.getCondition(context); + if (condition != null) { + const expression = this.expressions[condition]; + const blockContext = context.child(expression); + appendContent(parent, this.contents[condition], blockContext); + } + parent.appendChild(end); + updateRange(context, binding, this, start, end, null, condition); + } + + attachTo(parent: Node, node: Node, context: Context) { + const start = document.createComment(this.beginning); + const end = document.createComment(this.ending); + parent.insertBefore(start, node || null); + const condition = this.getCondition(context); + if (condition != null) { + const expression = this.expressions[condition]; + const blockContext = context.child(expression); + node = attachContent(parent, node, this.contents[condition], blockContext); + } + parent.insertBefore(end, node || null); + updateRange(context, null, this, start, end, null, condition); + return node; + } + + serialize(): string { + return serializeObject.instance(this, this.expressions, this.contents); + } + + update(context: Context, binding: RangeBinding) { + if (!binding.start.parentNode) return; + const condition = this.getCondition(context); + // Cancel update if prior condition is equivalent to current value + if (equalConditions(condition, binding.condition)) return; + binding.condition = condition; + // Get start and end in advance, since binding is mutated in getFragment + const start = binding.start; + const end = binding.end; + const fragment = this.getFragment(context, binding); + replaceRange(context, start, end, fragment, binding); + } + + getCondition(context: Context) { + for (let i = 0, len = this.expressions.length; i < len; i++) { + if (this.expressions[i].truthy(context)) { + return i; + } + } + } + + dependencies(context: Context, options: { ignoreTemplate?: Template; }) { + if (DependencyOptions.shouldIgnoreTemplate(this, options)) return; + const condition = this.getCondition(context); + if (condition == null) { + return getDependencies(this.expressions[0], context, options); + } + const dependencies = concatSubArrayDependencies(null, this.expressions, context, options, condition); + const expression = this.expressions[condition]; + const content = this.contents[condition]; + const blockContext = context.child(expression); + return concatArrayDependencies(dependencies, content, blockContext, options); + } +} + +export class EachBlock extends Block { + type = 'EachBlock'; + elseContent: Template[]; + + constructor(expression: Expression, content: Template[], elseContent?: Template[]) { + super(expression, content); + this.ending = '/' + expression; + this.elseContent = elseContent; + } + + get(context: Context, unescaped: boolean) { + const items = this.expression.get(context); + if (items && items.length) { + let html = ''; + for (let i = 0, len = items.length; i < len; i++) { + const itemContext = context.eachChild(this.expression, i); + html += contentHtml(this.content, itemContext, unescaped); + } + return html; + } else if (this.elseContent) { + return contentHtml(this.elseContent, context, unescaped); + } + return ''; + } + + appendTo(parent: Node, context: Context, binding: RangeBinding) { + const items = this.expression.get(context); + const start = document.createComment(this.expression.toString()); + const end = document.createComment(this.ending); + parent.appendChild(start); + if (items && items.length) { + for (let i = 0, len = items.length; i < len; i++) { + const itemContext = context.eachChild(this.expression, i); + this.appendItemTo(parent, itemContext, start); + } + } else if (this.elseContent) { + appendContent(parent, this.elseContent, context); + } + parent.appendChild(end); + updateRange(context, binding, this, start, end); + } + + appendItemTo(parent: Node, context: Context, itemFor: globalThis.Comment, binding?: RangeBinding) { + const before = parent.lastChild; + let start: Node, end: Node; + appendContent(parent, this.content, context); + if (before === parent.lastChild) { + start = end = document.createComment('empty'); + parent.appendChild(start); + } else { + start = (before && before.nextSibling) || parent.firstChild; + end = parent.lastChild; + } + updateRange(context, binding, this, start, end, itemFor); + } + + attachTo(parent: Node, node: Node, context: Context) { + const items = this.expression.get(context); + const start = document.createComment(this.expression.toString()); + const end = document.createComment(this.ending); + parent.insertBefore(start, node || null); + if (items && items.length) { + for (let i = 0, len = items.length; i < len; i++) { + const itemContext = context.eachChild(this.expression, i); + node = this.attachItemTo(parent, node, itemContext, start); + } + } else if (this.elseContent) { + node = attachContent(parent, node, this.elseContent, context); + } + parent.insertBefore(end, node || null); + updateRange(context, null, this, start, end); + return node; + } + + attachItemTo(parent: Node, node: Node, context: Context, itemFor: globalThis.Comment) { + let start: Node, end: Node; + const oldPrevious = node && node.previousSibling; + const nextNode = attachContent(parent, node, this.content, context); + if (nextNode === node) { + start = end = document.createComment('empty'); + parent.insertBefore(start, node || null); + } else { + start = (oldPrevious && oldPrevious.nextSibling) || parent.firstChild; + end = (nextNode && nextNode.previousSibling) || parent.lastChild; + } + updateRange(context, null, this, start, end, itemFor); + return nextNode; + } + + update(context: Context, binding: RangeBinding) { + if (!binding.start.parentNode) return; + const start = binding.start; + const end = binding.end; + let fragment: DocumentFragment; + if (binding.itemFor) { + fragment = document.createDocumentFragment(); + this.appendItemTo(fragment, context, binding.itemFor, binding); + } else { + fragment = this.getFragment(context, binding); + } + replaceRange(context, start, end, fragment, binding); + } + + insert(context: Context, binding: RangeBinding, index: number, howMany: number) { + const parent = binding.start.parentNode; + if (!parent) return; + // In case we are inserting all of the items, update instead. This is needed + // when we were previously rendering elseContent so that it is replaced + if (index === 0 && this.expression.get(context).length === howMany) { + return this.update(context, binding); + } + const node = indexStartNode(binding, index); + const fragment = document.createDocumentFragment(); + for (let i = index, len = index + howMany; i < len; i++) { + const itemContext = context.eachChild(this.expression, i); + this.appendItemTo(fragment, itemContext, binding.start as globalThis.Comment); + } + parent.insertBefore(fragment, node || null); + } + + remove(context: Context, binding: RangeBinding, index: number, howMany: number) { + const parent = binding.start.parentNode; + if (!parent) return; + // In case we are removing all of the items, update instead. This is needed + // when elseContent should be rendered + if (index === 0 && this.expression.get(context).length === 0) { + return this.update(context, binding); + } + let node = indexStartNode(binding, index); + let i = 0; + while (node) { + if (node === binding.end) return; + if (node.$bindItemStart && node.$bindItemStart.itemFor === binding.start) { + if (howMany === i++) return; + } + const nextNode = node.nextSibling; + parent.removeChild(node); + emitRemoved(context, node, binding); + node = nextNode; + } + } + + move(context: Context, binding: RangeBinding, from: number, to: number, howMany: number) { + const parent = binding.start.parentNode; + if (!parent) return; + let node = indexStartNode(binding, from); + const fragment = document.createDocumentFragment(); + let i = 0; + while (node) { + if (node === binding.end) break; + if (node.$bindItemStart && node.$bindItemStart.itemFor === binding.start) { + if (howMany === i++) break; + } + const nextNode = node.nextSibling; + fragment.appendChild(node); + node = nextNode; + } + node = indexStartNode(binding, to); + parent.insertBefore(fragment, node || null); + } + + serialize(): string { + return serializeObject.instance(this, this.expression, this.content, this.elseContent); + } + + dependencies(context: Context, options: { ignoreTemplate?: Template; }) { + if (DependencyOptions.shouldIgnoreTemplate(this, options)) return; + let dependencies = getDependencies(this.expression, context, options); + const items = this.expression.get(context); + if (items && items.length) { + for (let i = 0; i < items.length; i++) { + const itemContext = context.eachChild(this.expression, i); + dependencies = concatArrayDependencies(dependencies, this.content, itemContext, options); + } + } else if (this.elseContent) { + dependencies = concatArrayDependencies(dependencies, this.elseContent, context, options); + } + return dependencies; + } +} + +//#region functions + +function indexStartNode(binding: RangeBinding, index: number) { + let node = binding.start; + let i = 0; + while ((node = node.nextSibling)) { + if (node === binding.end) return node; + if (node.$bindItemStart && node.$bindItemStart.itemFor === binding.start) { + if (index === i) return node; + i++; + } + } +} + +function updateRange(context: Context, binding: RangeBinding, template: Template, start: Node, end: Node, itemFor?: globalThis.Comment, condition?: number) { + if (binding) { + binding.start = start; + binding.end = end; + binding.condition = condition; + setNodeBounds(binding, start, itemFor); + } else { + context.addBinding(new RangeBinding(template, context, start, end, itemFor, condition)); + } +} + +function setNodeBounds(binding: RangeBinding, start: Node, itemFor: globalThis.Comment) { + if (itemFor) { + setNodeProperty(start, '$bindItemStart', binding); + } else { + setNodeProperty(start, '$bindStart', binding); + } +} + +function appendContent(parent: Node, content: Template[], context: Context) { + for (let i = 0, len = content.length; i < len; i++) { + content[i].appendTo(parent, context); + } +} + +function attachContent(parent: Node, node: Node, content: Template[], context: Context) { + for (let i = 0, len = content.length; i < len; i++) { + while (node && 'hasAttribute' in node && (node as globalThis.Element).hasAttribute('data-no-attach')) { + node = node.nextSibling; + } + node = content[i].attachTo(parent, node, context); + } + return node; +} + +function contentHtml(content: Template[], context: Context, unescaped: boolean) { + let html = ''; + for (let i = 0, len = content.length; i < len; i++) { + html += content[i].get(context, unescaped); + } + return html; +} + +function replaceRange(context: Context, start: Node, end: Node, fragment: DocumentFragment, binding: Binding, innerOnly?: boolean) { + // Note: the calling function must make sure to check that there is a parent + const parent = start.parentNode; + // Copy item binding from old start to fragment being inserted + if (start.$bindItemStart && fragment.firstChild) { + setNodeProperty(fragment.firstChild, '$bindItemStart', start.$bindItemStart); + start.$bindItemStart.start = fragment.firstChild; + } + // Fast path for single node replacements + if (start === end) { + parent.replaceChild(fragment, start); + emitRemoved(context, start, binding); + return; + } + // Remove all nodes from start to end + let node = (innerOnly) ? start.nextSibling : start; + let nextNode: any; + while (node) { + nextNode = node.nextSibling; + emitRemoved(context, node, binding); + if (innerOnly && node === end) { + nextNode = end; + break; + } + parent.removeChild(node); + if (node === end) break; + node = nextNode; + } + // This also works if nextNode is null, by doing an append + parent.insertBefore(fragment, nextNode || null); +} + +function emitRemoved(context: Context, node: Node, ignore: Binding) { + context.removeNode(node); + emitRemovedBinding(context, ignore, node, '$bindNode'); + emitRemovedBinding(context, ignore, node, '$bindStart'); + emitRemovedBinding(context, ignore, node, '$bindItemStart'); + const attributes = node.$bindAttributes; + if (attributes) { + node.$bindAttributes = null; + for (const key in attributes) { + context.removeBinding(attributes[key]); + } + } + for (node = node.firstChild; node; node = node.nextSibling) { + emitRemoved(context, node, ignore); + } +} + +function emitRemovedBinding(context: Context, ignore: Binding, node: Node, property: string) { + const binding = node[property]; + if (binding) { + node[property] = null; + if (binding !== ignore) { + context.removeBinding(binding); + } + } +} + +function attachError(parent: Node, node: Node) { + if (typeof console !== 'undefined') { + console.error('Attach failed for', node, 'within', parent); + } + return new Error('Attaching bindings failed, because HTML structure ' + + 'does not match client rendering.' + ); +} + +//#endregion + +export class Binding { + type = 'Binding'; + meta: any; + context: Context; + template: any; + + constructor() { + this.meta = null; + } + + update(_previous?, _pass?) { + this.context.pause(); + this.template.update(this.context, this); + this.context.unpause(); + } + + insert(_index: number, _howMany: number) { + this.update(); + } + + remove(_index: number, _howMany: number) { + this.update(); + } + + move(_from: number, _to: number, _howMany: number) { + this.update(); + } + + isUnbound() { + return this.template.expression.isUnbound(this.context); + } +} + +export class NodeBinding extends Binding { + type = 'NodeBinding'; + node: Node; + + constructor(template: Template, context: Context, node: Node) { + super(); + this.template = template; + this.context = context; + this.node = node; + this.meta = null; + setNodeProperty(node, '$bindNode', this); + } +} + +export class AttributeBindingsMap { } + +export class AttributeBinding extends Binding { + type = 'AttributeBinding'; + element: globalThis.Element; + name: string; + + constructor(template: DynamicAttribute, context: Context, element: globalThis.Element, name: string) { + super(); + this.template = template; + this.context = context; + this.element = element; + this.name = name; + this.meta = null; + const map = element.$bindAttributes || + (element.$bindAttributes = new AttributeBindingsMap()); + map[name] = this; + } +} + +export class RangeBinding extends Binding { + type = 'RangeBinding'; + // In most cases, start and end are DOM Comment nodes. + // In an EachBlock template, start and end can be Element nodes for appendItemTo and attachItemTo. + start: Node; + end: Node; + itemFor?: globalThis.Comment | null; + condition: any; + + constructor(template: Template, context: Context, start: Node, end: Node, itemFor: globalThis.Comment | null | undefined, condition: any) { + super(); + this.template = template; + this.context = context; + this.start = start; + this.end = end; + this.itemFor = itemFor; + this.condition = condition; + this.meta = null; + setNodeBounds(this, start, itemFor); + } + + insert(index: number, howMany: number) { + this.context.pause(); + if (this.template.insert) { + this.template.insert(this.context, this, index, howMany); + } else { + this.template.update(this.context, this); + } + this.context.unpause(); + } + + remove(index: number, howMany: number) { + this.context.pause(); + if (this.template.remove) { + this.template.remove(this.context, this, index, howMany); + } else { + this.template.update(this.context, this); + } + this.context.unpause(); + } + + move(from: number, to: number, howMany: number) { + this.context.pause(); + if (this.template.move) { + this.template.move(this.context, this, from, to, howMany); + } else { + this.template.update(this.context, this); + } + this.context.unpause(); + } +} + +//#region +/// Utility functions /// + +function escapeHtml(string: string): string { + string = string + ''; + return string.replace(/[&<]/g, function(match) { + return (match === '&') ? '&' : '<'; + }); +} + +function escapeAttribute(string: string): string { + string = string + ''; + return string.replace(/[&"]/g, function(match) { + return (match === '&') ? '&' : '"'; + }); +} + +function equalConditions(a: unknown, b: unknown): boolean { + // First, test for strict equality + if (a === b) return true; + // Failing that, allow for template objects used as a condition to define a + // custom `equals()` method to indicate equivalence + return (a instanceof Template) && a.equals(b); +} + +/** + * Type map of `nodeType` numbers to corresponding TS types. Only ones useful + * in Derby are here. + * + * @see https://developer.mozilla.org/en-US/docs/Web/API/Node/nodeType + */ +interface NodeTypeMap { + 1: globalThis.Element; + 3: globalThis.Text; + 8: globalThis.Comment; + 11: DocumentFragment; + [nodeType: number]: Node; +} + +function hasNodeType(node: Node | undefined, nodeType: T): node is NodeTypeMap[T] { + return node && node.nodeType === nodeType; +} + +//#endregion + +export const emptyTemplate = new Template([]); + +//#region Shims & workarounds //// + +// General notes: +// +// In all cases, Node.insertBefore should have `|| null` after its second +// argument. IE works correctly when the argument is ommitted or equal +// to null, but it throws and error if it is equal to undefined. + +if (!Array.isArray) { + // @ts-expect-error Shim defining Array.isArray + Array.isArray = function(value) { + return Object.prototype.toString.call(value) === '[object Array]'; + }; +} + +// Equivalent to textNode.splitText, which is buggy in IE <=9 +function splitData(node: globalThis.Text, index: number) { + const newNode = node.cloneNode(false) as globalThis.Text; + newNode.deleteData(0, index); + node.deleteData(index, node.length - index); + node.parentNode.insertBefore(newNode, node.nextSibling || null); + return newNode; +} + +// Defined so that it can be overriden in IE <=8 +let setNodeProperty = (node: { [x: string]: any; }, key: string, value: unknown) => { + return node[key] = value; +} + +let normalizeLineBreaks = (value: string) => value; + +(function() { + // Don't try to shim in Node.js environment + if (typeof document === 'undefined') return; + + const div = document.createElement('div'); + div.innerHTML = '\r\n
\n' + // @ts-expect-error Property `data` does not exist on ChildNode + const windowsLength = div.firstChild.data.length; + // @ts-expect-error Property `data` does not exist on ChildNode + const unixLength = div.lastChild.data.length; + if (windowsLength === 1 && unixLength === 1) { + normalizeLineBreaks = function(value: string) { + return value.replace(/\r\n/g, '\n'); + }; + } else if (windowsLength === 2 && unixLength === 2) { + normalizeLineBreaks = function(value: string) { + return value.replace(/(^|[^\r])(\n+)/g, function(match, value, newLines) { + for (let i = newLines.length; i--;) { + value += '\r\n'; + } + return value; + }); + }; + } + + // TODO: Shim createHtmlFragment for old IE + + // TODO: Shim setAttribute('style'), which doesn't work in IE <=7 + // http://webbugtrack.blogspot.com/2007/10/bug-245-setattribute-style-does-not.html + + // TODO: Investigate whether input name attribute works in IE <=7. We could + // override Element::appendTo to use IE's alternative createElement syntax: + // document.createElement('') + // http://webbugtrack.blogspot.com/2007/10/bug-235-createelement-is-broken-in-ie.html + + // In IE, input.defaultValue doesn't work correctly, so use input.value, + // which mistakenly but conveniently sets both the value property and attribute. + // + // Surprisingly, in IE <=7, input.defaultChecked must be used instead of + // input.checked before the input is in the document. + // http://webbugtrack.blogspot.com/2007/11/bug-299-setattribute-checked-does-not.html + const input = document.createElement('input'); + input.defaultValue = 'x'; + if (input.value !== 'x') { + CREATE_PROPERTIES.value = 'value'; + } + + try { + // TextNodes are not expando in IE <=8 + // @ts-expect-error $try does not exist on Text + document.createTextNode('').$try = 0; + } catch (err) { + setNodeProperty = function(node, key, value) { + // If trying to set a property on a TextNode, create a proxy CommentNode + // and set the property on that node instead. Put the proxy after the + // TextNode if marking the end of a range, and before otherwise. + if (node.nodeType === 3) { + let proxyNode = node.previousSibling; + if (!proxyNode || proxyNode.$bindProxy !== node) { + proxyNode = document.createComment('proxy'); + proxyNode.$bindProxy = node; + node.parentNode.insertBefore(proxyNode, node || null); + } + return proxyNode[key] = value; + } + // Set the property directly on other node types + return node[key] = value; + }; + } +})(); + +//#endregion + +interface HasDependencies { + dependencies(context: Context, options?: { ignoreTemplate?: Template }): Dependency[] | undefined; +} + +function concatSubArrayDependencies(dependencies: Dependency[], expressions: HasDependencies[], context: Context, options: { ignoreTemplate?: Template; }, end: number) { + for (let i = 0; i <= end; i++) { + dependencies = concatDependencies(dependencies, expressions[i], context, options); + } + return dependencies; +} + +function concatArrayDependencies(dependencies: Dependency[] | undefined, expressions: HasDependencies[], context: Context, options: { ignoreTemplate?: Template; }) { + for (let i = 0; i < expressions.length; i++) { + dependencies = concatDependencies(dependencies, expressions[i], context, options); + } + return dependencies; +} + +function concatMapDependencies(dependencies: Dependency[], expressions: Record, context: Context, options: { ignoreTemplate?: Template; }) { + for (const key in expressions) { + dependencies = concatDependencies(dependencies, expressions[key], context, options); + } + return dependencies; +} + +function concatDependencies(dependencies: Dependency[] | undefined, expression: HasDependencies, context: Context, options: { ignoreTemplate?: Template; }) { + const expressionDependencies = getDependencies(expression, context, options); + return concat(dependencies, expressionDependencies); +} + +function getDependencies(expression: HasDependencies, context: Context, options: { ignoreTemplate?: Template; }) { + return expression.dependencies(context, options); +} + +export abstract class MarkupHook { + module = NAMESPACE; + name: string; + abstract emit(context: Context, target: T): void; +} + +class Hook extends MarkupHook { + name = 'hook'; + emit(context: Context, node: Node & { $component: Controller }) { + node.$component = context.controller; + context.controller.markerNode = node; + } +} + +const markerHooks = [new Hook()] as Array>; + +export class Marker extends Comment { + type = 'Marker'; + + constructor(data: string) { + super(data, markerHooks); + } + + serialize(): string { + return serializeObject.instance(this, this.data); + } + + get() { + return ''; + } +} + +function ViewAttributesMap(source: string) { + const items = source.split(/\s+/); + for (let i = 0, len = items.length; i < len; i++) { + this[items[i]] = true; + } +} + +function ViewArraysMap(source: string) { + const items = source.split(/\s+/); + for (let i = 0, len = items.length; i < len; i++) { + const item = items[i].split('/'); + this[item[0]] = item[1] || item[0]; + } +} + +interface ViewOptions { + attributes?: any; + arrays?: any; + unminified?: string; + string?: string; + literal?: string; + + /** + * Custom HTML tag name for the view, so it can be used like `` + * in addition to the standard ``. + */ + tag?: string; + /** @deprecated - Use `tag` instead */ + element?: string; + + server?: boolean; + /** + * If true, the view is only for use in server code, and + * Derby won't serialize the view for client code. + */ + serverOnly?: boolean; +} + +export class View extends Template { + arraysMap: any; + attributesMap: any; + componentFactory: { + constructorFn: any, + init: any, + create: any, + }; + fromSerialized: boolean; + literal: boolean; + name: string; + namespace: string; + options: ViewOptions; + registeredName: string; + string: boolean; + template: any; + type = 'View'; + unminified: boolean; + views: any; + + constructor(views: any, name: string, source: string, options: ViewOptions) { + super(); + this.views = views; + this.name = name; + this.source = source; + this.options = options; + + const nameSegments = (this.name || '').split(':'); + const lastSegment = nameSegments.pop(); + this.namespace = nameSegments.join(':'); + this.registeredName = (lastSegment === 'index') ? this.namespace : this.name; + + this.attributesMap = options && options.attributes && + new ViewAttributesMap(options.attributes); + this.arraysMap = options && options.arrays && + new ViewArraysMap(options.arrays); + // The empty string is considered true for easier HTML attribute parsing + this.unminified = !!(options && (options.unminified || options.unminified === '')); + this.string = !!(options && (options.string || options.string === '')); + this.literal = !!(options && (options.literal || options.literal === '')); + this.template = null; + this.componentFactory = null; + this.fromSerialized = false; + } + + serialize(): string { + return null; + } + + _isComponent(context: Context) { + if (!this.componentFactory) return false; + if (context.attributes && context.attributes.extend) return false; + return true; + } + + _initComponent(context: Context) { + return (this._isComponent(context)) ? + this.componentFactory.init(context) : context; + } + + _queueCreate(context: Context, viewContext: { controller: any; }) { + if (this._isComponent(context)) { + const componentFactory = this.componentFactory; + context.queue(function queuedCreate() { + componentFactory.create(viewContext); + }); + + if (!context.hooks) return; + context.queue(function queuedComponentHooks() { + // Kick off hooks if view instance specified `on` or `as` attributes + for (let i = 0, len = context.hooks.length; i < len; i++) { + context.hooks[i].emit(context, viewContext.controller); + } + }); + } + } + + get(context: Context, unescaped: boolean) { + const viewContext = this._initComponent(context); + const template = this.template || this.parse(); + return template.get(viewContext, unescaped); + } + + getFragment(context: Context, binding: Binding) { + const viewContext = this._initComponent(context); + const template = this.template || this.parse(); + const fragment = template.getFragment(viewContext, binding); + this._queueCreate(context, viewContext); + return fragment; + } + + appendTo(parent: Node, context: Context) { + const viewContext = this._initComponent(context); + const template = this.template || this.parse(); + template.appendTo(parent, viewContext); + this._queueCreate(context, viewContext); + } + + attachTo(parent: Node, node: Node, context: Context) { + const viewContext = this._initComponent(context); + const template = this.template || this.parse(); + node = template.attachTo(parent, node, viewContext); + this._queueCreate(context, viewContext); + return node; + } + + dependencies(context: Context, options: { ignoreTemplate?: Template; }) { + if (DependencyOptions.shouldIgnoreTemplate(this, options)) return; + const template = this.template || this.parse(); + // We can't figure out relative path dependencies within a component without + // rendering it, because each component instance's scope is dynamically set + // based on its unique `id` property. To represent this, set the context + // controller to `null`. + // + // Under normal rendering conditions, contexts should always have reference + // to a controller. Expression::get() methods use the reference to + // `context.controller.model.data` to lookup values, and paths are resolved + // based on `context.controller.model._scope`. + // + // To handle this, Expression methods guard against a null controller by not + // returning any dependencies for model paths. In addition, they return + // `undefined` from get, which affect dependencies computed for + // ConditionalBlock and EachBlock, as their dependencies will differ based + // on the value of model data. + // + // TODO: This likely under-estimates the true dependencies within a + // template. However, to provide a more complete view of dependencies, we'd + // need information we only have at render time, namely, the scope and data + // within the component model. This may indicate that Derby should use a + // more Functional Reactive Programming (FRP)-like approach of having + // dependencies be returned from getFragment and attach methods along with + // DOM nodes rather than computing dependencies separately from rendering. + const viewContext = (this._isComponent(context)) ? + context.componentChild(null) : context; + return template.dependencies(viewContext, options); + } + + parse() { + this._parse(); + if (this.componentFactory && !this.componentFactory.constructorFn.prototype.singleton) { + const marker = new Marker(this.name); + this.template.content.unshift(marker); + } + return this.template; + } + + // _parse is defined in parsing.js, so that it doesn't have to + // be included in the client if templates are all parsed server-side + _parse() { + throw new Error('View parsing not available'); + } +} + +abstract class BaseViewInstance extends Template { + attributes: any; + hooks: any; + initHooks: any; + + get(context: Context, unescaped: boolean) { + const view = this._find(context); + const viewContext = context.viewChild(view, this.attributes, this.hooks, this.initHooks); + return view.get(viewContext, unescaped); + } + + getFragment(context: Context, binding: Binding) { + const view = this._find(context); + const viewContext = context.viewChild(view, this.attributes, this.hooks, this.initHooks); + return view.getFragment(viewContext, binding); + } + + appendTo(parent: Node, context: Context) { + const view = this._find(context); + const viewContext = context.viewChild(view, this.attributes, this.hooks, this.initHooks); + view.appendTo(parent, viewContext); + } + + attachTo(parent: Node, node: Node, context: Context) { + const view = this._find(context); + const viewContext = context.viewChild(view, this.attributes, this.hooks, this.initHooks); + return view.attachTo(parent, node, viewContext); + } + + abstract _find(_context: Context): any; +} + +export class ViewInstance extends BaseViewInstance { + type = 'ViewInstance'; + name: string; + view: any; + + constructor(name: string, attributes: any, hooks: any, initHooks: any) { + super(); + this.name = name; + this.attributes = attributes; + this.hooks = hooks; + this.initHooks = initHooks; + this.view = null; + } + + serialize(): string { + return serializeObject.instance(this, this.name, this.attributes, this.hooks, this.initHooks); + } + + dependencies(context: Context, options: { ignoreTemplate?: Template; }) { + if (DependencyOptions.shouldIgnoreTemplate(this, options)) return; + const view = this._find(context); + const viewContext = context.viewChild(view, this.attributes, this.hooks, this.initHooks); + return view.dependencies(viewContext, options); + } + + _find(context: Context) { + if (this.view) return this.view; + const contextView = context.getView(); + const namespace = contextView && contextView.namespace; + this.view = context.meta.views.find(this.name, namespace); + if (!this.view) { + const message = context.meta.views.findErrorMessage(this.name, contextView); + throw new Error(message); + } + return this.view; + } +} + +export class DynamicViewInstance extends BaseViewInstance { + type = 'DynamicViewInstance'; + nameExpression: any; + + constructor(nameExpression: any, attributes: any, hooks: any, initHooks: any) { + super(); + this.attributes = attributes; + this.hooks = hooks; + this.initHooks = initHooks; + this.nameExpression = nameExpression; + } + + serialize(): string { + return serializeObject.instance(this, this.nameExpression, this.attributes, this.hooks, this.initHooks); + } + + _find(context: Context) { + const name = this.nameExpression.get(context); + const contextView = context.getView(); + const namespace = contextView && contextView.namespace; + const view = name && context.meta.views.find(name, namespace); + return view || emptyTemplate; + } + + dependencies(context: Context, options: { ignoreTemplate?: Template; }) { + if (DependencyOptions.shouldIgnoreTemplate(this, options)) return; + const nameDependencies = this.nameExpression.dependencies(context); + const viewDependencies = ViewInstance.prototype.dependencies.call(this, context, options); + return concat(nameDependencies, viewDependencies); + } +} + +// Without a ContextClosure, ViewParent will return the nearest context that +// is the parent of a view instance. When a context with a `closure` property +// is encountered first, ViewParent will find the specific referenced context, +// even if it is further up the context hierarchy. +export class ViewParent extends Template { + type = 'ViewParent'; + template: Template; + + constructor(template: Template) { + super(); + this.template = template; + } + + serialize(): string { + return serializeObject.instance(this, this.template); + } + + get(context: Context, unescaped: boolean) { + const parentContext = context.forViewParent(); + return this.template.get(parentContext, unescaped); + } + + getFragment(context: Context, binding: Binding) { + const parentContext = context.forViewParent(); + return this.template.getFragment(parentContext, binding); + } + + appendTo(parent: Node, context: Context) { + const parentContext = context.forViewParent(); + this.template.appendTo(parent, parentContext); + } + + attachTo(parent: Node, node: Node, context: Context) { + const parentContext = context.forViewParent(); + return this.template.attachTo(parent, node, parentContext); + } + + dependencies(context: Context, options: { ignoreTemplate?: Template; }) { + if (DependencyOptions.shouldIgnoreTemplate(this, options)) return; + const parentContext = context.forViewParent(); + return this.template.dependencies(parentContext, options); + } +} + +// At render time, this template creates a context child and sets its +// `closure` property to a fixed reference. It is used in combination with +// ViewParent in order to control which context is returned. +// +// Instances of this template cannot be serialized. It is intended for use +// dynamically during rendering only. +export class ContextClosure extends Template { + template: Template; + context: Context; + + constructor(template: Template, context: Context) { + super(); + this.template = template; + this.context = context; + } + + serialize(): string { + throw new Error('ContextClosure cannot be serialized'); + } + + get(context: Context, unescaped: boolean) { + const closureContext = context.closureChild(this.context); + return this.template.get(closureContext, unescaped); + } + + getFragment(context: Context, binding: Binding) { + const closureContext = context.closureChild(this.context); + return this.template.getFragment(closureContext, binding); + } + + appendTo(parent: Node, context: Context) { + const closureContext = context.closureChild(this.context); + this.template.appendTo(parent, closureContext); + } + + attachTo(parent: Node, node: Node, context: Context) { + const closureContext = context.closureChild(this.context); + return this.template.attachTo(parent, node, closureContext); + } + + dependencies(context: Context, options: { ignoreTemplate: Template; }) { + if (DependencyOptions.shouldIgnoreTemplate(this.template, options)) return; + const closureContext = context.closureChild(this.context); + return this.template.dependencies(closureContext, options); + } + + equals(other: Template) { + return (other instanceof ContextClosure) && + (this.context === other.context) && + (this.template.equals(other.template)); + } +} + +class ViewsMap { } + +export class Views { + nameMap: ViewsMap; + tagMap: ViewsMap; + // @deprecated: elementMap is deprecated and should be removed with Derby 0.6.0 + elementMap: ViewsMap; + + constructor() { + this.nameMap = new ViewsMap(); + this.tagMap = new ViewsMap(); + // TODO: elementMap is deprecated and should be removed with Derby 0.6.0 + this.elementMap = this.tagMap; + } + + find(name: string, namespace?: string) { + const map = this.nameMap; + + // Exact match lookup + const exactName = (namespace) ? namespace + ':' + name : name; + const match = map[exactName]; + if (match) return match; + + // Relative lookup + let segments = name.split(':'); + let segmentsDepth = segments.length; + if (namespace) segments = namespace.split(':').concat(segments); + // Iterate through segments, leaving the `segmentsDepth` segments and + // removing the second to `segmentsDepth` segment to traverse up the + // namespaces. Decrease `segmentsDepth` if not found and repeat again. + while (segmentsDepth > 0) { + const testSegments = segments.slice(); + while (testSegments.length > segmentsDepth) { + testSegments.splice(-1 - segmentsDepth, 1); + const testName = testSegments.join(':'); + const match = map[testName]; + if (match) return match; + } + segmentsDepth--; + } + } + + register(name: string, source: string, options?: ViewOptions) { + const mapName = name.replace(/:index$/, ''); + let view = this.nameMap[mapName]; + if (view) { + // Recreate the view if it already exists. We re-apply the constructor + // instead of creating a new view object so that references to object + // can be cached after finding the first time + const componentFactory = view.componentFactory; + View.call(view, this, name, source, options); + view.componentFactory = componentFactory; + } else { + view = new View(this, name, source, options); + } + this.nameMap[mapName] = view; + // TODO: element is deprecated and should be removed with Derby 0.6.0 + const tagName = options && (options.tag || options.element); + if (tagName) this.tagMap[tagName] = view; + return view; + } + + deserialize(items: string | any[]) { + for (let i = 0; i < items.length; i++) { + const item = items[i]; + const setTemplate = item[0]; + const name = item[1]; + const source = item[2]; + const options = item[3]; + const view = this.register(name, source, options); + view.parse = setTemplate; + view.fromSerialized = true; + } + } + + serialize(options?: { server?: boolean; minify?: boolean; }) { + const forServer = options && options.server; + const minify = options && options.minify; + const items = []; + for (const name in this.nameMap) { + const view = this.nameMap[name]; + let template = view.template || view.parse(); + if (!forServer && view.options) { + // Do not serialize views with the `serverOnly` option, except when + // serializing for a server script + if (view.options.serverOnly) continue; + // For views with the `server` option, serialize them with a blank + // template body. This allows them to be used from other views on the + // browser, but they will output nothing on the browser + if (view.options.server) template = emptyTemplate; + } + // Serializing views as a function allows them to be constructed lazily upon + // first use. This can improve initial load times of the application when + // there are many views + items.push( + '[function(){return this.template=' + + template.serialize() + '},' + + serializeObject.args([ + view.name, + (minify) ? null : view.source, + (hasKeys(view.options)) ? view.options : null + ]) + + ']' + ); + } + return 'function(derbyTemplates, views){' + + 'var expressions = derbyTemplates.expressions,' + + 'templates = derbyTemplates.templates;' + + 'views.deserialize([' + items.join(',') + '])}'; + } + + findErrorMessage(name: string, contextView?: { name: string; source: string; }) { + const names = Object.keys(this.nameMap); + let message = 'Cannot find view "' + name + '" in' + + [''].concat(names).join('\n ') + '\n'; + if (contextView) { + message += '\nWithin template "' + contextView.name + '":\n' + contextView.source; + } + return message; + } +} + +export class ElementOn extends MarkupHook { + type = 'ElementOn'; + name: string; + expression: any; + + constructor(name: string, expression: any) { + super(); + this.name = name; + this.expression = expression; + } + + serialize(): string { + return serializeObject.instance(this, this.name, this.expression); + } + + emit(context: Context, element: globalThis.Element) { + if (this.name === 'create') { + this.apply(context, element); + return; + } + // eslint-disable-next-line @typescript-eslint/no-this-alias + const elementOn = this; + const listener = function elementOnListener(event: any) { + return elementOn.apply(context, element, event); + }; + // Using `context.controller.dom.on` would be better for garbage collection, + // but since it synchronously removes listeners on component destroy, it would + // break existing code relying on `on-*` listeners firing as a component is + // being destroyed. Even with `addEventListener`, browsers should still GC + // the listeners once there are no references to the element. + element.addEventListener(this.name, listener, false); + // context.controller.dom.on(this.name, element, listener, false); + } + + apply(context: Context, element: any, event?: any) { + const modelData = context.controller.model.data; + modelData.$event = event; + modelData.$element = element; + const out = this.expression.apply(context); + delete modelData.$event; + delete modelData.$element; + return out; + } +} + +export class ComponentOn extends MarkupHook { + type = 'ComponentOn'; + name: string; + expression: any; + + constructor(name: string, expression: any) { + super(); + this.name = name; + this.expression = expression; + } + + serialize(): string { + return serializeObject.instance(this, this.name, this.expression); + } + + emit(context: Context, component: { on: (arg0: string, arg1: (...args: any[]) => any) => void; }) { + const expression = this.expression; + component.on(this.name, function componentOnListener(...args: any[]) { + return expression.apply(context, args); + }); + } +} + +abstract class AsPropertyBase extends MarkupHook { + type: string; + segments: PathSegment[]; + lastSegment: PathSegment; + + constructor(segments: PathSegment[]) { + super(); + this.segments = segments; + this.lastSegment = segments.pop(); + } + + serialize(): string { + const segments = this.segments.concat(this.lastSegment); + return serializeObject.instance(this, segments); + } + + emit(context: Context, target: T) { + const node = traverseAndCreate(context.controller, this.segments); + node[this.lastSegment] = target; + this.addListeners(target, node, this.lastSegment); + } + + addListeners(target: T, object: { [x: string]: any; }, key: string | number) { + this.addDestroyListener(target, function asPropertyDestroy() { + // memoize initial reference so we dont destroy + // property that has been replaced with a different reference + const intialRef = object[key]; + process.nextTick(function deleteProperty() { + if (intialRef !== object[key]) { + return; + } + delete object[key]; + }); + }); + } + + abstract addDestroyListener(target: T, onDestroy: () => void): void; +} + +export class AsProperty extends AsPropertyBase { + type = 'AsProperty'; + + addDestroyListener = elementAddDestroyListener; +} + +export class AsPropertyComponent extends AsPropertyBase { + type = 'AsPropertyComponent'; + + constructor(segments: PathSegment[]) { + super(segments); + } + + addDestroyListener = componentAddDestroyListener; +} + +export class AsObject extends AsProperty { + type = 'AsObject'; + keyExpression: any; + + constructor(segments: PathSegment[], keyExpression: any) { + super(segments); + this.keyExpression = keyExpression; + } + + serialize(): string { + const segments = this.segments.concat(this.lastSegment); + return serializeObject.instance(this, segments, this.keyExpression); + } + + emit(context: Context, target: any) { + const node = traverseAndCreate(context.controller, this.segments); + const object = node[this.lastSegment] || (node[this.lastSegment] = {}); + const key = this.keyExpression.get(context); + object[key] = target; + this.addListeners(target, object, key); + } +} + +export class AsObjectComponent extends AsObject { + type = 'AsObjectComponent'; + + constructor(segments: PathSegment[], keyExpression: any) { + super(segments, keyExpression); + } + + addDestroyListener = componentAddDestroyListener; +} + +abstract class AsArrayBase extends AsPropertyBase { + type = 'AsArrayBase'; + + emit(context: Context, target: any) { + const node = traverseAndCreate(context.controller, this.segments); + const array = node[this.lastSegment] || (node[this.lastSegment] = []); + + // Iterate backwards, since rendering will usually append + for (let i = array.length; i--;) { + const item = array[i]; + // Don't add an item if already in the array + if (item === target) return; + const mask = this.comparePosition(target, item); + // If the emitted target is after the current item in the document, + // insert it next in the array + // Node.DOCUMENT_POSITION_FOLLOWING = 4 + if (mask & 4) { + array.splice(i + 1, 0, target); + this.addListeners(target, array); + return; + } + } + // Add to the beginning if before all items + array.unshift(target); + this.addListeners(target, array); + } + + addListeners(target: any, array: any) { + this.addDestroyListener(target, function asArrayDestroy() { + removeArrayItem(array, target); + }); + } + + abstract comparePosition(target: T, item: T): number; +} + +export class AsArray extends AsArrayBase { + type = 'AsArray'; + + constructor(segments: PathSegment[]) { + super(segments); + } + + comparePosition(target: Node, item: Node) { + return item.compareDocumentPosition(target); + } + + addDestroyListener = elementAddDestroyListener; +} + +export class AsArrayComponent extends AsArrayBase<{ markerNode: Node }> { + type = 'AsArrayComponent'; + + constructor(segments: PathSegment[]) { + super(segments); + } + + comparePosition(target: { markerNode: Node }, item: { markerNode: Node }) { + return item.markerNode.compareDocumentPosition(target.markerNode); + } + + addDestroyListener = componentAddDestroyListener; +} + +export function elementAddDestroyListener(element: globalThis.Element, listener: any) { + const destroyListeners = element.$destroyListeners; + if (destroyListeners) { + if (destroyListeners.indexOf(listener) === -1) { + destroyListeners.push(listener); + } + } else { + element.$destroyListeners = [listener]; + } +} + +export function elementRemoveDestroyListener(element: { $destroyListeners: any; }, listener: any) { + const destroyListeners = element.$destroyListeners; + if (destroyListeners) { + removeArrayItem(destroyListeners, listener); + } +} + +// TODO: Change target to Component once Component is in TS and has EventEmitter as ancestor +function componentAddDestroyListener(target: any, listener: () => void) { + target.on('destroy', listener); +} + +function removeArrayItem(array: any[], item: any) { + const index = array.indexOf(item); + if (index > -1) { + array.splice(index, 1); + } +} diff --git a/src/templates/util.ts b/src/templates/util.ts new file mode 100644 index 000000000..f2c3a0594 --- /dev/null +++ b/src/templates/util.ts @@ -0,0 +1,23 @@ +export function concat(a, b) { + if (!a) return b; + if (!b) return a; + return a.concat(b); +} + +export function hasKeys(value) { + if (!value) return false; + for (const key in value) { + return true; + } + return false; +} + +export function traverseAndCreate(node, segments) { + const len = segments.length; + if (!len) return node; + for (let i = 0; i < len; i++) { + const segment = segments[i]; + node = node[segment] || (node[segment] = {}); + } + return node; +} diff --git a/lib/textDiff.js b/src/textDiff.js similarity index 100% rename from lib/textDiff.js rename to src/textDiff.js diff --git a/templates.js b/templates.js deleted file mode 100644 index b317f3300..000000000 --- a/templates.js +++ /dev/null @@ -1 +0,0 @@ -module.exports = require('./lib/templates'); diff --git a/test-utils/ComponentHarness.js b/test-utils/ComponentHarness.js index 939758e14..63109065d 100644 --- a/test-utils/ComponentHarness.js +++ b/test-utils/ComponentHarness.js @@ -3,8 +3,8 @@ var qs = require('qs'); var urlParse = require('url').parse; var Model = require('racer').Model; var racerUtil = require('racer/lib/util'); -var App = require('../lib/App'); -var AppForServer = require('../lib/AppForServer'); +var App = require('../dist/App').App; +var AppForServer = require('../dist/AppForServer').AppForServer; function AppForHarness(harness) { App.call(this); diff --git a/test-utils/domTestRunner.js b/test-utils/domTestRunner.js index 3c161dd58..5065fa777 100644 --- a/test-utils/domTestRunner.js +++ b/test-utils/domTestRunner.js @@ -59,7 +59,7 @@ function mochaHooksForNode(runner, options) { nodeGlobal.window = runner.window; nodeGlobal.document = runner.document; // Initialize "input" and "change" listeners on the document. - require('../lib/documentListeners').add(runner.document); + require('../dist/documentListeners').add(runner.document); }); global.afterEach(function() { diff --git a/test/all/App.mocha.js b/test/all/App.mocha.js index 36de0925f..37b56cb7e 100644 --- a/test/all/App.mocha.js +++ b/test/all/App.mocha.js @@ -1,5 +1,5 @@ var expect = require('chai').expect; -var App = require('../../lib/App'); +var App = require('../../dist/App').App; describe('App._parseInitialData', () => { it('parses simple json', () => { @@ -14,7 +14,7 @@ describe('App._parseInitialData', () => { it('thorws error with context for unexpected tokens', () => { expect(() => App._parseInitialData('{"foo": b}')).to.throw( - 'Parse failure: Unexpected token b in JSON at position 8 context: \'{"foo": b}\'' + /^Parse failure: Unexpected token/ ); }); }); diff --git a/test/all/ComponentHarness.mocha.js b/test/all/ComponentHarness.mocha.js index 644f4563e..bb9237bdc 100644 --- a/test/all/ComponentHarness.mocha.js +++ b/test/all/ComponentHarness.mocha.js @@ -1,6 +1,6 @@ var expect = require('chai').expect; var ComponentHarness = require('../../test-utils').ComponentHarness; -var derbyTemplates = require('../../templates'); +var derbyTemplates = require('../../dist/templates'); describe('ComponentHarness', function() { describe('renderHtml', function() { diff --git a/test/all/eventmodel.mocha.js b/test/all/eventmodel.mocha.js index b9bb6234e..396517d9d 100644 --- a/test/all/eventmodel.mocha.js +++ b/test/all/eventmodel.mocha.js @@ -1,5 +1,5 @@ var expect = require('chai').expect; -var EventModel = require('../../lib/eventmodel'); +var EventModel = require('../../dist/eventmodel'); describe('eventmodel', function() { beforeEach(function() { @@ -56,7 +56,7 @@ describe('eventmodel', function() { expect(binding.eventModels).to.eq(undefined); em.addBinding(['x'], binding); expect(binding.eventModels).to.be.instanceOf(Object); - console.log(em.at(['x'])); + // console.log(em.at(['x'])); expect(em.at(['x'])).has.ownProperty('bindings').instanceOf(Object); }); }); @@ -94,10 +94,9 @@ describe('eventmodel', function() { }); describe('lists', function() { - it('Does not update an item binding inside a list item when something is inserted around it', function() { + it.skip('Does not update an item binding inside a list item when something is inserted around it', function() { //var ctx = {item:1}; //this.em.addBinding(['list', ctx], this.binding); - }); }); diff --git a/test/all/parsing/dependencies.mocha.js b/test/all/parsing/dependencies.mocha.js index a9e529c62..188f84973 100644 --- a/test/all/parsing/dependencies.mocha.js +++ b/test/all/parsing/dependencies.mocha.js @@ -1,8 +1,8 @@ var expect = require('chai').expect; -var derbyTemplates = require('../../../lib/templates'); +var derbyTemplates = require('../../../dist/templates'); var contexts = derbyTemplates.contexts; var templates = derbyTemplates.templates; -var parsing = require('../../../lib/parsing'); +var parsing = require('../../../dist/parsing'); var createExpression = parsing.createExpression; var createTemplate = parsing.createTemplate; diff --git a/test/all/parsing/expressions.mocha.js b/test/all/parsing/expressions.mocha.js index 19a71a001..412f4720e 100644 --- a/test/all/parsing/expressions.mocha.js +++ b/test/all/parsing/expressions.mocha.js @@ -1,8 +1,8 @@ var expect = require('chai').expect; -var derbyTemplates = require('../../../lib/templates'); +var derbyTemplates = require('../../../dist/templates'); var contexts = derbyTemplates.contexts; var expressions = derbyTemplates.expressions; -var create = require('../../../lib/parsing/createPathExpression'); +var create = require('../../../dist/parsing/createPathExpression').createPathExpression; var controller = { plus: function(a, b) { diff --git a/test/all/parsing/templates.mocha.js b/test/all/parsing/templates.mocha.js index 0e3944703..5b40d6b05 100644 --- a/test/all/parsing/templates.mocha.js +++ b/test/all/parsing/templates.mocha.js @@ -1,8 +1,8 @@ var expect = require('chai').expect; -var derbyTemplates = require('../../../lib/templates'); +var derbyTemplates = require('../../../dist/templates'); var contexts = derbyTemplates.contexts; var templates = derbyTemplates.templates; -var parsing = require('../../../lib/parsing'); +var parsing = require('../../../dist/parsing'); var model = { data: { @@ -108,7 +108,7 @@ describe('Parse and render dynamic text and blocks', function() { var source = '{{with _page.greeting as greeting}}{{/with}}'; expect(function() { var template = parsing.createTemplate(source); - console.log(template.content[0]); + // console.log(template.content[0]); }).to.throw(/Alias must be an identifier starting with "#"/); }); diff --git a/test/all/parsing/truthy.mocha.js b/test/all/parsing/truthy.mocha.js index f150f5cb9..1c4174880 100644 --- a/test/all/parsing/truthy.mocha.js +++ b/test/all/parsing/truthy.mocha.js @@ -1,5 +1,5 @@ var expect = require('chai').expect; -var parsing = require('../../../lib/parsing'); +var parsing = require('../../../dist/parsing'); describe('template truthy', function() { diff --git a/test/all/templates/templates.mocha.js b/test/all/templates/templates.mocha.js index 58936c7ae..98145b767 100644 --- a/test/all/templates/templates.mocha.js +++ b/test/all/templates/templates.mocha.js @@ -1,5 +1,5 @@ var expect = require('chai').expect; -var templates = require('../../../lib/templates/templates'); +var templates = require('../../../dist/templates/templates'); describe('Views', function() { @@ -39,3 +39,22 @@ describe('Views', function() { }); }); + +describe('Hooks', function() { + it('derives valid module attribute from base class', function() { + class TestHook extends templates.MarkupHook { + constructor() { + super(); + this.name = 'TestHook'; + } + } + var testHook = new TestHook(); + expect(testHook.name).to.equal('TestHook'); + expect(testHook.module).to.equal('templates'); + }); + it('has valid module name', function() { + var hook = new templates.ComponentOn('foo'); + expect(hook.name).to.equal('foo'); + expect(hook.module).to.equal('templates'); + }); +}); diff --git a/test/browser/components.js b/test/browser/components.js index bf3cfdbc3..e042641d0 100644 --- a/test/browser/components.js +++ b/test/browser/components.js @@ -1,5 +1,5 @@ var expect = require('chai').expect; -var templates = require('../../lib/templates').templates; +var templates = require('../../dist/templates').templates; var derby = require('./util').derby; describe('components', function() { @@ -109,7 +109,6 @@ describe('components', function() { this.update = getFn.call(this, update); }; app.component('box', Box); - var fragment = page.getFragment('Body'); var box = page.box; box.update(); box.update(); @@ -143,7 +142,6 @@ describe('components', function() { this.update = getFn.call(this, update); }; app.component('box', Box); - var fragment = page.getFragment('Body'); box = page.box; box.update(); box.update(); @@ -182,7 +180,6 @@ describe('components', function() { this.update = getFn.call(this, update); }; app.component('box', Box); - var fragment = page.getFragment('Body'); box = page.box; box.update('a', 1); box.update('b', 2); @@ -245,12 +242,10 @@ describe('components', function() { this.update = this.debounceAsync(update); }; app.component('box', Box); - var fragment = page.getFragment('Body'); page.box.update('a', 1); }); it('debounceAsync debounces until the async call completes', function(done) { var app = derby.createApp(); - var page = app.createPage(); app.views.register('Body', ''); app.views.register('box', '
'); var calls = 0; @@ -282,11 +277,9 @@ describe('components', function() { }, 7); }; app.component('box', Box); - var fragment = page.getFragment('Body'); }); it('throttle calls no more frequently than delay', function(done) { var app = derby.createApp(); - var page = app.createPage(); app.views.register('Body', ''); app.views.register('box', '
'); var delay = 10; @@ -321,7 +314,6 @@ describe('components', function() { tick(); }; app.component('box', Box); - var fragment = page.getFragment('Body'); }); }); diff --git a/test/browser/forms.js b/test/browser/forms.js index 0fd2f0233..8cbae4689 100644 --- a/test/browser/forms.js +++ b/test/browser/forms.js @@ -48,7 +48,6 @@ describe('forms', function() { app.views.register('Body', ''); var page = app.createPage(); var text = page.model.at('_page.text'); - var key = page.model.at('_page.key'); text.set('Hi'); var fragment = page.getFragment('Body'); var textarea = fragment.firstChild; @@ -66,11 +65,9 @@ describe('forms', function() { app.views.register('Body', ''); var page = app.createPage(); var text = page.model.at('_page.text'); - var key = page.model.at('_page.key'); text.set('Hi'); var fragment = page.getFragment('Body'); var textarea = fragment.firstChild; - var textNode = textarea.firstChild; // Insert the fragment in the document so that Derby captures events this.fixture.appendChild(fragment); textarea.value = 'Yo'; @@ -83,11 +80,9 @@ describe('forms', function() { app.views.register('Body', ''); var page = app.createPage(); var text = page.model.at('_page.text'); - var key = page.model.at('_page.key'); text.set('Hi'); var fragment = page.getFragment('Body'); var textarea = fragment.firstChild; - var textNode = textarea.firstChild; // Insert the fragment in the document so that Derby captures events this.fixture.appendChild(fragment); textarea.value = 'Yo'; diff --git a/test/browser/util.js b/test/browser/util.js index 54ec1d3c3..4d17e98da 100644 --- a/test/browser/util.js +++ b/test/browser/util.js @@ -1,6 +1,6 @@ var chai = require('chai'); -var DerbyStandalone = require('../../lib/DerbyStandalone'); -require('../../lib/parsing'); +var DerbyStandalone = require('../../dist/DerbyStandalone'); +require('../../dist/parsing'); require('../../test-utils').assertions(window, chai.Assertion); exports.derby = new DerbyStandalone(); diff --git a/test/dom/ComponentHarness.mocha.js b/test/dom/ComponentHarness.mocha.js index 69376d9d3..ae8163400 100644 --- a/test/dom/ComponentHarness.mocha.js +++ b/test/dom/ComponentHarness.mocha.js @@ -1,5 +1,5 @@ var expect = require('chai').expect; -var Component = require('../../lib/components').Component; +var Component = require('../../dist/components').Component; var domTestRunner = require('../../test-utils/domTestRunner'); describe('ComponentHarness', function() { diff --git a/test/dom/templates/templates.dom.mocha.js b/test/dom/templates/templates.dom.mocha.js index 9d9a21f14..085972824 100644 --- a/test/dom/templates/templates.dom.mocha.js +++ b/test/dom/templates/templates.dom.mocha.js @@ -1,6 +1,6 @@ var chai = require('chai'); var expect = chai.expect; -var saddle = require('../../../lib/templates/templates'); +var saddle = require('../../../dist/templates/templates'); var domTestRunner = require('../../../test-utils/domTestRunner'); describe('templates rendering', function() { diff --git a/test/server/templates/templates.server.mocha.js b/test/server/templates/templates.server.mocha.js index aa17a6c86..018e9f707 100644 --- a/test/server/templates/templates.server.mocha.js +++ b/test/server/templates/templates.server.mocha.js @@ -1,6 +1,6 @@ var expect = require('chai').expect; -var templates = require('../../../lib/templates/templates'); -var expressions = require('../../../lib/templates/expressions'); +var templates = require('../../../dist/templates/templates'); +var expressions = require('../../../dist/templates/expressions'); function test(createTemplate) { return function() { diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 000000000..ba98083a0 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "allowJs": true, + "ignoreDeprecations": "5.0", + "lib":["DOM"], + "module": "CommonJS", + "noImplicitUseStrict": true, + "outDir": "dist", + "target": "ES5", + "sourceMap": false, + "declaration": false, + "declarationMap": false, + }, + "include": [ + "src/**/*" + ] +}