├── .eslintrc
├── .github
└── workflows
│ ├── docs-gh-pages.yml
│ └── test.yml
├── .gitignore
├── .pre-commit-config.yaml
├── History.md
├── README.md
├── docs
├── .gitignore
├── 404.html
├── Gemfile
├── Gemfile.lock
├── README.md
├── _config.yml
├── _sass
│ ├── color_schemes
│ │ └── derby-light.scss
│ ├── custom.scss
│ └── custom
│ │ └── custom.scss
├── apps.md
├── components.md
├── components
│ ├── charts-debug.png
│ ├── component-class.md
│ ├── events.md
│ ├── lifecycle.md
│ ├── scope.md
│ └── view-partials.md
├── guides.md
├── guides
│ └── troubleshooting.md
├── index.md
├── models.md
├── models
│ ├── backends.md
│ ├── contexts.md
│ ├── events.md
│ ├── filters-sorts.md
│ ├── getters.md
│ ├── mutators.md
│ ├── paths.md
│ ├── queries.md
│ ├── reactive-functions.md
│ └── refs.md
├── routes.md
├── views.md
└── views
│ ├── namespaces-and-files.md
│ ├── template-syntax.md
│ └── template-syntax
│ ├── blocks.md
│ ├── escaping.md
│ ├── functions-and-events.md
│ ├── literals.md
│ ├── operators.md
│ ├── paths.md
│ └── view-attributes.md
├── package.json
├── src
├── App.ts
├── AppForServer.ts
├── Controller.ts
├── Derby.ts
├── DerbyForServer.ts
├── Dom.ts
├── Page.ts
├── PageForServer.ts
├── _views.js
├── components.ts
├── documentListeners.js
├── eventmodel.ts
├── files.ts
├── index.ts
├── parsing
│ ├── createPathExpression.ts
│ ├── index.ts
│ └── markup.ts
├── routes.ts
├── server.ts
├── templates
│ ├── contexts.ts
│ ├── dependencyOptions.ts
│ ├── expressions.ts
│ ├── index.ts
│ ├── operatorFns.ts
│ ├── templates.ts
│ └── util.ts
├── test-utils
│ ├── ComponentHarness.ts
│ ├── assertions.ts
│ ├── domTestRunner.ts
│ └── index.ts
└── textDiff.js
├── test
├── .jshintrc
├── all
│ ├── App.mocha.js
│ ├── ComponentHarness.mocha.js
│ ├── eventmodel.mocha.js
│ ├── parsing
│ │ ├── dependencies.mocha.js
│ │ ├── expressions.mocha.js
│ │ ├── templates.mocha.js
│ │ └── truthy.mocha.js
│ └── templates
│ │ └── templates.mocha.js
├── dom
│ ├── ComponentHarness.mocha.js
│ ├── as.mocha.js
│ ├── bindings.mocha.js
│ ├── components.browser.mocha.js
│ ├── components.mocha.js
│ ├── dom-events.mocha.js
│ ├── domTestRunner.mocha.js
│ ├── forms.browser.mocha.js
│ └── templates
│ │ └── templates.dom.mocha.js
├── fixtures
│ ├── simple-box.html
│ └── simple-box.js
├── mocha.opts
├── public
│ └── index.html
├── server.js
└── server
│ ├── ComponentHarness.mocha.js
│ └── templates
│ └── templates.server.mocha.js
├── tsconfig.json
├── typedoc.json
└── typedocExcludeUnderscore.mjs
/.eslintrc:
--------------------------------------------------------------------------------
1 | {
2 | "env": {
3 | "node": true,
4 | "es6": true
5 | },
6 | "parserOptions": {
7 | "ecmaVersion": 9
8 | },
9 | "globals": {
10 | "window": false,
11 | "document": false
12 | },
13 | "root": true,
14 | "ignorePatterns": ["dist/"],
15 | "rules": {
16 | "comma-style": [
17 | 2,
18 | "last"
19 | ],
20 | "eqeqeq": ["error", "always", {"null": "ignore"}],
21 | "indent": [
22 | 2,
23 | 2,
24 | {
25 | "SwitchCase": 1
26 | }
27 | ],
28 | "new-cap": 2,
29 | "quotes": [
30 | 2,
31 | "single"
32 | ],
33 | "no-undef": 2,
34 | "no-shadow": 0,
35 | "no-unused-expressions": 2,
36 | "no-cond-assign": [
37 | 2,
38 | "except-parens"
39 | ],
40 | "no-unused-vars": ["off", { "argsIgnorePattern": "^_" }],
41 | "@typescript-eslint/no-unused-vars": ["error", { "argsIgnorePattern": "^_" }]
42 | },
43 | "overrides": [
44 | {
45 | // Files that are only run in Node can use more modern ES syntax.
46 | "files": ["**/*ForServer.js", "test/**/*.mocha.js"],
47 | "parserOptions": {
48 | // Node 16 LTS supports up through ES2021.
49 | "ecmaVersion": 2021
50 | }
51 | },
52 | {
53 | "files": ["test/**/*.js"],
54 | "env": {"mocha": true, "node": true}
55 | },
56 | {
57 | "files": ["src/**/*.ts"],
58 | "parser": "@typescript-eslint/parser",
59 | "plugins": ["@typescript-eslint", "prettier", "import"],
60 | "extends": [
61 | "eslint:recommended",
62 | "plugin:@typescript-eslint/eslint-recommended",
63 | "plugin:@typescript-eslint/recommended",
64 | "plugin:import/recommended",
65 | "plugin:import/typescript"
66 | ],
67 | "rules": {
68 | "@typescript-eslint/no-explicit-any": ["warn", { "ignoreRestArgs": false }],
69 | "import/no-unresolved": "error",
70 | "import/order": [
71 | "error",
72 | {
73 | "groups": [
74 | "builtin",
75 | "external",
76 | "internal",
77 | ["sibling", "parent"],
78 | "index",
79 | "unknown"
80 | ],
81 | "newlines-between": "always",
82 | "alphabetize": {
83 | "order": "asc",
84 | "caseInsensitive": true
85 | }
86 | }
87 | ]
88 | }
89 | }
90 | ]
91 | }
92 |
--------------------------------------------------------------------------------
/.github/workflows/docs-gh-pages.yml:
--------------------------------------------------------------------------------
1 | # Based on https://github.com/actions/starter-workflows/blob/main/pages/jekyll.yml
2 | name: Build Jekyll site, Deploy to Pages when on default branch
3 |
4 | env:
5 | DOCS_DIR: docs
6 |
7 | on:
8 | # Run workflow on any branch push.
9 | # Conditionals are used to only trigger deploy on the default branch.
10 | push:
11 | # Uncomment to only run on specific branch pushes.
12 | # branches: ["master"]
13 |
14 | # Allows you to run this workflow manually from the Actions tab
15 | workflow_dispatch:
16 |
17 | # Sets permissions of the GITHUB_TOKEN to allow deployment to GitHub Pages
18 | permissions:
19 | contents: read
20 | pages: write
21 | id-token: write
22 |
23 | # Allow only one concurrent deployment per branch, skipping runs queued between the run in-progress and latest queued.
24 | # However, do NOT cancel in-progress runs as we want to allow the deployments to complete.
25 | concurrency:
26 | group: "pages-${{ github.ref }}"
27 | cancel-in-progress: false
28 |
29 | jobs:
30 | # Build job
31 | build:
32 | runs-on: ubuntu-latest
33 | steps:
34 | - name: Checkout
35 | uses: actions/checkout@v4
36 | - name: Setup Node
37 | uses: actions/setup-node@v4
38 | with:
39 | node-version: 18
40 | - name: Setup Ruby
41 | uses: ruby/setup-ruby@8575951200e472d5f2d95c625da0c7bec8217c42 # v1.161.0
42 | with:
43 | ruby-version: '3.2' # Not needed with a .ruby-version file
44 | working-directory: ${{ env.DOCS_DIR }}
45 | bundler-cache: true # runs 'bundle install' and caches installed gems automatically
46 | # cache-version: 0 # Increment this number if the cache gets corrupted and you need to force-update cached gems
47 | - name: Setup Pages
48 | id: pages
49 | uses: actions/configure-pages@v4
50 | - name: Build with Typedoc
51 | run: npm i && npm run docs
52 | - name: Build documentaiton site
53 | working-directory: ${{ env.DOCS_DIR }}
54 | run: bundle exec jekyll build --baseurl "${{ steps.pages.outputs.base_path }}"
55 | env:
56 | JEKYLL_ENV: production
57 | - name: Upload artifact
58 | if: github.ref == 'refs/heads/master' # Only upload when on default branch
59 | uses: actions/upload-pages-artifact@v3
60 | with:
61 | # Default path is './_site'.
62 | path: "./${{ env.DOCS_DIR }}/_site"
63 |
64 | # Deployment job
65 | deploy:
66 | if: github.ref == 'refs/heads/master' # Only deploy when on default branch
67 | environment:
68 | name: github-pages
69 | url: ${{ steps.deployment.outputs.page_url }}
70 | runs-on: ubuntu-latest
71 | needs: build
72 | steps:
73 | - name: Deploy to GitHub Pages
74 | id: deployment
75 | uses: actions/deploy-pages@v4
76 |
--------------------------------------------------------------------------------
/.github/workflows/test.yml:
--------------------------------------------------------------------------------
1 | name: Test
2 |
3 | on:
4 | push:
5 | branches:
6 | - master
7 | - derby-2
8 | pull_request:
9 | branches:
10 | - master
11 | - derby-2
12 |
13 | jobs:
14 | test:
15 | name: Node ${{ matrix.node }}
16 | runs-on: ubuntu-latest
17 | strategy:
18 | matrix:
19 | node:
20 | - 18
21 | - 20
22 | - 22
23 | timeout-minutes: 10
24 | steps:
25 | - uses: actions/checkout@v2
26 | - uses: actions/setup-node@v1
27 | with:
28 | node-version: ${{ matrix.node }}
29 | - name: Install
30 | run: npm install
31 | - name: Test
32 | run: npm run checks
33 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | .DS_Store
2 | *.swp
3 | node_modules
4 | .vscode
5 | dist/
6 |
--------------------------------------------------------------------------------
/.pre-commit-config.yaml:
--------------------------------------------------------------------------------
1 | - repo: git://github.com/pre-commit/mirrors-jshint
2 | sha: 'v2.5.11'
3 | hooks:
4 | - id: jshint
5 | - repo: git://github.com/pre-commit/pre-commit-hooks
6 | sha: 'v0.4.0'
7 | hooks:
8 | - id: check-added-large-files
9 | - id: check-json
10 | - id: end-of-file-fixer
11 | - id: trailing-whitespace
12 |
--------------------------------------------------------------------------------
/History.md:
--------------------------------------------------------------------------------
1 | # Derby change history
2 |
3 | Racer provides the model and data synchronization for Derby. It's versions are updated along with Derby versions. See change history for Racer as well:
4 | https://github.com/derbyjs/racer/blob/master/History.md
5 |
6 | ## 0.3.14
7 | This release includes a great deal of work making components and templating more full featured and performant
8 |
9 | - Instead of macro template tags with triple curly braces, such as {{{items}}}, attributes passed to components are now accessed with an @ sign, such as {{@items}} or {@items}
10 | - syntax is now supported for including components with a variable name. This is equivalent to , but the view attribute can vary based on context
11 | - Components can be passed an `inherit` attribute to default to the attributes from the parent
12 | - View#fn syntax has changed to support more arbitrary inputs and outputs. See the docs for more detail
13 | - Instead of view.dom, it is now app.view, app.dom, app.history, and app.model in the client
14 | - Add View#componentsByName
15 | - Bindings don't update during route execution for faster page renders
16 | - Initial support for bindings within inline SVG elements
17 | - Components have a 'destroy' event that gets invoked when they are no longer in the DOM
18 | - Components automatically cleanup listeners added via dom.addListener and model.on within a component
19 | - App#fn can be used to add controller methods to the app more easily across different files
20 | - App auto-reloading no longer uses Up, which didn't work well with polling. Reloading now uses cluster, though it is still somewhat buggy
21 | - App#enter and App#exit added, which get called upon entering or exiting a particular route pattern. Can be used intead of App#ready, which only gets called on the very first page load
22 | - Add Component#setup for more easy access to the library within a given components code. Useful for adding view functions pertaining to a particular component
23 | - App#Collection added for more convenient access to scoped models in controller functions and definition of collection specific methods. Demonstrated in the leaderboard code of the Sink example.
24 | - e.path(), e.get(), and e.at() now available for using template-style path names in controller code. Often more flexible and convenient than using model.at(el)
25 | - Many bug fixes
26 |
27 | ## 0.3.13
28 | Mostly just bug fixes
29 |
30 | - Add Component#emitDelayable()
31 | - Fix options specification to be more consistent
32 | - Add staticMount option
33 | - Lots of bug fixes
34 |
35 | ## 0.3.12
36 | The API for creating stores and sessions has changed in support of adding auth. Generating a new starter server via `derby new` is recommended
37 |
38 | - There is no longer an app.createStore() method, and the derby.createStore() method must be used in combination with the store.modelMiddleware(). There is now a req.getModel() method added by the modelMiddleware.
39 | - Bugs in bracketed path interpolation in templates have been fixed, and the syntax has been updated to work more like javascript property accessors. The syntax is now `
{users[_userId].name}
`
40 | - The component type is now passed as a second argument to init and create events instead of being available as a property of the component. Within the same library, the namespaces is now correctly sent as 'lib:'
41 | - Add `log` and `path` view helper functions for debugging
42 | - Support binding the same event to multiple space-separated function names in `x-bind`
43 | - Set the value of component macro attributes to true when no value is specified for more easy HTML boolean style flags
44 | - Fix a bug where using multiple apps would cause the page to reload continuously
45 | - Default project now requires Express beta 4, since beta 6 breaks Gzippo
46 | - Fix lots of other bugs
47 |
48 | ## 0.3.11
49 | - Emit 'render', 'render:{ns}', 'replace', and 'replace:{ns}' events on an app when the page is rendered client-side
50 | - Call x-bind functions with `this` set to the app or component object
51 | - Support prototype-like definition of component methods for more efficient creation of component instances
52 | - More careful delaying of component creation method calls instead of using setTimeout
53 | - Fix bug in lookup of templates from an inherited namespace
54 | - Fix bug when binding to a view helper function inside of an each and later updating array indicies
55 | - Fix bugs in component path binding lookup
56 |
57 | ## 0.3.10
58 | - Fix bugs with Browserify 1.13.0 and Express 3.0.0 beta 3
59 | - Fix bugs in components
60 | - Can pass JSON object literals to component attributes
61 | - Fix bug with Safari binding to comment markers
62 |
63 | ## 0.3.9
64 | - Set the 'this' context of the ready callback to the app
65 | - Add model and params as a property of Page objects passed to routes
66 | - Use chokidar instead of fs.watch
67 |
68 | ## 0.3.8
69 | - Create a component librarary by default in the generated app
70 | - Fix Windows bugs. Windows should now work
71 | - Improvements to file watch reliability in development
72 | - Fix bugs parsing template files and comments
73 |
74 | ## 0.3.7
75 | - Make apps and components into event emitters
76 | - Fix bugs with creating components
77 | - Emit 'create:child', 'create:descendant', 'create:{name}' events for components
78 | - Support passing function names to x-bind via macro template tags
79 | - Fix lots of bugs in components
80 |
81 | ## 0.3.6
82 | - Start to implement components that have associated script files
83 | - Refactoring
84 |
85 | ## 0.3.5
86 | - Fix bug introduced in 0.3.4
87 |
88 | ## 0.3.4
89 | - Bug fixes with macro tags
90 |
91 | ## 0.3.3
92 | - Convert to JS
93 | - Refactor into separate npm modules for routing (tracks), HTML utilities (html-util), and DOM fixes (dom-shim)
94 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | 
2 |
3 | # Derby
4 |
5 | The Derby MVC framework makes it easy to write realtime, collaborative applications that run in both Node.js and browsers.
6 |
7 | Derby includes a powerful data synchronization engine called Racer that automatically syncs data among browsers, servers, and a database. Models subscribe to changes on specific objects, enabling granular control of data propagation without defining channels. Racer supports offline usage and conflict resolution out of the box, which greatly simplifies writing multi-user applications.
8 |
9 | Derby applications load immediately and can be indexed by search engines, because the same templates render on both server and client. In addition, templates define bindings, which instantly update the view when the model changes and vice versa. Derby makes it simple to write applications that load as fast as a search engine, are as interactive as a document editor, and work offline.
10 |
11 | See docs here: **https://derbyjs.github.io/derby/**
12 |
13 | Main site: **https://derbyjs.com/**
14 |
15 | Examples here: **https://github.com/derbyjs/derby-examples**
16 |
17 | ## Running Tests
18 |
19 | Run unit tests:
20 |
21 | ```
22 | npm test
23 | ```
24 |
25 | Run browser tests:
26 |
27 | > This will prompt to open a browser to run front-end tests
28 |
29 | ```
30 | npm run test-browser
31 | ```
32 |
33 | ## Getting in touch
34 |
35 | Create an [issue](https://github.com/derbyjs/derby/issues) or reach out to the derbyjs [team](https://github.com/orgs/derbyjs/people).
36 |
37 | Further resources: **https://derbyjs.com/resources**
38 |
39 | ## MIT License
40 |
41 | Copyright (c) 2011-2021 by Nate Smith
42 |
43 | Permission is hereby granted, free of charge, to any person obtaining a copy
44 | of this software and associated documentation files (the "Software"), to deal
45 | in the Software without restriction, including without limitation the rights
46 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
47 | copies of the Software, and to permit persons to whom the Software is
48 | furnished to do so, subject to the following conditions:
49 |
50 | The above copyright notice and this permission notice shall be included in
51 | all copies or substantial portions of the Software.
52 |
53 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
54 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
55 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
56 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
57 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
58 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
59 | THE SOFTWARE.
60 |
--------------------------------------------------------------------------------
/docs/.gitignore:
--------------------------------------------------------------------------------
1 | _site
2 | .sass-cache
3 | .jekyll-cache
4 | .jekyll-metadata
5 | vendor
6 |
--------------------------------------------------------------------------------
/docs/404.html:
--------------------------------------------------------------------------------
1 | ---
2 | permalink: /404.html
3 | layout: default
4 | ---
5 |
6 |
19 |
20 |
21 |
404
22 |
23 |
Page not found :(
24 |
The requested page could not be found.
25 |
26 |
--------------------------------------------------------------------------------
/docs/Gemfile:
--------------------------------------------------------------------------------
1 | source "https://rubygems.org"
2 | # Hello! This is where you manage which Jekyll version is used to run.
3 | # When you want to use a different version, change it below, save the
4 | # file and run `bundle install`. Run Jekyll with `bundle exec`, like so:
5 | #
6 | # bundle exec jekyll serve
7 | #
8 | # This will help ensure the proper Jekyll version is running.
9 | # Happy Jekylling!
10 | gem "jekyll", "~> 4.3.2"
11 |
12 | # Theme
13 | gem "just-the-docs", "0.7.0"
14 | # gem "minima", "~> 2.5" # This is the default theme for new Jekyll sites.
15 |
16 | # If you want to use GitHub Pages, remove the "gem "jekyll"" above and
17 | # uncomment the line below. To upgrade, run `bundle update github-pages`.
18 | # Latest GH Pages versions are here - https://pages.github.com/versions/
19 | # gem "github-pages", "~> 228", group: :jekyll_plugins
20 | # If you have any plugins, put them here!
21 | group :jekyll_plugins do
22 | gem "jekyll-feed", "~> 0.12"
23 | # gem "jekyll-remote-theme"
24 | end
25 |
26 | # Windows and JRuby does not include zoneinfo files, so bundle the tzinfo-data gem
27 | # and associated library.
28 | platforms :mingw, :x64_mingw, :mswin, :jruby do
29 | gem "tzinfo", ">= 1", "< 3"
30 | gem "tzinfo-data"
31 | end
32 |
33 | # Performance-booster for watching directories on Windows
34 | gem "wdm", "~> 0.1.1", :platforms => [:mingw, :x64_mingw, :mswin]
35 |
36 | # Lock `http_parser.rb` gem to `v0.6.x` on JRuby builds since newer versions of the gem
37 | # do not have a Java counterpart.
38 | gem "http_parser.rb", "~> 0.6.0", :platforms => [:jruby]
39 |
40 | # Lock jekyll-sass-converter to v2.
41 | # This lock can be removed when upgrading from Ruby 2.7 to Ruby 3.
42 | # https://github.com/jekyll/jekyll/pull/9225#issuecomment-1363894633
43 | gem "jekyll-sass-converter", "~> 2.0"
44 |
--------------------------------------------------------------------------------
/docs/Gemfile.lock:
--------------------------------------------------------------------------------
1 | GEM
2 | remote: https://rubygems.org/
3 | specs:
4 | addressable (2.8.6)
5 | public_suffix (>= 2.0.2, < 6.0)
6 | colorator (1.1.0)
7 | concurrent-ruby (1.2.2)
8 | em-websocket (0.5.3)
9 | eventmachine (>= 0.12.9)
10 | http_parser.rb (~> 0)
11 | eventmachine (1.2.7)
12 | ffi (1.16.3)
13 | forwardable-extended (2.6.0)
14 | http_parser.rb (0.8.0)
15 | i18n (1.14.1)
16 | concurrent-ruby (~> 1.0)
17 | jekyll (4.3.2)
18 | addressable (~> 2.4)
19 | colorator (~> 1.0)
20 | em-websocket (~> 0.5)
21 | i18n (~> 1.0)
22 | jekyll-sass-converter (>= 2.0, < 4.0)
23 | jekyll-watch (~> 2.0)
24 | kramdown (~> 2.3, >= 2.3.1)
25 | kramdown-parser-gfm (~> 1.0)
26 | liquid (~> 4.0)
27 | mercenary (>= 0.3.6, < 0.5)
28 | pathutil (~> 0.9)
29 | rouge (>= 3.0, < 5.0)
30 | safe_yaml (~> 1.0)
31 | terminal-table (>= 1.8, < 4.0)
32 | webrick (~> 1.7)
33 | jekyll-feed (0.17.0)
34 | jekyll (>= 3.7, < 5.0)
35 | jekyll-include-cache (0.2.1)
36 | jekyll (>= 3.7, < 5.0)
37 | jekyll-sass-converter (2.2.0)
38 | sassc (> 2.0.1, < 3.0)
39 | jekyll-seo-tag (2.8.0)
40 | jekyll (>= 3.8, < 5.0)
41 | jekyll-watch (2.2.1)
42 | listen (~> 3.0)
43 | just-the-docs (0.7.0)
44 | jekyll (>= 3.8.5)
45 | jekyll-include-cache
46 | jekyll-seo-tag (>= 2.0)
47 | rake (>= 12.3.1)
48 | kramdown (2.4.0)
49 | rexml
50 | kramdown-parser-gfm (1.1.0)
51 | kramdown (~> 2.0)
52 | liquid (4.0.4)
53 | listen (3.8.0)
54 | rb-fsevent (~> 0.10, >= 0.10.3)
55 | rb-inotify (~> 0.9, >= 0.9.10)
56 | mercenary (0.4.0)
57 | pathutil (0.16.2)
58 | forwardable-extended (~> 2.6)
59 | public_suffix (5.0.4)
60 | rake (13.1.0)
61 | rb-fsevent (0.11.2)
62 | rb-inotify (0.10.1)
63 | ffi (~> 1.0)
64 | rexml (3.2.6)
65 | rouge (4.2.0)
66 | safe_yaml (1.0.5)
67 | sassc (2.4.0)
68 | ffi (~> 1.9)
69 | terminal-table (3.0.2)
70 | unicode-display_width (>= 1.1.1, < 3)
71 | unicode-display_width (2.5.0)
72 | webrick (1.8.1)
73 |
74 | PLATFORMS
75 | ruby
76 |
77 | DEPENDENCIES
78 | http_parser.rb (~> 0.6.0)
79 | jekyll (~> 4.3.2)
80 | jekyll-feed (~> 0.12)
81 | jekyll-sass-converter (~> 2.0)
82 | just-the-docs (= 0.7.0)
83 | tzinfo (>= 1, < 3)
84 | tzinfo-data
85 | wdm (~> 0.1.1)
86 |
87 | BUNDLED WITH
88 | 2.1.4
89 |
--------------------------------------------------------------------------------
/docs/README.md:
--------------------------------------------------------------------------------
1 | These docs are built with Jekyll using the [Just the Docs theme](https://just-the-docs.com/).
2 |
3 | They are served on GitHub Pages at [https://derbyjs.github.io/derby/](https://derbyjs.github.io/derby/).
4 |
5 | # Locally building and viewing docs
6 |
7 | Jekyll has a dev server, which will auto-build the docs upon any changes to the docs' source files. Only changes to `_config.yml` require a dev server restart.
8 |
9 | ## With local Ruby
10 |
11 | Setup:
12 |
13 | ```
14 | cd docs && bundle install
15 | ```
16 |
17 | Run the dev server:
18 |
19 | ```
20 | bundle exec jekyll serve
21 | ```
22 |
23 | The site is viewable at `http://localhost:4000/derby/`.
24 |
25 | ## With Ruby in Docker container
26 |
27 | One-time container creation, with current directory pointing at this repo's root:
28 |
29 | ```
30 | docker run --name derby-docs-ruby -v "$(pwd)/docs:/derby-docs" -p 127.0.0.1:4000:4000 ruby:2.7 bash -c 'cd derby-docs && bundle install && bundle exec jekyll serve -H 0.0.0.0 -P 4000 --trace'
31 | ```
32 |
33 | Subsequently, to run the dev server:
34 |
35 | ```
36 | docker start -i derby-docs-ruby
37 | ```
38 |
39 | Either way, the site is viewable at `http://localhost:4000/derby/`.
40 |
41 | Explanation of flags:
42 | * `-v` - Set up a Docker bind mount, mapping the host's `$PWD/docs` directory to a container directory `/derby-docs`.
43 | * `-p` - Map the host's local port 4000 to the container's port 4000, to allow the dev server inside the container to serve requests issued against the host.
44 | * `-H 0.0.0.0 -P 4000` - Have the dev server listen to connections from outside the container. This won't allow connections from outside the host.
45 |
46 | To recreate the container with a different command or setup, run `docker rm derby-docs-ruby` to delete the container first.
47 |
--------------------------------------------------------------------------------
/docs/_config.yml:
--------------------------------------------------------------------------------
1 | # Welcome to Jekyll!
2 | #
3 | # This config file is meant for settings that affect your whole blog, values
4 | # which you are expected to set up once and rarely edit after that. If you find
5 | # yourself editing this file very often, consider using Jekyll's data files
6 | # feature for the data you need to update frequently.
7 | #
8 | # For technical reasons, this file is *NOT* reloaded automatically when you use
9 | # 'bundle exec jekyll serve'. If you change this file, please restart the server process.
10 | #
11 | # If you need help with YAML syntax, here are some quick references for you:
12 | # https://learn-the-web.algonquindesign.ca/topics/markdown-yaml-cheat-sheet/#yaml
13 | # https://learnxinyminutes.com/docs/yaml/
14 | #
15 | # Site settings
16 | # These are used to personalize your new site. If you look in the HTML files,
17 | # you will see them accessed via {{ site.title }}, {{ site.email }}, and so on.
18 | # You can create any custom variable you would like, and they will be accessible
19 | # in the templates via {{ site.myvariable }}.
20 | title: DerbyJS Docs
21 | baseurl: "/derby" # the subpath of your site, e.g. /blog
22 | url: "" # the base hostname & protocol for your site, e.g. http://example.com
23 | repository: derbyjs/derby
24 |
25 | aux_links:
26 | "DerbyJS on GitHub":
27 | - "//github.com/derbyjs/derby"
28 |
29 | # Footer "Edit this page on GitHub" link text
30 | gh_edit_link: true # show or hide edit this page link
31 | gh_edit_link_text: "Edit this page on GitHub"
32 | gh_edit_repository: "https://github.com/derbyjs/derby" # the github URL for your repo
33 | gh_edit_branch: "master" # the branch that your docs is served from
34 | gh_edit_source: "docs" # the source that your files originate from
35 | gh_edit_view_mode: "tree" # "tree" or "edit" if you want the user to jump into the editor immediately
36 |
37 | # Build settings
38 | markdown: kramdown
39 | # remote_theme: just-the-docs/just-the-docs
40 | theme: just-the-docs
41 | color_scheme: derby-light # just-the-docs theme customization
42 | permalink: /:path/:name
43 | keep_files: [api]
44 |
45 | # just-the-docs customization
46 | callouts:
47 | warning-red:
48 | title: Warning
49 | color: red
50 | warning-yellow:
51 | title: Warning
52 | color: yellow
53 |
54 | # Front matter defaults
55 | defaults:
56 | -
57 | scope:
58 | path: "" # an empty string here means all files in the project
59 | type: "pages"
60 | values:
61 | render_with_liquid: false
62 | -
63 | scope:
64 | path: "assets" # an empty string here means all files in the project
65 | values:
66 | render_with_liquid: true
67 |
68 | nav_external_links:
69 | - title: Derby API
70 | url: /api
71 | opens_in_new_tab: true
72 | - title: Racer API
73 | url: https://derbyjs.github.io/racer
74 | opens_in_new_tab: true
75 |
76 | # Exclude from processing.
77 | # The following items will not be processed, by default.
78 | # Any item listed under the `exclude:` key here will be automatically added to
79 | # the internal "default list".
80 | #
81 | # Excluded items can be processed by explicitly listing the directories or
82 | # their entries' file path in the `include:` list.
83 | #
84 | # exclude:
85 | # - .sass-cache/
86 | # - .jekyll-cache/
87 | # - gemfiles/
88 | # - Gemfile
89 | # - Gemfile.lock
90 | # - node_modules/
91 | # - vendor/bundle/
92 | # - vendor/cache/
93 | # - vendor/gems/
94 | # - vendor/ruby/
95 |
--------------------------------------------------------------------------------
/docs/_sass/color_schemes/derby-light.scss:
--------------------------------------------------------------------------------
1 | $body-background-color: #fffcfa;
2 | $link-color: #107f7d;
3 | $sidebar-color: #fdf7f4;
4 |
5 | .main a:not([class]) {
6 | text-decoration-color: #c6e6e6;
7 | }
8 |
--------------------------------------------------------------------------------
/docs/_sass/custom.scss:
--------------------------------------------------------------------------------
1 | // TODO - This default custom scss file appears to be unused by just-the-docs.
2 | // Custom styles are actually defined in ./custom/custom.scss instead.
3 | // Can this safely be removed?
4 |
5 | /**
6 | * Add call-out support: https://github.com/pmarsceill/just-the-docs/issues/171#issuecomment-538794741
7 | */
8 | $callouts: (
9 | info: ($blue-300, rgba($blue-000, .2), 'Note'),
10 | warn: ($yellow-300, rgba($yellow-000, .2), 'Note'),
11 | danger: ($red-300, rgba($red-000, .2), 'Note')
12 | );
13 |
14 | @each $class, $props in $callouts {
15 | .#{$class} {
16 | background: nth($props, 2);
17 | border-left: $border-radius solid nth($props, 1);
18 | border-radius: $border-radius;
19 | box-shadow: 0 1px 2px rgba(0, 0, 0, 0.12), 0 3px 10px rgba(0, 0, 0, 0.08);
20 | padding: .8rem;
21 |
22 | &::before {
23 | color: nth($props, 1);
24 | content: nth($props, 3);
25 | display: block;
26 | font-weight: bold;
27 | font-size: .75em;
28 | padding-bottom: .125rem;
29 | }
30 |
31 | br {
32 | content: '';
33 | display: block;
34 | margin-top: .5rem;
35 | }
36 | }
37 | }
38 |
39 | .label-grey {
40 | background: rgba($grey-dk-000, 1);
41 | }
42 |
--------------------------------------------------------------------------------
/docs/_sass/custom/custom.scss:
--------------------------------------------------------------------------------
1 | // Derby template examples use "jinja" as the language with some style customizations,
2 | // as Derby's template syntax doesn't fully match that of Handlebars. Jinja formatting
3 | // with some style tweaks looks better than Handlebars, so we use Jinja.
4 | .language-jinja {
5 | .highlight {
6 | // Override Name.Attribute and Name.Builtin to use normal text color
7 | // instead of being orange.
8 | .na, .nv {
9 | color: inherit;
10 | }
11 |
12 | // Jinja formatting treats double-curlies as italicized Comment.Preproc.
13 | // Override to look like Handlebars formatting for double curlies.
14 | .cp {
15 | // Color from just-the-docs OneLightJekyll keyword color
16 | color: #a625a4;
17 | font-style: inherit;
18 | }
19 |
20 | // Disable syntax error styling for Derby template syntax that
21 | // doesn't strictly match Jinja/Handlebars.
22 | .err {
23 | color: inherit;
24 | background-color: inherit;
25 | }
26 | }
27 | }
28 |
29 | h3 > code {
30 | // fix font size of code block in heading
31 | font-size: 1.0rem;
32 | }
33 |
--------------------------------------------------------------------------------
/docs/apps.md:
--------------------------------------------------------------------------------
1 | ---
2 | layout: default
3 | title: Apps
4 | ---
5 |
6 | # Derby Apps
7 |
8 | Derby projects support one or more single-page apps.
9 | Apps have a full MVC structure, including a model provided by
10 | [Racer](https://github.com/derbyjs/racer), a template and styles based view, and controller
11 | code with application logic and routes (which map URLs to actions).
12 |
13 | On the server, apps provide a router middleware for Express. One or more app
14 | routers as well as server only routes can be included in the same Express
15 | server.
16 |
17 | Derby packages up all of an app's templates, routes, and application code when
18 | rendering. Regardless of which app URL the browser requests initially, the app
19 | is able to render any other state within the same application client-side. If
20 | the app cannot handle a URL, it will fall through and request from the server.
21 | Errors thrown during route handling also cause requests to fall through to the
22 | server.
23 |
24 | Derby works great with only a single app, though developers may wish to create
25 | separate apps if only certain sets of pages are likely to be used together. For
26 | example, a project might have a separate desktop web app and mobile web app. Or
27 | a project might have an internal administration panel app and a public content
28 | app.
29 |
30 |
31 | ## Creating apps
32 |
33 | Apps are created in the file that defines the app's controller code. They are
34 | then associated with a server by requiring the app within the server file.
35 |
36 | > `app = derby.createApp ( name, fileName )`
37 | >
38 | > * `name`: the name of the app
39 | > * `fileName`: the name of the file, typically node's default __filename is used.
40 | >
41 | > * `app`: Returns an app object, typically exported as `module.exports = app`
42 |
43 |
44 | App names are used to automatically associate an app with template and styles files of the same
45 | name.
46 |
47 | The `createApp` method adds a number of methods to the app. On both the client
48 | and the server, these are `use`, `get`, `post`, `put`, `del`,
49 | and `ready`. On the server only, Derby also adds `router`,
50 | for use with Express.
51 |
52 | ## Connecting servers to apps
53 |
54 | Because Derby shares most code between server and client, Derby server files
55 | can be very minimal.
56 |
57 | The server includes an app with a standard Node.js require statement. It can
58 | then use the `app.router()` method to create a router middleware for Express
59 | that handles all of the app's routes.
60 |
61 | The server also needs to create a `backend` object, which is what creates models,
62 | coordinates data syncing, and interfaces with databases. Stores are created via
63 | the `derby.createBackend()` method. See [Backends](models/backends).
64 |
65 | > A typical setup can be seen in the [derby-starter](https://github.com/derbyjs/derby-starter/blob/master/lib/server.js) project, which is a node module for getting started with Derby.
66 | >
67 | > The [derby-examples](https://github.com/derbyjs/derby-examples) make use of derby-starter to setup their apps.
68 |
--------------------------------------------------------------------------------
/docs/components.md:
--------------------------------------------------------------------------------
1 | ---
2 | layout: default
3 | title: Components
4 | has_children: true
5 | ---
6 |
7 | # Overview
8 |
9 | Components are the building blocks of Derby applications. A component is a view associated with a controller class. The [view](../views) is implemented as a Derby template and the controller is implemented as a JavaScript [class](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Classes) or [constructor function](https://developer.mozilla.org/en-US/docs/Learn/JavaScript/Objects/Object-oriented_JS). Derby creates an instance of the controller class each time it renders the component view.
10 |
11 |
12 | ## Reuse and organization
13 |
14 | Components are reusable UI pieces, similar to custom HTML elements. In addition, they are the recommended way to structure complex applications as modular parts with clear inputs and outputs. Each significant unit of UI functionality should be its own component.
15 |
16 | Components can be rendered on the server and the client, so the same code can produce static HTML, server-rendered dynamic applications, and client-rendered applications.
17 |
18 |
19 | ## Encapsulation
20 |
21 | Each component has a scoped model in its own namespace. Data or references to the component's parent are passed in via view attributes. If you're familiar with it, this structure is similar to the [Model-View-ViewModel (MVVM) pattern](https://en.wikipedia.org/wiki/Model%E2%80%93view%E2%80%93viewmodel)—a component's scoped model is a ViewModel.
22 |
23 |
24 | ## Tabs Example
25 |
26 | ### index.html
27 | ```jinja
28 |
29 |
30 |
31 |
151 |
152 |
153 |
156 | ```
157 |
158 | ```js
159 | app.component('todos-new', class TodosNew {
160 | submit() {
161 | const value = this.model.del('value');
162 | this.emit('submit', value);
163 | }
164 | });
165 |
166 | app.component('todos-list', class TodosList {
167 | add(text) {
168 | if (!text) return;
169 | this.model.push('items', {text});
170 | }
171 | remove(index) {
172 | this.model.remove('items', index);
173 | }
174 | });
175 |
176 | app.component('todos-footer', class TodosFooter {
177 | static singleton = true;
178 | remaining(items) {
179 | if (!items) return 0;
180 | return items.filter(item => !item.done).length;
181 | }
182 | });
183 | ```
184 |
185 |
186 |
187 |
188 |
--------------------------------------------------------------------------------
/docs/components/charts-debug.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/derbyjs/derby/9968348205560323c592aa3f81e38ff8ffdc62e8/docs/components/charts-debug.png
--------------------------------------------------------------------------------
/docs/components/events.md:
--------------------------------------------------------------------------------
1 | ---
2 | layout: default
3 | title: Events
4 | parent: Components
5 | ---
6 |
7 | # Events
8 |
9 | Functions defined on a property of a controller can be invoked from view expressions or in response to events. As a general pattern, view paths refer to the model when getting values and to the controller when calling functions.
10 |
11 | Functions are looked up on the current component's controller, the page, and the global, in that order. See the [view functions and events](../views/template-syntax/functions-and-events#controller-property-lookup) documentation for more detail.
12 |
13 | ## Lifecycle events
14 |
15 | Default events are triggered during the lifecycle of a component:
16 |
17 | * `init`: Emitted before the component's `init()` function is called.
18 | * `create`: Emitted before the component's `create()` function is called.
19 | * `destroy`: Emitted before the component's `destroy()` function is called.
20 |
21 | If the functions to be called aren't defined on the component, their respective events are still triggered unconditionally.
22 |
23 | ## Custom events
24 |
25 | Components support custom events. Dashes are transformed into camelCase.
26 | ```jinja
27 |
28 | ```
29 | ```js
30 | // Equivalent to:
31 | modal.on('close', function() {
32 | self.reset();
33 | });
34 | modal.on('fullView', function() {
35 | back.fade();
36 | });
37 | ```
38 |
39 | ## Emitting events
40 | Components can emit custom events to be handled by their parents.
41 |
42 | ```jinja
43 |
44 |
45 | ```
46 |
47 | ```js
48 | //listen
49 | modal.on('fullView', function(foo) {
50 | console.log(foo);
51 | })
52 | //...
53 | //emit
54 | modal.emit("fullView", foo);
55 | ```
56 |
57 |
58 | ## Calling peer component methods
59 |
60 | Components and elements can be set as a property on the current controller with the `as=` HTML attribute ([more detail](../views/template-syntax/view-attributes#as-attribute)). This paired with how controller properties are looked up on function calls makes it easy to connect events on components or elements to methods on other components.
61 |
62 | ```jinja
63 |
64 |
65 |
66 | ```
67 |
68 | ```jinja
69 |
70 |
71 | ...
72 |
73 | ```
74 |
75 | ## Component event arguments
76 |
77 | Component events implicitly pass through any emitted arguments. These arguments are added after any explicitly specified arguments in the expression.
78 |
79 | ```jinja
80 |
81 |
82 |
83 |
84 | ```
85 |
--------------------------------------------------------------------------------
/docs/components/scope.md:
--------------------------------------------------------------------------------
1 | ---
2 | layout: default
3 | title: Scope
4 | parent: Components
5 | ---
6 |
7 | # Scope
8 |
9 | Each component instance has its own scoped model, providing it isolation from model data for other components and remote collection data.
10 |
11 | ## Attributes and data bindings
12 |
13 | The most direct way to get data into a component is to pass in a reference or a literal as a view attribute.
14 |
15 | ```jinja
16 |
17 |
18 |
19 |
20 |
21 | {{each data as #user}}
22 |
{{#user.name}}
23 | {{/each}}
24 |
25 | {{num + 10}}
26 | ```
27 |
28 | See [view attributes](../views/template-syntax/view-attributes) for more information.
29 |
30 |
31 | ## Root model
32 |
33 | There are times when accessing data in the root model is desirable from within the component. This can be achieved both in the template and in the controller.
34 |
35 | ```jinja
36 |
37 |
38 | {{#root.users[userId]}}
39 | ```
40 |
41 | ```js
42 | var users = this.model.root.get("users");
43 | var user = users[userId];
44 | // or
45 | var $users = this.model.scope("users");
46 | var user = $users.get(userId);
47 | ```
48 |
49 |
50 | ### With block
51 | See the documentation for [with blocks](../views/template-syntax/blocks#with) to pass in data with an alias.
52 |
53 |
--------------------------------------------------------------------------------
/docs/components/view-partials.md:
--------------------------------------------------------------------------------
1 | ---
2 | layout: default
3 | title: View partials
4 | parent: Components
5 | ---
6 |
7 | # View partials
8 |
9 | This page goes into more detail about how view partials relate to components. For more general concepts, see the [template syntax](../views/template-syntax) documentation.
10 |
11 | While a component's controller is associated with a single view, it can contain sub-views defined as view partials. Components can also accept other views passed in as attributes.
12 |
13 | ## Scope
14 | By default a view partial inherits the scope where it is instantiated.
15 |
16 | ```jinja
17 |
18 | {{foo}}
19 | {{with #root.bar as #bar}}
20 |
21 | {{/with}}
22 |
23 |
24 | i can render {{foo}} and {{#bar}}
25 | ```
26 | A view partial associated with a component follows the [component scope](scope) rules. A view partial used inside a component will inherit the scope of the component.
27 |
28 | ### extend
29 | It is possible to override another component's functionality while preserving its view. You can do this with the `extend` keyword.
30 |
31 |
32 |
33 |
34 |
35 |
36 | ### import
37 | If you just want to reuse a view partial the `import` keyword is probably more appropriate. See the [namespaces and files](../views/namespaces-and-files#structuring-views-in-multiple-files) documentation for more details.
38 |
39 |
40 | ## Component tracking
41 | Derby components are tracked in the DOM with an HTML comment tag. This allows components to be responsible for arbitrary DOM content, for example two table rows that otherwise cannot be wrapped by any other DOM elements.
42 |
43 | ```jinja
44 |
45 | ```
46 |
47 | ## Debugging
48 |
49 | A relatively quick way to inspect a component for debugging is to find its comment in the browser's DOM inspector.
50 | In modern browsers clicking on the comment allows you to reference it in the console with `$0`.
51 | Once you have a reference to the comment tag, you can access it's controller with `$0.$component` and its model data with `$0.$component.model.get()`
52 |
53 |
54 |
55 | ### derby-debug
56 | There is a plugin which makes accessing your components from the console even more accessible that is recommended for development.
57 | Read more about [derby-debug](https://github.com/derbyjs/derby-debug).
58 |
--------------------------------------------------------------------------------
/docs/guides.md:
--------------------------------------------------------------------------------
1 | ---
2 | layout: default
3 | title: Guides
4 | has_children: true
5 | ---
6 |
--------------------------------------------------------------------------------
/docs/guides/troubleshooting.md:
--------------------------------------------------------------------------------
1 | ---
2 | layout: default
3 | title: Troubleshooting
4 | parent: Guides
5 | ---
6 |
7 | # Troubleshooting
8 |
9 | This guide covers common issues that you may run into as you use Derby. Feel free to contribute your own troubleshooting experience! :)
10 |
11 | ## Attaching bindings failed, because HTML structure does not match client rendering
12 |
13 | When the page is loaded on the browser, the following error might be thrown in the console:
14 |
15 | ```
16 | Attaching bindings failed, because HTML structure does not match client rendering
17 | ```
18 |
19 | ... along with the problematic view that is causing this issue. It can be tricky to understand what caused the error if you touched a bunch of files at the same time (JS, HTML, CSS) and being unsure what caused the problem in the first place. Turns out, it's always about the HTML structure.
20 |
21 | When the page is rendered server side and is sent down to the client, Derby it will ensure that both HTML structures are exactly the same before attaching. If they don't match that is usually because the browser's parser attempts to gracefully handle invalid HTML that you may have introduced by mistake. For example, the following syntax is valid XML syntax but invalid HTML:
22 |
23 | ```jinja
24 |
25 |
26 |
27 |
28 | ```
29 |
30 | Browsers will effectively turn this into:
31 |
32 | ```jinja
33 |
34 |
35 | ```
36 |
37 | ... according to the HTML rules set by W3:
38 |
39 | > The P element represents a paragraph. It cannot contain block-level elements (including P itself). We discourage authors from using empty P elements. User agents should ignore empty P elements.
40 |
41 | source: https://www.w3.org/TR/html401/struct/text.html#edef-P
42 |
43 | The same goes for HTML tables where:
44 |
45 | ```jinja
46 |
47 |
48 |
49 | ```
50 |
51 | ... may be rendered by a browser as:
52 |
53 | ```jinja
54 |
55 |
56 |
57 |
58 |
59 |
60 |
61 | ```
62 |
63 | There are many other ways where parsers will try to "fix" invalid HTML and cause Derby to fail attaching.
64 |
65 | Here are a few common possibilities:
66 | * invalid HTML (as illustrated above)
67 | * sorting lists on in `init()` might cause the output to be non-deterministic (like alphabetizing / capitalization). Basically a data "bug" would end-up generated different HTML.
68 | * putting links in links, which has undefined behavior in HTML
69 | * inserting a conditional `
` such as `{{if thisIsTrue}}
stuff
{{/if}}` without restarting the server
70 |
71 | ## Error when attempting to use local model paths in singleton components
72 |
73 | A [singleton component](../components/lifecycle#singleton-stateless-components) does not have a local model, so trying to use a local model path like `{{value}}` in its view will fail with this error:
74 |
75 | ```
76 | TypeError: Cannot read properties of undefined (reading 'data')
77 | at PathExpression.get
78 | ...
79 | ```
80 |
81 | To resolve the issue, bind the data via an attribute and refer to it with an attribute path `{{@value}}`. See the linked singleton component documentation for an example.
82 |
83 | Alternatively, if you don't need component controller functions, switch to using a plain [view partial](../components/view-partials) instead.
84 |
85 | ## Mutation on uncreated remote document
86 |
87 | To perform mutations on a DB-backed document, it must first be loaded in the model. If not, an error `Error: Mutation on uncreated remote document` will be thrown.
88 |
89 | There are a few ways to load a document into the model:
90 | - [Fetching or subscribing to the document](../models/backends#loading-data-into-a-model), either directly via doc id or via a query
91 | - Creating a new document, e.g. via `model.add()`
92 |
93 | When a document is loaded with a [projection](https://share.github.io/sharedb/projections), the mutation must be done using the same projection name.
94 | - For example, if a doc was loaded only with a projection name `model.fetch('notes_title.note-12')`, then mutations must be done with the projection name, `model.set('notes_title.note-12.title', 'Intro')`.
95 | - Trying to mutate using the base collection name in that case, `model.set('notes.note-12.title')`, will result in the "Mutation on uncreated remote document" error.
96 | - If a doc is loaded both with the base collection name and with projections, then mutations can be done with any collection or projection name the doc was loaded with.
97 |
98 | ## Invalid op submitted. Operation invalid in projected collection
99 |
100 | Make sure the field being mutated is one of the fields defined in the [projection](https://share.github.io/sharedb/projections).
101 |
102 | If that's not feasible, then fetch/subscribe the doc using its base collection name and do the mutation using the base collection.
103 |
--------------------------------------------------------------------------------
/docs/index.md:
--------------------------------------------------------------------------------
1 | ---
2 | layout: default
3 | title: Introduction
4 | nav_order: 1
5 | ---
6 |
7 | DerbyJS is a full-stack framework for writing modern web applications.
8 |
9 | # Realtime collaboration
10 |
11 | Effortlessly sync data across clients and servers with automatic conflict resolution powered by ShareDB's operational transformation of JSON and text.
12 |
13 | # Server and client rendering
14 |
15 | Templates can be rendered in the browser and on the server. In a browser, DerbyJS renders with fast, native DOM methods.
16 |
17 | On the server, no DOM or virtual DOM implementation is needed — the same templates return HTML as well! HTML rendering means faster page loads, full search engine support, and ability to use the same templates for all types of HTML output, such as emails.
18 |
--------------------------------------------------------------------------------
/docs/models.md:
--------------------------------------------------------------------------------
1 | ---
2 | layout: default
3 | title: Models
4 | has_children: true
5 | ---
6 |
7 | # Models
8 |
9 | DerbyJS's models are provided by [Racer](https://github.com/derbyjs/racer), a realtime model synchronization engine built for Derby. By building on ShareDB, Racer enables multiple users and services to interact with the same data objects with realtime conflict resolution.
10 |
11 | Racer models can also be used without Derby, such as in backend code to interact with ShareDB-based data, or even in frontend code with another UI framework.
12 |
13 | A model's data can be thought of as a JSON object tree - see the [Paths documentation](models/paths) for details.
14 |
15 | Racer models provide functionality useful for writing real-time application logic:
16 | - Methods to [load data into the model](backends#loading-data-into-a-model), including via [database queries](queries)
17 | - Null-safe [getter methods](getters) and [mutator (setter) methods](mutators)
18 | - [Reactive functions](reactive-functions) for automatically producing output data whenever any input data changes
19 | - Built-in reactive [filter and sort](filters-sorts) functions
20 | - [Data change events](events) for more complex situations not covered by pure reactive functions
21 |
22 | ## Racer and ShareDB
23 |
24 | Racer provides a single interface for working with local data stored in memory and remote data synced via ShareDB. It works equally well on the server and the browser, and it is designed to be used independently from DerbyJS. This is useful when writing migrations, data-only services, or integrating with different view libraries.
25 |
26 | Remotely synced data is stored via [ShareDB](https://github.com/share/sharedb), which means that different clients can modify the same data at the same time. ShareDB uses [Operational Transformation (OT)](https://en.wikipedia.org/wiki/Operational_transformation) to automatically resolve conflicts in realtime or offline.
27 |
28 | On the server, Racer provides a `backend` that extends the [ShareDB Backend](https://share.github.io/sharedb/api/backend). It configures a connection to a database and pub/sub adapter. Every backend connected to the same database and pub/sub system is synchronized in realtime.
29 |
30 | Backends create `model` objects. Models have a synchronous interface similar to interacting directly with objects. They maintain their own copy of a subset of the global state. This subset is defined via [subscriptions](backends#loading-data-into-a-model) to certain queries or documents. Models perform operations independently, and they automatically synchronize their state.
31 |
32 | Models emit events when their contents are updated, which DerbyJS uses to update the view in realtime.
33 |
34 | ## Creating models
35 |
36 | On the server, `backend.modelMiddleware()` provides an Express-compatible middleware function that, when run during request handling, creates a new empty model for the request and attaches it to `req.model`. Custom middleware can be added between the `modelMiddleware()` and the application routes to customize the model's data.
37 |
38 | When the server runs an application route, it uses `req.model` to render the page. The model state is serialized into the server-side rendered page, and then in the browser, the model is reinitialized into the same state. This model object is passed to app routes rendered on the client.
39 |
40 | ```js
41 | // Middleware to add req.model on each request
42 | expressApp.use(backend.modelMiddleware());
43 |
44 | // Subsequent middleware can use the model
45 | expressApp.use((req, res, next) => {
46 | req.model.set('_session.userId', 'test-user');
47 | next();
48 | });
49 |
50 | // Derby application routes use req.model for rendering
51 | expressApp.use(derbyApp.router());
52 | ```
53 |
54 | If you would like to get or set data on the server outside of the context of a request, you can create models directly via `backend.createModel()`.
55 |
56 | > `model = backend.createModel(options)`
57 | > * `options:`
58 | > * `fetchOnly` Set to true to make model.subscribe calls perform a fetch instead
59 | > * `model` Returns a model instance associated with the given backend
60 |
61 | ## Closing models
62 |
63 | Models created by `modelMiddleware()` are automatically closed when the Express request ends.
64 |
65 | To close a manually-created model, you can use `model.close()`. The `close()` method will wait for all pending operations to finish before closing the model.
66 |
67 | > `backend.close([callback])`
68 | > * `callback` - `() => void` - Optional callback, called once the model has finished closing.
69 |
70 | > `closePromise = backend.closePromised()`
71 | > * Returns a `Promise` that resolves when the model has finished closing. The promise will never be rejected.
72 |
73 | ## Backend
74 |
75 | Typically, a project will have only one backend, even if it has multiple apps. It is possible to have multiple backends, but a model can be associated with only a single backend, and a page can have only a single model.
76 |
77 | > `backend = derby.createBackend(options)`
78 | > `backend = racer.createBackend(options)`
79 | > * `options` See the [Backends](backends) section for information on configuration
80 | > * `backend` Returns a Racer backend instance
81 |
82 | ### Methods
83 |
84 | > `middleware = backend.modelMiddleware()`
85 | > * `middleware` Returns an Express-compatible middleware function
86 |
87 | The model middleware creates a new model for each request and adds a `req.model` reference to that model. It also closes this model automatically at the end of the request.
88 |
89 | Models created by `modelMiddleware()` use the `{fetchOnly: true}` option. That means during server-side rendering, `model.subscribe()` doesn't actually register with the pub/sub system, which is more efficient since the model is only open for the short lifetime of the request. It's still tracked as a subscription so that when the model is re-initialized in the browser, the browser can register the actual subscriptions.
90 |
--------------------------------------------------------------------------------
/docs/models/backends.md:
--------------------------------------------------------------------------------
1 | ---
2 | layout: default
3 | title: Backends and data loading
4 | parent: Models
5 | has_children: true
6 | ---
7 |
8 | # Backends
9 |
10 | Racer stores are backed by ShareDB, which is used to persist data, perform queries, keep a journal of all operations, and pub/sub operations and changes to queries. Currently, ShareDB has [two pub/sub adapters](https://share.github.io/sharedb/adapters/pub-sub): one for in memory and one for Redis based pub/sub. ShareDB supports in memory or MongoDB storage. The database adapter [ShareDBMongo](https://github.com/share/sharedb-mongo)
11 | is backed by a real Mongo database and full query support. ShareDB is written with support for additional database adapters in mind.
12 |
13 | Getting started with a single-process server and MongoDB:
14 |
15 | ```js
16 | var derby = require('derby');
17 | var ShareDbMongo = require('sharedb-mongo');
18 |
19 | var db = new ShareDbMongo('mongodb://localhost:27017/test');
20 | var backend = derby.createBackend({db: db});
21 | var model = backend.createModel();
22 | ```
23 |
24 | The above examples use the in-process driver by default. In a production environment, you'll want to scale across multiple frontend servers and support updating data in other processes, such as migration scripts and additional services. For this, you should use the [ShareDB Redis pub/sub adapter](https://github.com/share/sharedb-redis-pubsub). ShareDB requires Redis 2.6 or newer, since it uses Lua scripting commands.
25 |
26 | ```js
27 | var derby = require('derby');
28 | var ShareDbMongo = require('sharedb-mongo');
29 | var RedisPubSub = require('sharedb-redis-pubsub');
30 |
31 | var db = new ShareDbMongo('mongodb://localhost:27017/test');
32 | var backend = derby.createBackend({
33 | db: db,
34 | pubsub: new RedisPubSub()
35 | });
36 | var model = backend.createModel();
37 | ```
38 |
39 | See [ShareDBMongo](https://github.com/share/sharedb-mongo) and [ShareDB Redis](https://github.com/share/sharedb-redis-pubsub) documentation for more information on configuration options.
40 |
41 | The Redis driver supports flushing all data from Redis or starting with an empty Redis database with journal and snapshot data in MongoDB. Thus, it is OK to start with a basic deployment using only a single process and add Redis later or to flush the Redis database if it becomes corrupt.
42 |
43 | ## Mapping between database and model
44 |
45 | Racer paths are translated into database collections and documents using a natural mapping:
46 |
47 | ```bash
48 | collection.documentId.documentProperty
49 | ```
50 |
51 | ShareDB Mongo will add the following properties to Mongo documents for internal use:
52 | * `_m.ctime` - Timestamp when the ShareDB document was created
53 | * `_m.mtime` - Timestamp when the ShareDB document was last modified
54 | * `_type` - [OT type](https://share.github.io/sharedb/types/)
55 | * `_v` - [Snapshot version](https://share.github.io/sharedb/api/snapshot)
56 |
57 | In addition to `ctime` and `mtime`, custom metadata properties can be added to `_m` with middleware that modifies `snapshot.m` in apply or commit.
58 |
59 | Since these underscore-prefixed properties are for ShareDB's internal use, ShareDB Mongo will strip out these properties (`_m`, `_type`, and `_v`) as well as `_id` when it returns the document from Mongo. The `_id` is removed because Racer adds an `id` alias to all local documents. This alias references the `_id` property of the original Mongo document.
60 |
61 | If a document is an object, it will be stored as the Mongo document directly. For example,
62 |
63 | ```js
64 | {
65 | make: "Ford",
66 | model: "Mustang",
67 | year: 1969,
68 | _m: {
69 | ctime: 1494381632731,
70 | mtime: 1494381635994
71 | },
72 | _type: "http://sharejs.org/types/JSONv0",
73 | _v: 12
74 | }
75 | ```
76 |
77 | If it is another type (e.g. [Plaintext OT Type](https://github.com/ottypes/text)), the value will be nested under a property on the Mongo document called `_data`.
78 |
79 | ```js
80 | {
81 | _data: "This is a text message.",
82 | _m: {
83 | ctime: 1494381632731,
84 | mtime: 1494381635994
85 | },
86 | _type: "http://sharejs.org/types/text",
87 | _v: 12
88 | }
89 | ```
90 |
91 | It is not possible to set or delete an entire collection, or get the list of collections via the Racer API.
92 |
93 | ## Loading data into a model
94 |
95 | The `subscribe`, `fetch`, `unsubscribe`, and `unfetch` methods are used to load and unload data from the database. These methods don't return data directly. Rather, they load the data into a model. Once loaded, the data are then accessed via model getter methods.
96 |
97 | `subscribe` and `fetch` both load the requested data into the model. Subscribe also registers with pub/sub, automatically applying remote updates to the locally loaded data.
98 |
99 | > `model.subscribe(items, callback)`
100 | > `model.fetch(items, callback)`
101 | > `model.unsubscribe(items, callback)`
102 | > `model.unfetch(items, callback)`
103 | > * `items` - `string | ChildModel | Query | Array` - Specifier(s) for one or more loadable items, such as a document path, scoped model, or query
104 | > * `callback` - `(error?: Error) => void` - Calls back once all of the data for each item has been loaded or when an error is encountered
105 |
106 | There are also promise-based versions of the methods, available since racer@1.1.0.
107 |
108 | > `model.subscribePromised(items)`
109 | > `model.fetchPromised(items)`
110 | > `model.unsubscribePromised(items)`
111 | > `model.unfetchPromised(items)`
112 | > * These each return a `Promise` that is resolved when the requested item(s) are loaded or rejected on errors.
113 |
114 | Avoid subscribing or fetching queries by document id like `model.query('users', {_id: xxx})`. You can achieve the same result passing `'users.xxx'` or `model.at('users.xxx')` to subscribe or fetch, and it is much more efficient.
115 |
116 | If you only have one argument in your call to subscribe or fetch, you can also call `subscribe`, `fetch`, `unsubscribe`, and `unfetch` on the query or scoped model directly.
117 |
118 | ```js
119 | // Subscribing to a single document with a path string.
120 | const userPath = `users.${userId}`;
121 | model.subscribe(userPath, (error) => {
122 | if (err) return next(err);
123 | console.log(model.get(userPath));
124 | });
125 | // Subscribing to two things at once: a document via child model and a query.
126 | const userModel = model.at(userPath);
127 | const todosQuery = model.query('todos', {creatorId: userId});
128 | model.subscribe([userModel, todosQuery], function(err) {
129 | if (err) return next(err);
130 | console.log(userModel.get(), todosQuery.get());
131 | });
132 |
133 | // Promise-based API
134 | model.subscribePromised(userPath).then(
135 | () => {
136 | console.log(model.get(userPath));
137 | },
138 | (error) => { next(error); }
139 | );
140 | // Promise-based API with async/await
141 | try {
142 | await model.subscribePromised([userModel, todosQuery]);
143 | console.log(userModel.get(), todosQuery.get());
144 | } catch (error) {
145 | // Handle subscribe error
146 | }
147 | ```
148 |
149 | Racer internally keeps track of the context in which you call subscribe or fetch, and it counts the number of times that each item is subscribed or fetched. To actually unload a document from the model, you must call the unsubscribe method the same number of times that subscribe is called and the unfetch method the same number of times that fetch is called.
150 |
151 | However, you generally don't need to worry about calling unsubscribe and unfetch manually. Instead, the `model.unload()` method can be called to unsubscribe and unfetch from all of the subscribes and fetches performed since the last call to unload.
152 |
153 | Derby unloads all contexts when doing a full page render, right before invoking route handlers. By default, the actual unsubscribe and unfetch happens after a short delay, so if something gets resubscribed during routing, the item will never end up getting unsubscribed and it will callback immediately.
154 |
155 | See the [Contexts documentation](contexts) for more details.
156 |
--------------------------------------------------------------------------------
/docs/models/contexts.md:
--------------------------------------------------------------------------------
1 | ---
2 | layout: default
3 | title: Data loading contexts
4 | parent: Backends and data loading
5 | grand_parent: Models
6 | ---
7 |
8 | # Data loading contexts
9 |
10 | Data loading contexts are an advanced feature, useful for features like pop-over dialogs and modals that need to load their own data independently from the parent page.
11 |
12 | Racer uses something like [reference counting](https://en.wikipedia.org/wiki/Reference_counting) for fetches and subscribes. As data is loaded into a model with calls to fetch and subscribe, Racer tracks the number of fetches and subscribes for each document and query. Data is not removed from a model until it is released by calling unfetch and unsubscribe the matching number of times for each document or query.
13 |
14 | For example, after calling `subscribe()` on a query twice, then `unsubscribe()` once, the query would remain subscribed. It would be unsubscribed and its data would be removed from the model only after `unsubscribe()` was called once more.
15 |
16 | This behavior is helpful, since multiple parts of a page may need the same resource, but they may want perform data loading and unloading independently. For example, an edit dialog may be opened and closed while some of the same data may be displayed in a list; or a migration script may fetch data in batches in order to process a large amount of data without loading all of it into memory simultaneously.
17 |
18 | A model's context tracks all fetches and subscribes made under its context name. Calling `model.unload()` on the model will "undo" the unfetch and unsubscribe counts made under its context, while not affecting fetches and subscribes made under other contexts.
19 |
20 | By default, all fetches and subscribes happen within the context named `'root'`. Additional context names may be used to isolate the loading and unloading of data within the same model for independent purposes.
21 |
22 | Child models created with `model.at()`, `model.scope()`, etc. will inherit the context name from the parent model.
23 |
24 | > `childModel = model.context(name)`
25 | > * `name` A string uniquely identifying a context. Calling `model.context()` again with the same string will refer to the same context. By default, models have the context name `'root'`
26 | > * `childModel` Returns a model with a context of `name`. All fetch, subscribe, and unload actions performed on this child model will be tracked under the new named context. The child model's path is inherited from the parent.
27 |
28 | > `model.unload([name])`
29 | > * `name` *(optional)* - A specific context name to unload. Defaults to the current model's context name.
30 | > * Undoes the fetches and subscribes for all documents and queries loaded under the context. For each piece of data, if no other contexts hold fetches or subscribes on it, then this will end the subscription and remove the data from the model.
31 |
32 | > `model.unloadAll()`
33 | > * Unload all contexts within a model. Results in all remotely loaded data being removed from a model.
34 | > * Data within [local collections](paths#local-and-remote-collections) will remain.
35 | > * This is automatically called by Derby prior to doing a client-side render of a new page.
36 |
37 | ## Usage example
38 |
39 | ```js
40 | function openTodos(model) {
41 | // Create a model with a load context inheriting from the current model
42 | var dialogModel = model.context('todosDialog');
43 | // Load data
44 | var userId = dialogModel.scope('_session.userId').get();
45 | var user = dialogModel.scope('users.' + userId);
46 | var todosQuery = dialogModel.query('todos', {creatorId: userId});
47 | dialogModel.subscribe(user, todosQuery, function(err) {
48 | if (err) throw err;
49 | // Delay display until required data is loaded
50 | dialogModel.set('showTodos', true);
51 | });
52 | }
53 |
54 | function closeTodos(model) {
55 | model.set('showTodos', false);
56 | // Use the same context name to unsubscribe
57 | model.unload('todosDialog');
58 | }
59 | ```
60 |
61 | ## Automatic unloading on page navigation
62 |
63 | Derby uses Racer model contexts to unload the data for the previous page render when it performs client-side routing and a full-page render. When moving away from a page and before calling into the route for the new page, Derby calls `unloadAll()`, removing the data from all subscribes and fetches performed on the prior page.
64 |
--------------------------------------------------------------------------------
/docs/models/filters-sorts.md:
--------------------------------------------------------------------------------
1 | ---
2 | layout: default
3 | title: Filters and sorts
4 | parent: Models
5 | ---
6 |
7 | # Filters and sorts
8 |
9 | Filters create a live-updating list from items in an object. The results automatically update as the input items change.
10 |
11 | > `filter = model.filter(inputPath, [additionalInputPaths...], [options], fn)`
12 | > * `inputPath` A path pointing to an object or array. The path's values will be retrieved from the model via `model.get()`, and then each item will be checked against the filter function
13 | > * `additionalInputPaths` *(optional)* Other parameters can be set in the model, and the filter function will be re-evaluated when these parameters change as well
14 | > * `options:`
15 | > * `skip` The number of first results to skip
16 | > * `limit` The maximum number of results. A limit of zero is equivalent to no limit
17 | > * `fn` A function or the name of a function defined via `model.fn()`. The function should have the arguments `function(item, key, object, additionalInputs...)`. Like functions for [`array.filter()`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/filter), the function should return true for values that match the filter
18 |
19 | ```js
20 | app.get('/search-pants', function(page, model, params, next) {
21 | model.subscribe('pants', function(err) {
22 | if (err) return next(err);
23 | model.filter('pants', 'pricing', 'color',
24 | // evaluate whether a pants item matches the search options
25 | function(item, pantsId, pants, pricing, color) {
26 | return item.price >= pricing.minimum
27 | && item.price <= pricing.maximum
28 | && item.color == color;
29 | }
30 | ).ref('_page.pantsList'); // bind the output of the filter
31 | page.render('pants');
32 | });
33 | });
34 | ```
35 |
36 | If `model.filter()` is called with `null` for the function, it will create a list out of all items in the input object. This can be handy as a way to render all subscribed items in a collection, since only arrays can be used as an input to `{{each}}` template tags.
37 |
38 | > `filter = model.sort(inputPath, [options], fn)`
39 | > * `inputPath` A path pointing to an object or array. The path's values will be retrieved from the model via `model.get()`, and then each item will be checked against the filter function
40 | > * `options:`
41 | > * `skip` The number of first results to skip
42 | > * `limit` The maximum number of results. A limit of zero is equivalent to no limit
43 | > * `fn` A function or the name of a function defined via `model.fn()`. The function should should be a compare function with the arguments `function(a, b)`. It should return the same values as compare functions for [`array.sort()`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/sort)
44 |
45 | There are two default named functions defined for sorting, `'asc'` and `'desc'`. These functions compare each item with Javascript's less than and greater than operators. See MDN for more info on [sorting non-ASCII characters](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/sort#Sorting_non-ASCII_characters).
46 |
47 | You may define functions to be used in `model.filter()` and `model.sort()` via [`model.fn()`](reactive-functions#named-functions).
48 |
49 | A filter may have both a filter function and a sort function by chaining the two calls:
50 |
51 | ```js
52 | app.on('model', function(model) {
53 | model.fn('expensiveItem', function(item) {
54 | return item.price > 100;
55 | });
56 | model.fn('priceSort', function(a, b) {
57 | return b.price - a.price;
58 | });
59 | });
60 |
61 | app.get('/expensive-pants', function(page, model, params, next) {
62 | model.subscribe('pants', function(err) {
63 | if (err) return next(err);
64 | var filter = model.filter('pants', 'expensiveItem')
65 | .sort('priceSort');
66 | filter.ref('_page.expensivePants');
67 | page.render('pants');
68 | });
69 | });
70 | ```
71 |
72 | ## Methods
73 |
74 | The output of a filter is typically used by creating a reference from it. This sets the data in the model and keeps it updated.
75 |
76 | > `scoped = filter.ref(path)`
77 | > * `path` The path at which to create a refList of the filter's output
78 | > * `scoped` Returns a model scoped to the output path of the ref
79 |
80 | The filter's current value can also be retrieved directly via `filter.get()`.
81 |
82 | > `results = filter.get()`
83 | > * `results` Returns an array of results matching the filter
84 |
85 | As well as by updating its input paths, a filter can be recomputed manually by calling its `filter.update()` method. This can also be used to perform pagination, since the the `filter.skip` and `filter.limit` properties can be modified followed by calling `filter.update()`.
86 |
87 | > `filter.update()`
88 |
89 | ```js
90 | var filter = model.sort('items', {skip: 0, limit: 10}, function(a, b) {
91 | if (a && b) return a.score - b.score;
92 | });
93 | // Logs first 10 items
94 | console.log(filter.get());
95 |
96 | filter.skip += filter.limit;
97 | filter.update();
98 | // Logs next 10 items
99 | console.log(filter.get());
100 | ```
101 |
--------------------------------------------------------------------------------
/docs/models/getters.md:
--------------------------------------------------------------------------------
1 | ---
2 | layout: default
3 | title: Getters
4 | parent: Models
5 | ---
6 |
7 | # Getters
8 |
9 | Data in the model is accessed with `model.get()`. This method returns values by reference, based on the model's scope and/or the path passed to the get method. Models allow getting undefined paths. The get method will return `undefined` and will not throw when looking up a property below another property that is undefined.
10 |
11 | Internally, model data is represented as collections of documents. Collections must be objects, and documents may be of any type, but are also typically objects. A document's data at a point in time is referred to as its snapshot. This structure allows for some documents to be remote documents synced with the server and some to be local documents only in the client. It also means that models are broken into a similar structure as database collections or tables.
12 |
13 | As model document snapshots change from local or remote mutations, the `model.root.data` object is updated. `model.get()` traverses through the properties of the model's data to lookup and return the appropriate value.
14 |
15 | ```js
16 | model.get('_session.account') === model.root.data._session.account;
17 | ```
18 |
19 | ## Basic get methods
20 |
21 | The basic `get` methods are fastest for most use-cases, where you don't need to do directly manipulate returned objects/arrays.
22 |
23 | > `value = model.get([subpath])`
24 | > * `path` *(optional)* Subpath of object to get. Not supplying a subpath will return the entire value at the current model's path.
25 | > * `value` Returns the current value at the given subpath.
26 |
27 | > `value = model.getOrThrow(subpath)` _(since racer@2.1.0)_
28 | > * `path` Subpath of object to get
29 | > * `value` Returns the current value at the given subpath, if not null-ish. If the current value is `null` or `undefined`, an exception will be thrown.
30 |
31 | > `value = model.getOrDefault(subpath, defaultValue)` _(since racer@2.1.0)_
32 | > * `path` *(optional)* Subpath of object to get
33 | > * `value` Returns the current value at the given subpath, if not null-ish. If the current value is `null` or `undefined`, the provided `defaultValue` will be returned instead.
34 | >
35 | > This method will _not_ put the default into the model. It's equivalent to using the relatively newer [JS nullish coalescing operator](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Nullish_coalescing) as `model.get(subpath) ?? defaultValue`.
36 |
37 | {: .warning-red }
38 | > When using the non-copying `get` methods above to get an object or array, do NOT modify or sort the returned value in-place.
39 | >
40 | > The returned values are references to Racer's internal data tree, and direct manipulations can cause hard-to-debug issues. To make changes to model data, use [the mutator methods](mutators).
41 | >
42 | > If you do need to modify the value in-place, such as for sorting or for a later `setDiffDeep`, use the copying getters below.
43 | >
44 | > The TypeScript types indicate this restriction by returning a `ReadonlyDeep` version of the type parameter.
45 |
46 | ## Copying get methods
47 |
48 | > `shallowCopy = model.getCopy([path])`
49 | > * `path` *(optional)* Path of object to get
50 | > * `shallowCopy` Shallow copy of current value, going only one level deep when returning an object or array
51 |
52 | > `deepCopy = model.getDeepCopy([path])`
53 | > * `path` *(optional)* Path of object to get
54 | > * `deepCopy` Deep copy of current value
55 |
56 | ```js
57 | // Do NOT directly manipulate objects in the model
58 | var user = model.get('users.' + userId);
59 | /* BAD */ user.name = 'John'; /* BAD */
60 |
61 | // Instead, use the model setter methods
62 | var user = model.get('users.' + userId);
63 | model.set('users.' + userId + '.name', 'John');
64 |
65 | // Or, get a copy and set the difference
66 | var user = model.getDeepCopy('users.' + userId);
67 | user.name = 'John';
68 | model.setDiffDeep('users.' + userId, user);
69 | ```
70 |
--------------------------------------------------------------------------------
/docs/models/paths.md:
--------------------------------------------------------------------------------
1 | ---
2 | layout: default
3 | title: Paths
4 | parent: Models
5 | ---
6 |
7 | # Paths
8 |
9 | The model's data can be thought of as a JSON object tree.
10 |
11 | A path is a string with dot-separated segments, referring to a node (sub-object or value) within the tree. Each segment represents a property lookup within an object or array. Array indexes are 0-based like in JavaScript.
12 |
13 | For example, the model data:
14 |
15 | ```js
16 | {
17 | _page: {
18 | currentStorefrontId: 'storefront-1',
19 | },
20 | storefronts: {
21 | 'storefront-a': {
22 | id: 'storefront-a',
23 | title: 'Fruit store',
24 | fruits: [
25 | { name: 'banana', color: 'yellow' },
26 | { name: 'apple', color: 'red' },
27 | { name: 'lime', color: 'green' }
28 | ]
29 | }
30 | }
31 | }
32 | ```
33 |
34 | Would have paths like:
35 | - `'_page'`, referring to the object `{ currentStorefrontId: 'storefront-1' }`
36 | - `'storefronts.storefront-a.title'`, referring to the title of "storefront-a"
37 | - `'storefronts.storefront-a.fruits.0'`, referring to the first fruit object in "storefront-a"
38 |
39 | From the data root, the first level of properties are collection names, in this case `'_page'` and `'storefront-a'`. These can have special meanings, as described in the next section.
40 |
41 | > **WARNING** If you want to use a number as a path segment, be careful to prefix this before setting it. Otherwise, you will accidentally create a gigantic array and probably run out of memory. For example, use a path like: `items.id_1239182389123.name` and never `items.1239182389123.name`.
42 |
43 | ## Local and remote collections
44 |
45 | Collection names (i.e. the first path segment) that start with an underscore (`_`) are local and are not synced to the database. Data written to local collections during server-side rendering _is_ available in the browser, but that data isn't shared with other servers or clients.
46 |
47 | Collection names that begin with dollar signs (`$`) are special local collections reserved for use by Racer, Derby, or extensions, and should not be used for application data.
48 |
49 | Collection names not prefixed with those special characters are considered remote collections, and will be synced to the server and other clients via ShareDB.
50 |
51 | Almost all non-synced data within an application should be stored underneath the `_page` local collection, which Derby to automatically cleans up when the user navigates between pages. Right before rendering a new page, Derby calls `model.destroy('_page')`, which removes all data, references, event listeners, and reactive functions underneath the `_page` collection.
52 |
53 | If you have some local data that you would like to be maintained between page renders, it can be stored underneath a different local collection. This is useful for setting data on the server, such as setting `_session.userId` in authentication code. However, be very careful when storing local data outside of `_page`, since bleeding state between pages is likely to be a source of unexpected bugs.
54 |
55 | ## Scoped models
56 |
57 | Scoped models provide a more convenient way to interact with commonly used paths. They support the same methods, and they provide the path argument to accessors, mutators, event methods, and subscription methods. Also, wherever a path is accepted in a racer method, a scoped model can typically be used as well.
58 |
59 | > `scoped = model.at(subpath)`
60 | > * `subpath` A relative path starting from the current model's path
61 | > * `scoped` Returns a scoped model
62 |
63 | > `scoped = model.scope([absolutePath])`
64 | > * `absolutePath` *(optional)* An absolute path from the root of the model data, or the root path by default. This will become the scope even if called on a scoped model. May be called without a path to get a model scoped to the root
65 | > * `scoped` Returns a scoped model
66 |
67 | > `scoped = model.parent([levels])`
68 | > * `levels` *(optional)* Defaults to 1. The number of path segments to remove from the end of the reference path
69 | > * `scoped` Returns a scoped model
70 |
71 | > `path = model.path([subpath])`
72 | > * `subpath` *(optional)* A relative reference path to append. Defaults to the current path
73 | > * `path` Returns the reference path if applicable
74 |
75 | > `isPath = model.isPath(subpath)`
76 | > * `subpath` A relative reference path or scoped model
77 | > * `isPath` Returns true if the argument can be interpreted as a path, false otherwise
78 |
79 | > `segment = model.leaf()`
80 | > * `segment` Returns the last segment for the reference path. Useful for getting indices, ids, or other properties set at the end of a path
81 |
82 | ```js
83 | const roomModel = model.at('_page.room');
84 |
85 | // These are equivalent:
86 | roomModel.at('name').set('Fun room');
87 | roomModel.set('name', 'Fun room');
88 |
89 | // Logs: {name: 'Fun room'}
90 | console.log(roomModel.get());
91 | // Logs: 'Fun room'
92 | console.log(roomModel.get('name'));
93 |
94 | // Use model.scope(absolutePath) to refer to things outside a model's subtree.
95 | class MyComponent extends Component {
96 | init() {
97 | // In a component, `this.model` is the component's "private" scoped model
98 | const roomModel = this.model.scope('_page.room');
99 | }
100 | }
101 | ```
102 |
103 | ## UUIDs
104 |
105 | Models provide a method to create globally unique ids. These can be used as part of a path or within mutator methods.
106 |
107 | > `uuid = model.id()`
108 | > * `uuid` Returns a globally unique identifier that can be used for model operations
109 |
--------------------------------------------------------------------------------
/docs/models/queries.md:
--------------------------------------------------------------------------------
1 | ---
2 | layout: default
3 | title: Queries
4 | parent: Models
5 | ---
6 |
7 | # Queries
8 |
9 | Racer can fetch or subscribe to queries based a database-specific query.
10 |
11 | When fetching or subscribing to a query, all of the documents associated with that query's results are also individually loaded into the model, as if they were fetched/subscribed.
12 |
13 | First, create a Query object.
14 |
15 | > `query = model.query(collectionName, databaseQuery)`
16 | > * `collectionName` The name of a collection from which to get documents
17 | > * `databaseQuery` A query in the database native format, such as a MonogDB query
18 |
19 | Next, to actually run the query, it needs to be subscribed or fetched. For details on subscribing vs fetching, see the ["Loading data into a model" documentation](./backends#loading-data-into-a-model).
20 |
21 | - Query objects have subscribe and fetch methods:
22 | - Callback API - `query.subscribe(callback)` and `query.fetch(callback)`. The callback `(error?: Error) => void` is called when the query data is successfully loaded into the model or when the query encounters an error.
23 | - Promise API - `query.subscribePromised()` and `query.fetchPromised()`. They return a `Promise` that is resolved when the the query data is successfully loaded into the model, or is rejected when the query encounters an error.
24 | - The general `model.subscribe` and `model.fetch` methods also accept query objects, which is useful to execute multiple queries in parallel.
25 | - Callback API - `model.subscribe([query1, query2, ...], callback)` and `model.fetch([query1, query2, ...], callback)`
26 | - Promise API _(since racer@1.1.0)_ - `model.subscribePromised([query1, query2, ...])` and `model.fetchPromised([query1, query2, ...])`
27 | - See ["Loading data into a model" documentation](./backends#loading-data-into-a-model) for more details.
28 |
29 | ## Query results
30 |
31 | After a query is subscribed or fetched, its results can be returned directly via `query.get()`. It is also possible to create a live-updating results set in the model via `query.ref()`.
32 |
33 | > `results = query.get()`
34 | > * `results` Creates and returns an array of each of the document objects matching the query
35 |
36 | > `scoped = query.ref(path)`
37 | > * `path` Local path at which to create an updating refList of the queries results
38 | > * `scoped` Returns a model scoped to the path at which results are output
39 |
40 | ## Examples
41 |
42 | These examples use the MongoDB query format, as sharedb-mongo is the most mature DB adapter for ShareDB. Adjust the query expressions as needed based on your DB adapter.
43 |
44 | ### Callback API
45 |
46 | ```js
47 | const notesQuery = model.query('notes', { creatorId: userId });
48 |
49 | // Frontend code usually subscribes.
50 | // Subscribing to multiple things in parallel reduces the number of round-trips.
51 | model.subscribe([notesQuery, `users.${userId}`], (error) => {
52 | if (error) {
53 | return handleError(error);
54 | }
55 | // Add a reference to the query results to get automatic UI updates.
56 | // A view can use these query results with {{#root._page.notesQueryResults}}.
57 | notesQuery.ref('_page.notesQueryResults');
58 | // Controller code can get the results either with the query or with the ref.
59 | console.log(notesQuery.get());
60 | console.log(model.get('_page.notesQueryResults'));
61 | // Documents from the results are also loaded into the model individually.
62 | model.get(`notes.${notesQuery[0].id}`);
63 | });
64 |
65 | // Backend-only code usually only needs to fetch.
66 | notesQuery.fetch((error) => {
67 | if (error) {
68 | return handleError(error);
69 | }
70 | console.log(notesQuery.get());
71 | });
72 | ```
73 |
74 | ### Promise API
75 |
76 | _(since racer@1.1.0)_
77 |
78 | ```js
79 | const notesQuery = model.query('notes', { creatorId: userId });
80 |
81 | // Frontend code usually subscribes.
82 | // Subscribing to multiple things in parallel reduces the number of round-trips.
83 | try {
84 | await model.subscribePromised([notesQuery, `users.${userId}`]);
85 | } catch (error) {
86 | return handleError(error);
87 | }
88 | // Add a reference to the query results to get automatic UI updates.
89 | // A view can use these query results with {{#root._page.notesQueryResults}}.
90 | notesQuery.ref('_page.notesQueryResults');
91 | // Controller code can get the results either with the query or with the ref.
92 | console.log(notesQuery.get());
93 | console.log(model.get('_page.notesQueryResults'));
94 |
95 | // Backend-only code usually only needs to fetch.
96 | try {
97 | await notesQuery.fetchPromised();
98 | } catch (error) {
99 | console.log(notesQuery.get());
100 | }
101 | ```
102 |
103 | ## MongoDB query format
104 |
105 | The `sharedb-mongo` adapter supports most MongoDB queries that you could pass to the Mongo `find()` method. See the [Mongo DB query documentation](https://docs.mongodb.org/manual/core/read-operations/#read-operations-query-document) and the [query selectors reference](https://docs.mongodb.org/manual/reference/operator/#query-selectors). Supported MongoDB cursor methods must be passed in as part of the query. `$sort` should be used for sorting, and skips and limits should be specified as `$skip` and `$limit`. There is no `findOne()` equivalent—use `$limit: 1` instead.
106 |
107 | Note that projections, which are used to limit the fields that a query returns, may not be defined in the query. Please refer to the [guide on using projections](https://github.com/derbyparty/derby-faq/tree/master/en#i-dont-need-all-collections-fields-in-a-browser-how-to-get-only-particular-fields-collections-projection), which you can follow if you only want specific fields of a document transferred to the browser.
108 |
--------------------------------------------------------------------------------
/docs/models/refs.md:
--------------------------------------------------------------------------------
1 | ---
2 | layout: default
3 | title: Refs
4 | parent: Models
5 | ---
6 |
7 | # References
8 |
9 | Model references work like [symlinks in filesystems](https://en.wikipedia.org/wiki/Symbolic_link), redirecting model operations from a reference path to the underlying data, and they set up event listeners that emit model events on both the reference and the actual object's path.
10 |
11 | References must be declared per model, since calling `model.ref` creates a number of event listeners in addition to setting a ref object in the model. When a reference is created or removed, a `change` model event is emitted. References are not actually stored in the model data, but they can be used from getter and setter methods as if they are.
12 |
13 | > `scoped = model.ref(path, to, [options])`
14 | > * `path` The location at which to create a reference. This must be underneath a [local collection](paths#local-and-remote-collections) (typically `_page`), since references must be declared per model
15 | > * `to` The location that the reference links to. This is where the data is actually stored
16 | > * `options:`
17 | > * `updateIndices` Set true to update the ref's `to` path if it contains array indices whose parents are modified via array inserts, removes, or moves
18 | > * `scoped` Returns a model scoped to the output path for convenience
19 |
20 | > `model.removeRef(path)`
21 | > * `path` The location at which to remove the reference
22 |
23 | ```js
24 | model.set('colors', {
25 | red: {hex: '#f00'}
26 | , green: {hex: '#0f0'}
27 | , blue: {hex: '#00f'}
28 | });
29 |
30 | // Getting a reference returns the referenced data
31 | model.ref('_page.green', 'colors.green');
32 | // Logs {hex: '#0f0'}
33 | console.log(model.get('_page.green'));
34 |
35 | // Setting a property of the reference path modifies
36 | // the underlying data
37 | model.set('_page.green.rgb', [0, 255, 0]);
38 | // Logs {hex: '#0f0', rgb: [0, 255, 0]}
39 | console.log(model.get('colors.green'));
40 |
41 | // Removing the reference has no effect on the underlying data
42 | model.removeRef('_page.green');
43 | // Logs undefined
44 | console.log(model.get('_page.green'));
45 | // Logs {hex: '#0f0', rgb: [0, 255, 0]}
46 | console.log(model.get('colors.green'));
47 | ```
48 |
49 | Racer also supports a special reference type created via `model.refList`. This type of reference is useful when a number of objects need to be rendered or manipulated as a list even though they are stored as properties of another object. This is also the type of reference created by a query. A reference list supports the same mutator methods as an array, so it can be bound in a view template just like an array.
50 |
51 | > `scoped = model.refList(path, collection, ids, [options])`
52 | > * `path` The location at which to create a reference list. This must be underneath a [local collection](paths#local-and-remote-collections) (typically `_page`), since references must be declared per model
53 | > * `collection` The path of an object that has properties to be mapped onto an array. Each property must be an object with a unique `id` property of the same value
54 | > * `ids` A path whose value is an array of ids that map the `collection` object's properties to a given order
55 | > * `options:`
56 | > * `deleteRemoved` Set true to delete objects from the `collection` path if the corresponding item is removed from the refList's output path
57 | > * `scoped` Returns a model scoped to the output path for convenience
58 |
59 | > `model.removeRefList(path)`
60 | > * `path` The location at which to remove the reference
61 |
62 | Note that if objects are inserted into a refList without an `id` property, a unique id from [`model.id()`](paths#uuids) will be automatically added to the object.
63 |
64 | ```js
65 | // refLists should consist of objects with an id matching
66 | // their property on their parent
67 | model.setEach('colors', {
68 | red: {hex: '#f00', id: 'red'},
69 | green: {hex: '#0f0', id: 'green'},
70 | blue: {hex: '#00f', id: 'blue'}
71 | });
72 | model.set('_page.colorIds', ['blue', 'red']);
73 | model.refList('_page.myColors', 'colors', '_page.colorIds');
74 |
75 | model.push('_page.myColors', {hex: '#ff0', id: 'yellow'});
76 |
77 | // Logs: [
78 | // {hex: '#00f', id: 'blue'},
79 | // {hex: '#f00', id: 'red'},
80 | // {hex: '#ff0', id: 'yellow'}
81 | // ]
82 | console.log(model.get('_page.myColors'));
83 | ```
84 |
85 | When a collection is cleaned up by `model.destroy()`, the `model.removeAllRefs()` method is invoked to remove all refs and refLists underneath the collection.
86 |
87 | > `model.removeAllRefs(from)`
88 | > * `from` Path underneath which to remove all refs and refLists
89 |
90 | It isn't neccessary to manually dereference model paths, but for debugging, testing, or special cases there is a `model.dereference()` method.
91 |
92 | > `resolved = model.dereference(from)`
93 | > * `from` Path to dereference
94 | > * `resolved` Returns the fully dereferenced path, possibly passing through multiple refs or refLists. Will return the input path if no references are found
95 |
--------------------------------------------------------------------------------
/docs/routes.md:
--------------------------------------------------------------------------------
1 | ---
2 | layout: default
3 | title: Routes
4 | ---
5 |
6 | # Routes
7 |
8 | Routes map URL patterns to actions. Derby routes are powered by [Express](https://expressjs.com/). Within apps, routes are defined via the `get`, `post`, `put`, and `del` methods of the app created by `derby.createApp()`.
9 |
10 | > `app.get ( routePattern, callback(page, model, params, next) )`
11 | >
12 | > `app.post ( routePattern, callback(page, model, params, next) )`
13 | >
14 | > `app.put ( routePattern, callback(page, model, params, next) )`
15 | >
16 | > `app.del ( routePattern, callback(page, model, params, next) )`
17 | >
18 | > * `pattern`: A string containing a literal URL, an Express route pattern, or a regular expression. See [Express's routing documentation](https://expressjs.com/guide/routing.html) for more info.
19 | >
20 | > * `callback`: Function invoked when a request for a URL matching the appropriate HTTP method and pattern is received. Note that this function is called both on the server and the client.
21 | >
22 | > * `page`: Object with the methods [`page.render()`](#page) and `page.redirect()`. All app routes should call one of these two methods or pass control by calling `next()`.
23 | >
24 | > * `model`: Derby model object
25 | >
26 | > * `params`: An object containing the matching URL parameters. The `url`, `query`, and `body` properties typically available on `req` are also added to this object.
27 | >
28 | > * `next`: A function that can be called to pass control to the next matching route. If this is called on the client, control will be passed to the next route defined in the app. If no other routes in the same app match, it will fall through to a server request.
29 |
30 | Express is used directly on the server. On the client, Derby includes Express's route matching module. When a link is clicked or a form is submitted, Derby first tries to render the new URL on the client. AJAX requests will still go directly to the server.
31 |
32 | Derby can also capture form submissions client-side. It provides support for `post`, `put`, and `del` HTTP methods using the same hidden form field [override approach](https://expressjs.com/guide.html#http-methods) as Express.
33 |
34 | ## Page
35 |
36 | Unlike Express, which provides direct access to the `req` and `res` objects created by Node HTTP servers, Derby returns a `page` object. This provide the same interface on the client and the server, so that route handlers may be executed in both environments.
37 |
38 | > `page.render ( viewName )`
39 | >
40 | > * `viewName`: The name of the view to render, see [Namespaces and files](./views/namespaces-and-files) for more details.
41 | >
42 | >
43 | > `page.renderStatic ( statusCode, content )`
44 | >
45 | > * `statusCode`: The HTTP status code to return.
46 | >
47 | > * `content`: A string of HTML to render
48 | >
49 | > `page.redirect ( url, [status] )`
50 | >
51 | > * `url`: Destination of redirect. [Like Express][expressRedirect], may also be the string 'home' (which redirects to '/') or 'back' (which goes back to the previous URL).
52 | >
53 | > * `status`: *(optional)* Number specifying HTTP status code. Defaults to 302 on the server. Has no effect on the client.
54 |
55 | [expressRedirect]: https://expressjs.com/guide.html#res.redirect()
56 |
57 |
58 | ### Middleware
59 |
60 | It is possible to directly use [express middleware](https://expressjs.com/guide/using-middleware.html) and get access to a [Racer model](./models#methods).
61 |
62 |
63 | ## History
64 |
65 | For the most part, updating the URL client-side should be done with normal HTML links. The default action of requesting a new page from the server is canceled automatically if the app has a route that matches the new URL.
66 |
67 | To update the URL after an action other than clicking a link, scripts can call methods on `app.history`. For example, an app might update the URL as the user scrolls and the page loads more content from a paginated list.
68 |
69 | > `app.history.push ( url, [render], [state], [e] )`
70 | >
71 | > `app.history.replace ( url, [render], [state], [e] )`
72 | >
73 | > * `url`: New URL to set for the current window
74 | >
75 | > * `render`: *(optional)* Re-render the page after updating the URL if true. Defaults to true
76 | >
77 | > * `state`: *(optional)* A state object to pass to the `window.history.pushState` or `window.history.replaceState` method. `$render` and `$method` properties are added to this object for internal use when handling `popstate` events
78 | >
79 | > * `e`: *(optional)* An event object whose `stopPropogation` method will be called if the URL can be rendered client-side
80 |
81 | Derby's `history.push` and `history.replace` methods will update the URL via `window.history.pushState` or `window.history.replaceState`, respectively. They will fall back to setting `window.location` and server-rendering the new URL if a browser does not support these methods. The `push` method is used to update the URL and create a new entry in the browser's back/forward history. The `replace` method is used to only update the URL without creating an entry in the back/forward history.
82 |
83 | > `app.history.refresh ( )`
84 | >
85 | > Re-render the current URL client-side
86 |
87 | For convenience, the navigational methods of [`window.history`](https://developer.mozilla.org/en/DOM/window.history) can also be called on `app.history`.
88 |
89 | > `app.history.back ( )`
90 | >
91 | > * Call `window.history.back()`, which is equivalent to clicking the browser's back button
92 |
93 | > `view.history.forward ( )`
94 | >
95 | > * Call `window.history.forward()`, which is equivalent to clicking the browser's forward button
96 |
97 | > `view.history.go ( i )`
98 | >
99 | > * Call `window.history.go()`
100 | >
101 | > * `i`: An integer specifying the number of times to go back or forward. Navigates back if negative or forward if positive
102 |
--------------------------------------------------------------------------------
/docs/views.md:
--------------------------------------------------------------------------------
1 | ---
2 | layout: default
3 | title: Views
4 | has_children: true
5 | ---
6 |
7 | # Views
8 |
9 | When writing an app or new feature in Derby, you should typically start by writing its view. Derby templates can be written in HTML or Jade with [derby-jade](https://github.com/derbyparty/derby-jade). Templates define HTML/DOM output, data bindings, event listeners, and component parameters.
10 |
11 | ## Creating views
12 |
13 | Views are written in HTML files. These files are parsed and added to a Derby app with the `app.loadViews()` method. This method synchronously reads template files, traverses their includes, and calls `app.views.register()` for each view.
14 |
15 | > `app.loadViews(filename)`
16 | > * `filename` File path to root template file at which to start loading views
17 |
18 | > `app.views.register(name, source, options)`
19 | > * `name` View name to add
20 | > * `source` Derby HTML source
21 | > * `options:`
22 | > * `tag` Name of an HTML tag that will render this view
23 | > * `attributes` Space separated list of HTML tags interpreted as an attribute when directly within the view instance
24 | > * `arrays` Space separated list of HTML tags interpreted as an array of objects attribute when directly within the view instance
25 | > * `unminified` Whitespace is removed from templates by default. Set true to disable
26 | > * `string` True if the template should be interpreted as a string instead of HTML
27 |
28 | > `view = app.views.find(name, [namespace])`
29 | > * `name` View name to find
30 | > * `namespace` *(optional)* Namespace from which to start the name lookup
31 | > * `view` Returns the view template object
32 |
33 | Each view is wrapped in a tag that names it. This name must end in a colon to differentiate it from a normal HTML tag. These tags can't be nested, and they need not be closed.
34 |
35 | ```jinja
36 |
37 |
Hello, sir.
38 |
39 |
40 |
Howdy!
41 | ```
42 |
43 | is equivalent to:
44 |
45 | ```js
46 | app.views.register('serious-title', '
Hello, sir.
');
47 | app.views.register('friendly-title', '
Howdy!
');
48 | ```
49 |
50 | ## Using views
51 |
52 | You can instantiate a view in a template with the `` tag, `{{view}}` expression, or by giving the view a tag name. Typically, you should use the `` tag in HTML templates. The `{{view}}` expression is useful when writing string templates or wish to include a view in an HTML attribute, script tag, or style tag. Custom tag names are global to an application. They are recommended for general purpose components, like `` or ``, but not for ordinary views.
53 |
54 | ```jinja
55 |
56 |
Hello, sir.
57 |
58 |
59 |
60 |
61 |
62 |
63 |
64 | {{view 'serious-title'}}
65 |
66 |
67 |
68 | ```
69 |
70 | Views may be looked up dynamically with an expression. If the view isn't found, nothing will be rendered.
71 |
72 | ```jinja
73 |
74 |
75 |
76 | {{view type + '-title'}}
77 | ```
78 |
--------------------------------------------------------------------------------
/docs/views/namespaces-and-files.md:
--------------------------------------------------------------------------------
1 | ---
2 | layout: default
3 | title: Namespaces and files
4 | parent: Views
5 | ---
6 |
7 | # View namespaces
8 |
9 | View names have colon (`:`) separated namespaces. Lookups of views are relative to the namespace in which they are used. Thus, sub-views within components or different sections of large applications are well encapsulated and won't cause naming conflicts.
10 |
11 | ```jinja
12 |
13 | ...
14 |
15 |
16 | ...
17 |
18 |
19 |
20 |
21 | ```
22 |
23 | In addition, similar to the way that CSS allows overriding of styles by using a more specific selector, you can define views at a general namespace and then redefine them at a more specific namespace.
24 |
25 | ```jinja
26 |
27 | App
28 |
29 |
30 | About - App
31 |
32 |
33 | Mission statement - App
34 | ```
35 |
36 | ### Custom HTML tags
37 |
38 | A view can be turned into a custom HTML tag by specifying the `tag` property in it's definition. Custom tag names are global so care should be taken in their usage.
39 |
40 | ```jinja
41 |
42 |
43 |
{{data}}
44 |
45 |
46 |
47 |
48 | ```
49 |
50 | ## Structuring views in multiple files
51 |
52 | Views should be broken into files that correspond to major pieces of functionality, different URLs, or components. Views are included from another file with the `` tag.
53 |
54 | ```jinja
55 |
56 |
57 |
58 |
59 |
60 |
61 |
62 |
63 | ```
64 |
65 | Typically, view namespaces have a one-to-one correspondence with directories and files. For example, a typical structure like:
66 |
67 | #### index.html
68 | ```jinja
69 |
70 |
71 |
72 | App
73 | ```
74 |
75 | #### about/index.html
76 | ```jinja
77 |
78 |
79 |
80 | About - App
81 | ```
82 |
83 | #### about/mission.html
84 | ```jinja
85 |
86 | Mission statement - App
87 | ```
88 |
89 | would be equivalent to:
90 |
91 | `index.html`
92 | ```jinja
93 |
94 | App
95 |
96 |
97 | About - App
98 |
99 |
100 | Mission statement - App
101 | ```
102 |
103 | Rules for importing views work the same way as [Node.js module loading](https://nodejs.org/api/modules.html) with `require()`. The `src` attribute uses the same syntax of relative paths or paths to `node_modules`. An `index.html` file can be imported via the name of the directory that it is in, just like `index.js` files in Node.js.
104 |
105 | As well, the name `index` can be used for a view that is returned for just the name of its namespace.
106 |
107 | #### index.html
108 | ```jinja
109 |
110 |
111 |
112 |
113 | ```
114 |
115 | #### home.html
116 | ```jinja
117 |
118 |
119 |
120 |
121 |
122 |
123 | Hello!
124 | ```
125 |
--------------------------------------------------------------------------------
/docs/views/template-syntax.md:
--------------------------------------------------------------------------------
1 | ---
2 | layout: default
3 | title: Template syntax
4 | parent: Views
5 | has_children: true
6 | ---
7 |
8 | # Template syntax
9 |
10 | Derby’s template syntax is loosely based on [Handlebars](https://handlebarsjs.com/), a semantic templating language that extends [Mustache](https://mustache.github.io/mustache.5.html).
11 |
12 | Semantic templates encourage separation of logic from presentation. Instead of arbitrary code, there are a small set of template expressions. During rendering, data are passed to the template, and template expressions are replaced with the appropriate values. In Derby, this data comes from the model.
13 |
14 | Derby supports calling controller functions and the full JavaScript expression syntax. Expressions are parsed with [Esprima](http://esprima.org/) and do not use JavaScript's `eval()` or `new Function()`.
15 |
16 | ## HTML
17 |
18 | With the exception of templates that return strings and the contents of `
72 | ```
73 |
74 | HTML comments are removed, except for those with square brackets immediately inside of them. This syntax is used by Internet Explorer's conditional comments. It can also be used to include comments for copyright notices or the like.
75 |
76 | ```jinja
77 |
78 |
79 |
80 |
81 | ```
82 |
--------------------------------------------------------------------------------
/docs/views/template-syntax/blocks.md:
--------------------------------------------------------------------------------
1 | ---
2 | layout: default
3 | title: Blocks
4 | parent: Template syntax
5 | grand_parent: Views
6 | ---
7 |
8 | # Blocks
9 |
10 | Blocks are template expressions that start with special keywords. They are used to conditionally render, repeat, or control the way in which sections of a template are rendered.
11 |
12 | Similar to HTML tags, blocks end in a forward slash followed by the same keyword that started them. The closing keyword is optional but recommended for clarity. For example, both `{{with}}...{{/with}}` and `{{with}}...{{/}}` are parsed correctly.
13 |
14 | # Conditionals
15 |
16 | Conditional blocks use the `if`, `else if`, `else`, and `unless` keywords. They render the first template section that matches a condition or nothing if none match. Like in Mustache and Handlebars, zero length arrays (`[]`) are treated as falsey. Other than that, falsey values are the same as JavaScript: `false`, `undefined`, `null`, `''`, and `0`.
17 |
18 | ```jinja
19 | {{if user.name}}
20 |
user.name
21 | {{else if user}}
22 |
Unnamed user
23 | {{else}}
24 | No user
25 | {{/if}}
26 | ```
27 |
28 | The inverse of `if` is `unless`. For clarity, unless should only be used when there is no `else` condition. A block that has an unless and else condition can usually be writtern more clearly as an if and else.
29 |
30 | ```jinja
31 | {{unless items}}
32 | Please add some items
33 | {{/unless}}
34 | ```
35 |
36 | The contents of a conditional block are only re-rendered when a different condition starts to match. If the values in the conditional change, the condition expression is evaluated, but the DOM is not updated if the same section matches.
37 |
38 | # Each
39 |
40 | Each blocks repeat for each of the items in an array. They cannot iterate over objects.
41 |
42 | ```jinja
43 | {{each items}}
44 |
{{this.text}}
45 | {{else}}
46 | No items
47 | {{/each}}
48 | ```
49 |
50 | In addition to an alias to the array item, eaches support an alias for the index of the item. This index alias supports binding and will be updated as the array changes.
51 |
52 | ```jinja
53 | {{each items as #item, #i}}
54 | {{#i + 1}}. {{#item.text}}
55 | {{/each}}
56 | ```
57 |
58 | Derby has very granular model events to describe array mutations as inserts, removes, and moves. It maps these directly into efficient DOM mutations of just what changed.
59 |
60 | # With
61 |
62 | With blocks set the path context of a block, but they do not trigger re-rendering. Their primary use is to set an alias to a path inside of their contents.
63 |
64 | Aliases can be a convenient way to set a name that can be used throughout a section of a template or many nested views and/or components.
65 |
66 | ```jinja
67 |
68 | {{with _session.user as #user}}
69 |
70 | {{/with}}
71 |
72 | {{with {name: 'Jim', age: 32} as #user}}
73 |
74 | {{/with}}
75 |
76 |
77 |
{{#user.name}}
78 |
{{#user.age}}
79 | ```
80 |
81 | # On
82 |
83 | To clear UI state, to optimize performance by rendering larger sections, or to work around issues with template bindings not rendering often enough, an `{{on}}` block can provide more control. Its contents will re-render whenever any of its paths change.
84 |
85 | ```jinja
86 | {{on #profile.id}}
87 |
{{#profile.name}}
88 |
89 | {{/on}}
90 |
91 | {{on first, second, third}}
92 |
93 | {{/on}}
94 | ```
95 |
96 | # Unbound and bound
97 |
98 | Bindings are created by default for all template expressions. To render an initial value only and not create bindings, the `{{unbound}}` block may be wrapped around a template section. Bindings can be toggled back on with a `{{bound}}` block.
99 |
100 | ```jinja
101 | {{unbound}}
102 |
103 | {{bound}}
104 |
105 | {{/bound}}
106 | {{/unbound}}
107 | ```
108 |
--------------------------------------------------------------------------------
/docs/views/template-syntax/escaping.md:
--------------------------------------------------------------------------------
1 | ---
2 | layout: default
3 | title: Escaping
4 | parent: Template syntax
5 | grand_parent: Views
6 | ---
7 |
8 | # Escaping
9 |
10 | Derby escapes values as required when it renders HTML. Escaping is relative to whether it is rendering inside of an HTML attribute or text. For example, Derby will escape `"` as `"` inside of an HTML attribute, and it will escape `<` as `<` inside of text.
11 |
12 | Derby's templates also follow HTML escaping rules. Derby will parse the string `{{` as the start of a template tag, so if you wish to write this value in an attribute or the text of a Derby template, you can use the HTML entity equivalent: `{{`.
13 |
14 | ## Rendering unescaped HTML
15 |
16 | The `unescaped` keyword may be used to render an HTML string without escaping. It is *very unlikely* that you should use this feature. Derby has many ways of dynamically creating views. Unescaped HTML is unsafe, is typically slower, and is rarely necessary with Derby. This feature is intended only for rendering the output of a well-tested library that produces sanitized HTML, such as [Google Caja](https://developers.google.com/caja/).
17 |
18 | ```jinja
19 |
20 |
{{unescaped rawHtml}}
21 | ```
22 |
23 | Instead, prefer passing in a template as an attribute or dynamically selecting a view in most cases.
24 |
25 | ```jinja
26 |
27 |
28 |
29 | Custom HTML for this user!
30 |
31 |
32 |
33 |
34 |
35 | {{@content}}
36 |
37 | ```
38 |
39 | ```jinja
40 |
41 |
42 | ```
43 |
44 | If you need completely dynamic generation of HTML (such as implementing an HTML or template editor in your application), it is even possible to use Derby's HTML parser and pass the returned Template object to your views. Derby will render this HTML safely without any Cross-site Scripting (XSS) concerns. You'll even be able to use Derby's template syntax! See how this is done in the [Derby render example](https://github.com/derbyjs/derby-examples/blob/master/render/index.js#L29), which powers the live template editor on the [DerbyJS home page](https://derbyjs.com/).
45 |
--------------------------------------------------------------------------------
/docs/views/template-syntax/functions-and-events.md:
--------------------------------------------------------------------------------
1 | ---
2 | layout: default
3 | title: Functions and events
4 | parent: Template syntax
5 | grand_parent: Views
6 | ---
7 |
8 | # Functions and events
9 |
10 | Attributes beginning with `on-` add listeners to DOM events and component events. Under the hood, events on elements are added with `element.addEventListener()` and events on components are added with `component.on()`. Adding events declaritively with attributes is easier than CSS selectors and less prone to unexpectedly breaking when refactoring templates or classes for styling.
11 |
12 | ```jinja
13 |
14 |
15 | ```
16 |
17 | ```js
18 | // Equivalent to:
19 | input.addEventListener('mousedown', function(event) {
20 | self.mousedownInput(event);
21 | }, false);
22 | input.addEventListener('blur', function(event) {
23 | self.blurInput();
24 | self.update();
25 | }, false);
26 | ```
27 |
28 | ## View functions
29 |
30 | Functions are looked up on the current component's controller, the page, and the global, in that order. The majority of functions are defined on component prototypes, generic shared utility functions are defined on the page prototype, and the global provides access to functions like `new Date()` and `console.log()`.
31 |
32 | ```jinja
33 |
34 |
35 |
36 | {{sum(1, 2, 4)}}
37 |
38 | {{console.log('rendering value', value)}}
39 | ```
40 |
41 | ```js
42 | // component prototypes are where most functions are defined
43 | UserList.prototype.delUser = function(userId) {
44 | this.users.del(userId);
45 | };
46 |
47 | // app.proto is the prototype for all pages created by the app
48 | app.proto.sum = function() {
49 | var sum = 0;
50 | for (var i = 0; i < arguments.length; i++) {
51 | sum += arguments[i];
52 | }
53 | return sum;
54 | };
55 | ```
56 |
57 | ### Component events
58 | Components support custom events. Dashes are transformed into camelCase.
59 |
60 | See the [component events](../../components/events) documentation for more detail on using events and component functions.
61 | ```jinja
62 |
63 | ```
64 |
65 | ```js
66 | // Equivalent to:
67 | modal.on('close', function() {
68 | self.reset();
69 | });
70 | modal.on('fullView', function() {
71 | back.fade();
72 | });
73 | ```
74 |
75 |
76 | ### Special HTML rules
77 |
78 | As a convenience, an `on-click` listener can be added to a link without an `href`. Derby will add an `href="#"` and prevent the default action automatically if no href is specified.
79 |
80 | ```jinja
81 |
82 | Hi
83 | ```
84 |
85 | HTML forms have very useful behavior, but their default action on submit will navigate away from the current page. If an `on-submit` handler is added to a form with no `action` attribute, the default will be prevented.
86 |
87 | ```jinja
88 |
95 | ```
96 |
97 | ### DOM event arguments
98 |
99 | For functions invoked by DOM events only, the special arguments `$event` or `$element` may be specified. The `$event` argument is the DOM Event object passed to the listener function for `addEventListener()`. The `$element` argument is a reference to the element on which the listener attribute is specified. These arguments are only passed to functions if explicitly specified.
100 |
101 | ```jinja
102 |
112 | ```
113 |
114 | ```js
115 | UserList.prototype.clickRow = function(e, tr) {
116 | // Ignore clicks on or in links
117 | var node = e.target;
118 | while (node && node !== tr) {
119 | if (node.tagName === 'A') return;
120 | node = node.parentNode;
121 | }
122 | // Cancel the original click event inside of the row
123 | e.stopPropagation();
124 | // Pretend like the click happened on the first link in the row
125 | var event = new MouseEvent('click', e);
126 | var link = tr.querySelector('a');
127 | if (link) link.dispatchEvent(event);
128 | };
129 | ```
130 |
131 | ## Scoped model arguments
132 |
133 | Functions can be passed the value of any view path. In some cases, it can be convenient to get a [scoped model](../../models/paths#scoped-models) to the view name instead. To pass a scoped model, you can wrap the view path in `$at()`. Instead of getting the value for a view path, this will return a scoped model. It will return undefined if no scoped model can be created for a view path.
134 |
135 | ```jinja
136 |
137 | ```
138 |
139 | ```js
140 | app.proto.toggle = function(scoped) {
141 | scoped.set(!scoped.get());
142 | };
143 | ```
--------------------------------------------------------------------------------
/docs/views/template-syntax/literals.md:
--------------------------------------------------------------------------------
1 | ---
2 | layout: default
3 | title: Literals
4 | parent: Template syntax
5 | grand_parent: Views
6 | ---
7 |
8 | # Literals
9 |
10 | Derby supports creating JavaScript literals in templates. The syntax is identical to JavaScript, except that identifiers within literals are parsed as [view paths](paths) instead of JavaScript variables. Derby parses template expressions with Esprima, so its coverage of JavaScript expression syntax is comprehensive.
11 |
12 | ## Simple literals
13 |
14 | ```jinja
15 |
16 | {{0}}
17 | {{1.1e3}}
18 | {{0xff}}
19 |
20 | {{true}}
21 | {{false}}
22 |
23 | {{'Hi'}}
24 | {{"Hey"}}
25 |
26 | {{/([0-9])+/}}
27 |
28 | {{null}}
29 |
30 | {{undefined}}
31 |
32 | {{ [0, 1, 2] }}
33 |
34 | {{ {name: 'Jim'} }}
35 | ```
36 |
37 | For greater efficiency, simple literals are instantiated at the time of parsing. Object literals created at parse time will be passed by reference to controller functions, so be careful not to modify them.
38 |
39 | ```jinja
40 |
41 |
42 | ```
43 |
44 | It is possible to iterate over object literals in template expressions. In most cases, it makes more sense to define constants in the controller or use HTML, but this can be handy when prototyping and debugging.
45 |
46 | ```jinja
47 |
56 | {{/with}}
57 | ```
58 |
59 | ## Literals containing paths
60 |
61 | Literals containing paths are created at render time and populated with the appropriate values from the model.
62 |
63 | ```jinja
64 |
65 | {{each [first, 1, 2, 3] as #item}}
66 |
{{#item}}
67 | {{/each}}
68 |
69 |
70 |
71 | ```
--------------------------------------------------------------------------------
/docs/views/template-syntax/operators.md:
--------------------------------------------------------------------------------
1 | ---
2 | layout: default
3 | title: Operators
4 | parent: Template syntax
5 | grand_parent: Views
6 | ---
7 |
8 | # Operators
9 |
10 | All non-assigment JavaScript operators and using parentheses for grouping expressions are supported. They work exactly the same as they do in Javascript. Operators that do an assignment, such as the `++` increment operator, are not supported. This avoids rendering having side effects.
11 |
12 | ```jinja
13 |
14 | {{-value}}
15 |
16 | {{+value}}
17 |
18 |
19 | {{left + right}}
20 |
21 | {{left - right}}
22 |
23 | {{left * right}}
24 |
25 | {{left / right}}
26 |
27 | {{left % right}}
28 |
29 |
30 | {{!value}}
31 |
32 | {{left || right}}
33 |
34 | {{left && right}}
35 |
36 |
37 | {{~value}}
38 |
39 | {{left | right}}
40 |
41 | {{left & right}}
42 |
43 | {{left ^ right}}
44 |
45 |
46 | {{left << right}}
47 |
48 | {{left >> right}}
49 |
50 | {{left >>> right}}
51 |
52 |
53 | {{left === right}}
54 |
55 | {{left !== right}}
56 |
57 | {{left == right}}
58 |
59 | {{left != right}}
60 |
61 |
62 | {{left < right}}
63 |
64 | {{left > right}}
65 |
66 | {{left <= right}}
67 |
68 | {{left >= right}}
69 |
70 |
71 | {{typeof value}}
72 |
73 | {{left instanceof right}}
74 |
75 | {{left in right}}
76 |
77 |
78 | {{test ? consequent : alternate}}
79 |
80 |
81 | {{a, b, c, d}}
82 | ```
83 |
84 | ## Two-way operators
85 |
86 | In addition to getting values, operators for which there is a well defined opposite support two-way data bindings. These setters will make the relationship consistent with the value that is set.
87 |
88 | ```jinja
89 |
90 |
93 |
94 |
99 | {{each options as #option}}
100 |
103 | {{/each}}
104 | ```
105 |
--------------------------------------------------------------------------------
/docs/views/template-syntax/paths.md:
--------------------------------------------------------------------------------
1 | ---
2 | layout: default
3 | title: Paths
4 | parent: Template syntax
5 | grand_parent: Views
6 | ---
7 |
8 | # Paths
9 |
10 | Template paths use JavaScript syntax with a few small modifications.
11 |
12 | ## Model values
13 |
14 | What would be identifiers for variable names in JavaScript get a value from the model and bind to any updates. If the path returns null or undefined, nothing is rendered.
15 |
16 | Examples of rendering model values:
17 |
18 | ```jinja
19 | {{user.name}}
20 |
21 | {{user.bestFriends[0].name}}
22 |
23 | {{users[userId].name}}
24 | ```
25 |
26 | ```js
27 | model.get('user.name');
28 |
29 | model.get('user.bestFriends.0.name');
30 |
31 | var userId = model.get('userId');
32 | model.get('users.' + userId + '.name');
33 | ```
34 |
35 | ## Attributes
36 |
37 | Values are passed into views with attributes. Within the view, these values are accessed via paths that start with an at sign (`@`). In addition, there is an `@content` attribute created for any content inside of a view tag.
38 |
39 | ```jinja
40 |
41 |
54 | ```
55 |
56 | See [View attributes](view-attributes) for additional detail on passing data to views.
57 |
58 | ## Aliases
59 |
60 | Aliases label path expressions. They must begin with a hash (`#`) character to make it more obvious whether a path is an alias or a model value. Each of the block types support defining aliases with the `as` keyword.
61 |
62 | Aliases make it possible to refer to the scope of the current block or a parent block.
63 |
64 | ```jinja
65 | {{with user as #user}}
66 |
{{#user.name}}
67 |
{{#user.headline}}
68 | {{if #user.friendList as #friendList}}
69 |
70 |
Friends of {{#user.name}}
71 |
72 | {{each #friendList as #friend}}
73 |
{{#friend.name}}
74 | {{/each}}
75 |
76 | {{/if}}
77 | {{/with}}
78 | ```
79 |
80 | ## Relative paths - DEPRECATED
81 |
82 | Relative view paths begin with `this`. They refer to the expression in the containing block.
83 |
84 | Aliases are preferred to relative paths, as they are more clear. Relative paths came from implementing a syntax inspired by Handlebars, but Derby has been moving toward increased consistency with JavaScript, and the alternate use of the keyword `this` is confusing. Expect that this feature will be removed in a future version of Derby.
85 |
86 | ```jinja
87 | {{with user}}
88 |
{{this.name}}
89 |
{{this.headline}}
90 | {{if this.friendList}}
91 |
Friends
92 |
93 | {{each this}}
94 |
{{this.name}}
95 | {{/each}}
96 |
97 | {{/if}}
98 | {{/with}}
99 | ```
100 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "derby",
3 | "description": "MVC framework making it easy to write realtime, collaborative applications that run in both Node.js and browsers.",
4 | "version": "4.2.3",
5 | "homepage": "http://derbyjs.com/",
6 | "repository": {
7 | "type": "git",
8 | "url": "git://github.com/derbyjs/derby.git"
9 | },
10 | "publishConfig": {
11 | "access": "public"
12 | },
13 | "main": "dist/index.js",
14 | "exports": {
15 | ".": "./dist/index.js",
16 | "./components": "./dist/components.js",
17 | "./dist/components": "./dist/components.js",
18 | "./parsing": "./dist/parsing/index.js",
19 | "./dist/parsing": "./dist/parsing/index.js",
20 | "./templates": "./dist/templates/index.js",
21 | "./dist/templates": "./dist/templates/index.js",
22 | "./App": "./dist/App.js",
23 | "./AppForServer": "./dist/AppForServer.js",
24 | "./server": "./dist/server.js",
25 | "./dist/server": "./dist/server.js",
26 | "./Page": "./dist/Page.js",
27 | "./test-utils": "./dist/test-utils/index.js",
28 | "./test-utils/*": "./dist/test-utils/*.js",
29 | "./dist/test-utils": "./dist/test-utils/index.js",
30 | "./dist/test-utils/*": "./dist/test-utils/*.js",
31 | "./file-utils": "./dist/files.js"
32 | },
33 | "files": [
34 | "dist/",
35 | "test-utils/"
36 | ],
37 | "scripts": {
38 | "build": "node_modules/.bin/tsc",
39 | "checks": "npm run lint && npm test",
40 | "docs": "npx typedoc",
41 | "lint": "npx eslint src/**/*.ts test/**/*.js",
42 | "lint:ts": "npx eslint src/**/*.ts",
43 | "lint:fix": "npm run lint:ts -- --fix",
44 | "prepare": "npm run build",
45 | "test": "npx mocha -r ts-node/register 'test/all/**/*.mocha.*' 'test/dom/**/*.mocha.*' 'test/server/**/*.mocha.*'",
46 | "test-browser": "node test/server.js"
47 | },
48 | "dependencies": {
49 | "chokidar": "^3.5.3",
50 | "esprima-derby": "^0.1.0",
51 | "html-util": "^0.2.3",
52 | "qs": "^6.11.0",
53 | "resolve": "^1.22.1",
54 | "serialize-object": "^1.0.0",
55 | "tracks": "^0.5.8"
56 | },
57 | "devDependencies": {
58 | "@types/chai": "^4.3.11",
59 | "@types/esprima-derby": "npm:@types/esprima@^4.0.3",
60 | "@types/estree": "^1.0.1",
61 | "@types/express": "^4.17.18",
62 | "@types/mocha": "^10.0.6",
63 | "@types/node": "^20.3.1",
64 | "@types/qs": "^6.9.11",
65 | "@types/resolve": "^1.20.6",
66 | "@types/sharedb": "^3.3.10",
67 | "@typescript-eslint/eslint-plugin": "^6.2.1",
68 | "@typescript-eslint/parser": "^6.2.1",
69 | "async": "^3.2.4",
70 | "browserify": "^17.0.0",
71 | "chai": "^4.3.6",
72 | "eslint": "^8.37.0",
73 | "eslint-config-prettier": "^9.0.0",
74 | "eslint-plugin-import": "^2.28.0",
75 | "eslint-plugin-prettier": "^5.0.0",
76 | "express": "^4.18.1",
77 | "jsdom": "^20.0.1",
78 | "mocha": "^10.0.0",
79 | "prettier": "^3.0.1",
80 | "racer": "^v2.0.0-beta.11",
81 | "sinon": "^18.0.0",
82 | "ts-node": "^10.9.2",
83 | "typedoc": "^0.25.13",
84 | "typedoc-plugin-mdn-links": "^3.1.28",
85 | "typedoc-plugin-missing-exports": "^2.2.0",
86 | "typescript": "~5.1.3"
87 | },
88 | "peerDependencies": {
89 | "racer": "^v2.0.0-beta.8"
90 | },
91 | "optionalDependencies": {},
92 | "bugs": {
93 | "url": "https://github.com/derbyjs/derby/issues"
94 | },
95 | "directories": {
96 | "doc": "docs",
97 | "test": "test"
98 | },
99 | "author": "Nate Smith",
100 | "license": "MIT"
101 | }
102 |
--------------------------------------------------------------------------------
/src/Controller.ts:
--------------------------------------------------------------------------------
1 | import { EventEmitter } from 'events';
2 |
3 | import { DefualtType, type ChildModel } from 'racer';
4 |
5 | import { type App } from './App';
6 | import { type ComponentModelData } from './components';
7 | import { Dom } from './Dom';
8 | import { Page } from './Page';
9 |
10 | export class Controller extends EventEmitter {
11 | dom: Dom;
12 | app: App;
13 | page: Page;
14 | /**
15 | * Model scoped to this instance's "private" data.
16 | */
17 | model: ChildModel;
18 | markerNode: Node;
19 |
20 | constructor(app: App, page: Page, model: ChildModel) {
21 | super();
22 | this.dom = new Dom(this);
23 | this.app = app;
24 | this.model = model;
25 | this.page = page;
26 | (model.data as ComponentModelData).$controller = this;
27 | }
28 |
29 | emitCancellable(...args: unknown[]) {
30 | let cancelled = false;
31 | function cancel() {
32 | cancelled = true;
33 | }
34 |
35 | args.push(cancel);
36 | // eslint-disable-next-line prefer-spread
37 | this.emit.apply(this, args);
38 |
39 | return cancelled;
40 | }
41 |
42 | emitDelayable(...args: unknown[]) {
43 | const callback: () => void = args.pop() as any;
44 |
45 | let delayed = false;
46 | function delay() {
47 | delayed = true;
48 | return callback;
49 | }
50 |
51 | args.push(delay);
52 | // eslint-disable-next-line prefer-spread
53 | this.emit.apply(this, args);
54 | if (!delayed) callback();
55 |
56 | return delayed;
57 | }
58 | }
59 |
--------------------------------------------------------------------------------
/src/Derby.ts:
--------------------------------------------------------------------------------
1 | /*
2 | * Derby.js
3 | * Meant to be the entry point for the framework.
4 | *
5 | */
6 | import { Racer, util } from 'racer';
7 |
8 | import { AppForClient, type App, type AppOptions } from './App';
9 | import { Component } from './components';
10 | import { PageForClient } from './Page';
11 |
12 | export abstract class Derby extends Racer {
13 | Component = Component;
14 |
15 | abstract createApp(name?: string, filename?: string, options?: AppOptions): App
16 | }
17 |
18 | export class DerbyForClient extends Derby {
19 | App = AppForClient;
20 | Page = PageForClient;
21 |
22 | createApp(name?: string, filename?: string, options?: AppOptions) {
23 | return new this.App(this, name, filename, options);
24 | }
25 | }
26 |
27 | if (!util.isServer) {
28 | module.require('./documentListeners').add(document);
29 | }
30 |
--------------------------------------------------------------------------------
/src/DerbyForServer.ts:
--------------------------------------------------------------------------------
1 | import { util } from 'racer';
2 |
3 | import { App } from './App';
4 | import { AppForServer } from './AppForServer';
5 | import { Derby } from './Derby';
6 | import { PageForServer } from './PageForServer';
7 |
8 | util.isProduction = process.env.NODE_ENV === 'production';
9 |
10 | export class DerbyForServer extends Derby {
11 | App = AppForServer;
12 | Page = PageForServer;
13 |
14 | createApp(name: string, filename: string, options: any): App {
15 | return new this.App(this, name, filename, options);
16 | }
17 | }
18 |
--------------------------------------------------------------------------------
/src/Dom.ts:
--------------------------------------------------------------------------------
1 | import { type Controller } from './Controller';
2 |
3 | type ListenerFn = K extends 'destroy'
4 | ? () => void
5 | : (event: EventMap[K]) => void;
6 |
7 | interface EventMap extends DocumentEventMap {
8 | 'destroy': never;
9 | }
10 |
11 | export class Dom {
12 | controller: Controller;
13 | _listeners: DomListener[];
14 |
15 | constructor(controller) {
16 | this.controller = controller;
17 | this._listeners = null;
18 | }
19 |
20 | _initListeners() {
21 | this.controller.on('destroy', () => {
22 | const listeners = this._listeners;
23 | if (!listeners) return;
24 | for (let i = listeners.length; i--;) {
25 | listeners[i].remove();
26 | }
27 | this._listeners = null;
28 | });
29 | return this._listeners = [];
30 | }
31 |
32 | _listenerIndex(domListener) {
33 | const listeners = this._listeners;
34 | if (!listeners) return -1;
35 | for (let i = listeners.length; i--;) {
36 | if (listeners[i].equals(domListener)) return i;
37 | }
38 | return -1;
39 | }
40 |
41 | /**
42 | * Adds a DOM event listener that will get cleaned up when this component is cleaned up.
43 | *
44 | * @param type - Name of the DOM event to listen to
45 | * @param target - Optional target to add the DOM listener to. If not provided, the target is `document`.
46 | * @param listener - Listener to be called when the DOM event occurs
47 | * @param useCapture - Optional, defaults to false. If true, add the listener as a capturing listener.
48 | *
49 | * @see https://developer.mozilla.org/en-US/docs/Web/API/EventTarget/addEventListener
50 | */
51 | addListener(
52 | type: K,
53 | target: EventTarget,
54 | listener: ListenerFn,
55 | useCapture?: boolean
56 | ): void;
57 | addListener(
58 | type: K,
59 | listener: ListenerFn,
60 | useCapture?: boolean
61 | ): void;
62 | addListener(
63 | type: K,
64 | target: EventTarget | (ListenerFn),
65 | listener?: (ListenerFn) | boolean,
66 | useCapture?: boolean,
67 | ): void {
68 | if (typeof target === 'function') {
69 | useCapture = !!(listener as boolean);
70 | listener = target as ListenerFn;
71 | target = document;
72 | }
73 | const domListener = (type === 'destroy')
74 | ? new DestroyListener(target, listener as ListenerFn<'destroy'>)
75 | : new DomListener(type, target, listener as ListenerFn, useCapture);
76 | if (-1 === this._listenerIndex(domListener)) {
77 | const listeners = this._listeners || this._initListeners();
78 | listeners.push(domListener);
79 | }
80 | domListener.add();
81 | }
82 |
83 | /**
84 | * Adds a DOM event listener that will get cleaned up when this component is cleaned up.
85 | *
86 | * @param type - Name of the DOM event to listen to
87 | * @param target - Optional target to add the DOM listener to. If not provided, the target is `document`.
88 | * @param listener - Listener to be called when the DOM event occurs
89 | * @param useCapture - Optional, defaults to false. If true, add the listener as a capturing listener.
90 | *
91 | * @see https://developer.mozilla.org/en-US/docs/Web/API/EventTarget/addEventListener
92 | */
93 | on(
94 | type: K,
95 | target: EventTarget,
96 | listener: ListenerFn,
97 | useCapture?: boolean
98 | ): void;
99 | on(
100 | type: K,
101 | listener: ListenerFn,
102 | useCapture?: boolean
103 | ): void;
104 | on(
105 | type: K,
106 | target: EventTarget | (ListenerFn),
107 | listener?: (ListenerFn) | boolean,
108 | useCapture?: boolean,
109 | ): void {
110 | if (typeof target === 'function') {
111 | listener = target as ListenerFn;
112 | target = document;
113 | }
114 | this.addListener(type, target, listener as ListenerFn, useCapture);
115 | }
116 |
117 | /**
118 | * Adds a one-time DOM event listener that will get cleaned up when this component is cleaned up.
119 | *
120 | * @param type - Name of the DOM event to listen to
121 | * @param target - Optional target to add the DOM listener to. If not provided, the target is `document`.
122 | * @param listener - Listener to be called when the DOM event occurs
123 | * @param useCapture - Optional, defaults to false. If true, add the listener as a capturing listener.
124 | *
125 | * @see https://developer.mozilla.org/en-US/docs/Web/API/EventTarget/addEventListener
126 | */
127 | once(
128 | type: K,
129 | target: EventTarget,
130 | listener: ListenerFn,
131 | useCapture?: boolean
132 | ): void;
133 | once(
134 | type: K,
135 | listener: ListenerFn,
136 | useCapture?: boolean
137 | ): void;
138 | once(
139 | type: K,
140 | target: EventTarget | (ListenerFn),
141 | listener?: (ListenerFn) | boolean,
142 | useCapture?: boolean,
143 | ): void {
144 | if (typeof target === 'function') {
145 | useCapture = !!(listener);
146 | listener = target as ListenerFn;
147 | target = document;
148 | }
149 | const wrappedListener = ((...args) => {
150 | this.removeListener(type, target as EventTarget, wrappedListener, useCapture);
151 | return (listener as ListenerFn).apply(this, args);
152 | }) as ListenerFn;
153 | this.addListener(type, target, wrappedListener, useCapture);
154 | }
155 |
156 | /**
157 | * Removes a DOM event listener that was added via `#addListener`, `#on`, or `#once`, using the same
158 | * parameters as those methods.
159 | *
160 | * @param type - Name of the DOM event
161 | * @param target - Optional target for the DOM listener. If not provided, the target is `document`.
162 | * @param listener - Listener function that was passed to `#addListener`, `#on`, or `#once`.
163 | * @param useCapture - Optional, defaults to false. If true, removes a capturing listener.
164 | *
165 | * @see https://developer.mozilla.org/en-US/docs/Web/API/EventTarget/addEventListener
166 | */
167 | removeListener(
168 | type: K,
169 | target: EventTarget,
170 | listener: ListenerFn,
171 | useCapture?: boolean
172 | ): void;
173 | removeListener(
174 | type: K,
175 | listener: ListenerFn,
176 | useCapture?: boolean
177 | ): void;
178 | removeListener(
179 | type: K,
180 | target: EventTarget | ListenerFn,
181 | listener?: (ListenerFn) | boolean,
182 | useCapture?: boolean,
183 | ): void {
184 | if (typeof target === 'function') {
185 | useCapture = !!(listener);
186 | listener = target;
187 | target = document;
188 | }
189 | const domListener = new DomListener(type, target, listener as ListenerFn, useCapture);
190 | domListener.remove();
191 | const i = this._listenerIndex(domListener);
192 | if (i > -1) this._listeners.splice(i, 1);
193 | }
194 | }
195 |
196 | export class DomListener{
197 | type: string;
198 | target: EventTarget;
199 | listener: ListenerFn;
200 | useCapture: boolean;
201 |
202 | constructor(type: string, target: EventTarget, listener: ListenerFn, useCapture?: boolean) {
203 | this.type = type;
204 | this.target = target;
205 | this.listener = listener;
206 | this.useCapture = !!useCapture;
207 | }
208 |
209 | equals(domListener) {
210 | return this.listener === domListener.listener &&
211 | this.target === domListener.target &&
212 | this.type === domListener.type &&
213 | this.useCapture === domListener.useCapture;
214 | }
215 |
216 | add() {
217 | this.target.addEventListener(this.type, this.listener, this.useCapture);
218 | }
219 |
220 | remove() {
221 | this.target.removeEventListener(this.type, this.listener, this.useCapture);
222 | }
223 | }
224 |
225 | export class DestroyListener extends DomListener<'destroy'> {
226 | constructor(target: EventTarget, listener: ListenerFn<'destroy'>) {
227 | super('destroy', target, listener);
228 | DomListener.call(this, 'destroy', target, listener);
229 | }
230 |
231 | add() {
232 | const listeners = this.target.$destroyListeners || (this.target.$destroyListeners = []);
233 | if (listeners.indexOf(this.listener) === -1) {
234 | listeners.push(this.listener);
235 | }
236 | }
237 |
238 | remove() {
239 | const listeners = this.target.$destroyListeners;
240 | if (!listeners) return;
241 | const index = listeners.indexOf(this.listener);
242 | if (index !== -1) {
243 | listeners.splice(index, 1);
244 | }
245 | }
246 | }
247 |
--------------------------------------------------------------------------------
/src/PageForServer.ts:
--------------------------------------------------------------------------------
1 | import type { Request, Response } from 'express';
2 | import { type Model } from 'racer';
3 |
4 | import { type AppForServer } from './AppForServer';
5 | import { Page } from './Page';
6 | import { type PageParams } from './routes';
7 |
8 | declare module 'racer' {
9 | interface Model {
10 | hasErrored?: boolean;
11 | }
12 | }
13 |
14 | export class PageForServer extends Page {
15 | req: Request;
16 | res: Response;
17 | page: PageForServer;
18 |
19 | constructor(app: AppForServer, model: Model, req: Request, res: Response) {
20 | super(app, model);
21 | this.req = req;
22 | this.res = res;
23 | this.page = this;
24 | }
25 |
26 | render(ns?: string, status?: number) {
27 | this.app.emit('render', this);
28 |
29 | if (status) this.res.statusCode = status;
30 | // Prevent the browser from storing the HTML response in its back cache, since
31 | // that will cause it to render with the data from the initial load first
32 | this.res.setHeader('Cache-Control', 'no-store');
33 | // Set HTML utf-8 content type unless already set
34 | if (!this.res.getHeader('Content-Type')) {
35 | this.res.setHeader('Content-Type', 'text/html; charset=utf-8');
36 | }
37 |
38 | this._setRenderParams(ns);
39 | const pageHtml = this.get('Page', ns);
40 | this.res.write(pageHtml);
41 | this.app.emit('htmlDone', this);
42 |
43 | this.res.write('' + tailHtml);
54 | this.app.emit('routeDone', this, 'render');
55 | });
56 | }
57 |
58 | renderStatic(status?: number, ns?: string) {
59 | if (typeof status !== 'number') {
60 | ns = status;
61 | status = null;
62 | }
63 | this.app.emit('renderStatic', this);
64 |
65 | if (status) this.res.statusCode = status;
66 | this.params = pageParams(this.req);
67 | this._setRenderParams(ns);
68 | const pageHtml = this.get('Page', ns);
69 | const tailHtml = this.get('Tail', ns);
70 | this.res.send(pageHtml + tailHtml);
71 | this.app.emit('routeDone', this, 'renderStatic');
72 | }
73 |
74 | // Don't register any listeners on the server
75 | // _addListeners() {}
76 | }
77 |
78 | function stringifyBundle(bundle) {
79 | const json = JSON.stringify(bundle);
80 | return json.replace(/<[/!]/g, function(match) {
81 | // Replace the end tag sequence with an equivalent JSON string to make
82 | // sure the script is not prematurely closed
83 | if (match === '') return '<\\/';
84 | // Replace the start of an HTML comment tag sequence with an equivalent
85 | // JSON string
86 | if (match === ' {
56 | compress?: boolean;
57 | }
58 |
59 | export function loadStylesSync(app: AppForServer, sourceFilename: string, options?: StyleCompilerOptions) {
60 | if (options == null) {
61 | options = { compress: racer.util.isProduction };
62 | }
63 | const resolved = resolve.sync(sourceFilename, {
64 | extensions: app.styleExtensions,
65 | packageFilter: deleteMain}
66 | );
67 | if (!resolved) {
68 | throw new Error('Style file not found: ' + sourceFilename);
69 | }
70 | const extension = path.extname(resolved);
71 | const compiler = app.compilers[extension];
72 | if (!compiler) {
73 | throw new Error('Unable to find compiler for: ' + extension);
74 | }
75 | const file = fs.readFileSync(resolved, 'utf8');
76 | return compiler(file, resolved, options);
77 | }
78 |
79 | // Resolve will use a main path from a package.json if found. Main is the
80 | // entry point for javascript in a module, so this will mistakenly cause us to
81 | // load the JS file instead of a view or style file in some cases. This package
82 | // filter deletes the main property so that the normal file name lookup happens
83 | function deleteMain() {
84 | return {};
85 | }
86 |
--------------------------------------------------------------------------------
/src/index.ts:
--------------------------------------------------------------------------------
1 | import { util } from 'racer';
2 |
3 | import { type AppOptions } from './App';
4 | import { DerbyForClient, type Derby } from './Derby';
5 |
6 | export { AppForClient, App } from './App';
7 | export type { AppForServer } from './AppForServer';
8 | export { Dom } from './Dom';
9 | export { Page, PageForClient } from './Page';
10 | export type { PageForServer } from './PageForServer';
11 | export {
12 | Component,
13 | ComponentModelData,
14 | type ComponentConstructor,
15 | type ComponentViewDefinition,
16 | } from './components';
17 | export { type Context } from './templates/contexts';
18 | export { type PageParams, type QueryParams } from './routes';
19 |
20 | const DerbyClass = util.isServer
21 | ? util.serverRequire(module, './DerbyForServer').DerbyForServer
22 | : DerbyForClient;
23 | const instance: Derby = new DerbyClass();
24 |
25 | export function createApp(name?: string, file?: string, options?: AppOptions) {
26 | return instance.createApp(name, file, options);
27 | }
28 |
29 | export function use(plugin: (derby: Derby, options?: T) => Derby, options?: T) {
30 | return instance.use(plugin, options);
31 | }
32 |
33 | export {
34 | DerbyForClient as Derby,
35 | util,
36 | }
37 |
--------------------------------------------------------------------------------
/src/parsing/markup.ts:
--------------------------------------------------------------------------------
1 | import { EventEmitter } from 'events';
2 |
3 | import { createPathExpression } from './createPathExpression';
4 | import { templates } from '../templates';
5 |
6 | class MarkupParser extends EventEmitter { }
7 |
8 | // TODO: Should be its own module
9 | export const markup = new MarkupParser();
10 |
11 | markup.on('element:a', function(template) {
12 | if (hasListenerFor(template, 'click')) {
13 | const attributes = template.attributes || (template.attributes = {});
14 | if (!attributes.href) {
15 | attributes.href = new templates.Attribute('#');
16 | addListener(template, 'click', '$preventDefault($event)');
17 | }
18 | }
19 | });
20 |
21 | markup.on('element:form', function(template) {
22 | if (hasListenerFor(template, 'submit')) {
23 | addListener(template, 'submit', '$preventDefault($event)');
24 | }
25 | });
26 |
27 | function hasListenerFor(template, eventName) {
28 | const hooks = template.hooks;
29 | if (!hooks) return false;
30 | for (let i = 0, len = hooks.length; i < len; i++) {
31 | const hook = hooks[i];
32 | if (hook instanceof templates.ElementOn && hook.name === eventName) {
33 | return true;
34 | }
35 | }
36 | return false;
37 | }
38 |
39 | function addListener(template, eventName, source) {
40 | const hooks = template.hooks || (template.hooks = []);
41 | const expression = createPathExpression(source);
42 | hooks.push(new templates.ElementOn(eventName, expression));
43 | }
44 |
--------------------------------------------------------------------------------
/src/routes.ts:
--------------------------------------------------------------------------------
1 | import { RootModel, type Model } from 'racer';
2 | import tracks = require('tracks');
3 |
4 | import { type App } from './App';
5 | import { type Page } from './Page';
6 |
7 | export function routes(app: App) {
8 | return tracks.setup(app);
9 | }
10 |
11 | // From tracks/lib/router.js
12 | export interface PageParams extends ReadonlyArray {
13 | /**
14 | * Previous URL path + querystring
15 | */
16 | previous?: string;
17 |
18 | /**
19 | * Current URL path + querystring
20 | */
21 | url: string;
22 |
23 | /**
24 | * Parsed query parameters
25 | * @see https://www.npmjs.com/package/qs
26 | */
27 | query: Readonly;
28 |
29 | /**
30 | * HTTP method for the currently rendered page
31 | */
32 | method: string;
33 | routes: unknown;
34 | }
35 |
36 | export interface QueryParams {
37 | [param: string]: unknown;
38 | }
39 |
40 | export interface TransitionalRoute {
41 | from: string;
42 | to: string;
43 | }
44 |
45 | export interface RouteMethod {
46 | (routePattern: string, routeHandler: RouteHandler): void;
47 | (routePattern: TransitionalRoute, routeHandler: TransitionalRouteHandler): void;
48 | }
49 |
50 | export interface RouteHandler {
51 | (page: Page, model: RootModel, params: PageParams, next: (err?: Error) => void): void;
52 | }
53 |
54 | export interface TransitionalRouteHandler {
55 | (
56 | page: Page,
57 | model: RootModel,
58 | params: PageParams,
59 | next: (err?: Error) => void,
60 | done: () => void
61 | ): void;
62 | }
63 |
64 | declare module './App' {
65 | interface App {
66 | del: RouteMethod;
67 | get: RouteMethod;
68 | history: {
69 | push: (url: string, render?: boolean, state?: object, e?: any) => void,
70 | replace: (url: string, render?: boolean, state?: object, e?: any) => void,
71 | refresh: () => void,
72 | };
73 | post: RouteMethod;
74 | put: RouteMethod;
75 | }
76 | }
77 |
78 | declare module './Page' {
79 | interface Page {
80 | redirect(url: string, status?: number): void;
81 | }
82 | }
83 |
--------------------------------------------------------------------------------
/src/server.ts:
--------------------------------------------------------------------------------
1 | // import as namespace to avoid transform as cluster.default
2 | import * as cluster from 'node:cluster';
3 |
4 | const isProduction = process.env.NODE_ENV === 'production';
5 |
6 | export function run(createServer: () => void) {
7 | // In production
8 | if (isProduction) return createServer();
9 | // @ts-expect-error imported without default; need type update?
10 | if (cluster.isPrimary) {
11 | console.log('Primary PID ', process.pid);
12 | startWorker();
13 | } else {
14 | createServer();
15 | }
16 | }
17 |
18 | function startWorker() {
19 | // @ts-expect-error imported without default; need type update?
20 | const worker = cluster.fork();
21 |
22 | worker.once('disconnect', function () {
23 | worker.process.kill();
24 | });
25 |
26 | worker.on('message', function(message) {
27 | if (message.type === 'reload') {
28 | if (worker.isDead()) return;
29 | console.log('Killing %d', worker.process.pid);
30 | worker.process.kill();
31 | startWorker();
32 | }
33 | });
34 | }
35 |
--------------------------------------------------------------------------------
/src/templates/contexts.ts:
--------------------------------------------------------------------------------
1 | import { type Expression } from './expressions';
2 | import {
3 | type Attributes,
4 | type MarkupHook,
5 | type View,
6 | } from './templates';
7 | import { Controller } from '../Controller';
8 |
9 | function noop() { }
10 |
11 | /**
12 | * Properties and methods which are globally inherited for the entire page
13 | */
14 | export class ContextMeta {
15 | addBinding: (binding: any) => void = noop;
16 | removeBinding: (binding: any) => void = noop;
17 | removeNode: (node: Node) => void = noop;
18 | addItemContext: (context: Context) => void = noop;
19 | removeItemContext: (context: Context) => void = noop;
20 | views = null;
21 | idNamespace = '';
22 | idCount = 0;
23 | pending = [];
24 | pauseCount = 0;
25 | }
26 |
27 | export class Context {
28 | meta: ContextMeta;
29 | controller: Controller;
30 | parent?: Context;
31 | unbound?: boolean;
32 | expression?: Expression;
33 | alias?: string;
34 | keyAlias?: string;
35 | item?: number;
36 | view?: View;
37 | attributes?: Attributes;
38 | hooks?: MarkupHook[];
39 | initHooks?: MarkupHook[];
40 | closure?: Context;
41 | _id?: number;
42 | _eventModels?: any;
43 |
44 | constructor(meta: ContextMeta, controller: Controller, parent?: Context, unbound?: boolean, expression?: Expression) {
45 | // Required properties //
46 |
47 | // Properties which are globally inherited for the entire page
48 | this.meta = meta;
49 | // The page or component. Must have a `model` property with a `data` property
50 | this.controller = controller;
51 |
52 | // Optional properties //
53 |
54 | // Containing context
55 | this.parent = parent;
56 | // Boolean set to true when bindings should be ignored
57 | this.unbound = unbound;
58 | // The expression for a block
59 | this.expression = expression;
60 | // Alias name for the given expression
61 | this.alias = expression && expression.meta && expression.meta.as;
62 | // Alias name for the index or iterated key
63 | this.keyAlias = expression && expression.meta && expression.meta.keyAs;
64 |
65 | // For Context::eachChild
66 | // The context of the each at render time
67 | this.item = null;
68 |
69 | // For Context::viewChild
70 | // Reference to the current view
71 | this.view = null;
72 | // Attribute values passed to the view instance
73 | this.attributes = null;
74 | // MarkupHooks to be called after insert into DOM of component
75 | this.hooks = null;
76 | // MarkupHooks to be called immediately before init of component
77 | this.initHooks = null;
78 |
79 | // For Context::closureChild
80 | // Reference to another context established at render time by ContextClosure
81 | this.closure = null;
82 |
83 | // Used in EventModel
84 | this._id = null;
85 | this._eventModels = null;
86 | }
87 |
88 | /**
89 | * Generate unique Id
90 | *
91 | * @returns namespaced Id
92 | */
93 | id() {
94 | const count = ++this.meta.idCount;
95 | return this.meta.idNamespace + '_' + count.toString(36);
96 | }
97 |
98 | addBinding(binding) {
99 | // Don't add bindings that wrap list items. Only their outer range is needed
100 | if (binding.itemFor) return;
101 | const expression = binding.template.expression;
102 | // Don't rerender in unbound sections
103 | if (expression ? expression.isUnbound(this) : this.unbound) return;
104 | // Don't rerender to changes in a with expression
105 | if (expression && expression.meta && expression.meta.blockType === 'with') return;
106 | this.meta.addBinding(binding);
107 | }
108 |
109 | removeBinding(binding) {
110 | this.meta.removeBinding(binding);
111 | }
112 |
113 | removeNode(node) {
114 | const bindItemStart = node.$bindItemStart;
115 | if (bindItemStart) {
116 | this.meta.removeItemContext(bindItemStart.context);
117 | }
118 | const component = node.$component;
119 | if (component) {
120 | node.$component = null;
121 | if (!component.singleton) {
122 | component.destroy();
123 | }
124 | }
125 | const destroyListeners = node.$destroyListeners;
126 | if (destroyListeners) {
127 | node.$destroyListeners = null;
128 | for (let i = 0, len = destroyListeners.length; i < len; i++) {
129 | destroyListeners[i]();
130 | }
131 | }
132 | }
133 |
134 | child(expression) {
135 | // Set or inherit the binding mode
136 | const blockType = expression.meta && expression.meta.blockType;
137 | const unbound = (blockType === 'unbound') ? true :
138 | (blockType === 'bound') ? false :
139 | this.unbound;
140 | return new Context(this.meta, this.controller, this, unbound, expression);
141 | }
142 |
143 | componentChild(component) {
144 | return new Context(this.meta, component, this, this.unbound);
145 | }
146 |
147 | /**
148 | * Make a context for an item in an each block
149 | *
150 | * @param expression
151 | * @param item
152 | * @returns new Context
153 | */
154 | eachChild(expression, item) {
155 | const context = new Context(this.meta, this.controller, this, this.unbound, expression);
156 | context.item = item;
157 | this.meta.addItemContext(context);
158 | return context;
159 | }
160 |
161 | viewChild(view, attributes, hooks, initHooks) {
162 | const context = new Context(this.meta, this.controller, this, this.unbound);
163 | context.view = view;
164 | context.attributes = attributes;
165 | context.hooks = hooks;
166 | context.initHooks = initHooks;
167 | return context;
168 | }
169 |
170 | closureChild(closure) {
171 | const context = new Context(this.meta, this.controller, this, this.unbound);
172 | context.closure = closure;
173 | return context;
174 | }
175 |
176 | forRelative(expression: Expression) {
177 | // eslint-disable-next-line @typescript-eslint/no-this-alias
178 | let context: Context = this;
179 | while (context && context.expression === expression || context.view) {
180 | context = context.parent;
181 | }
182 | return context;
183 | }
184 |
185 | // Returns the closest context which defined the named alias
186 | forAlias(alias: string) {
187 | // eslint-disable-next-line @typescript-eslint/no-this-alias
188 | let context: Context = this;
189 | while (context) {
190 | if (context.alias === alias || context.keyAlias === alias) return context;
191 | context = context.parent;
192 | }
193 | }
194 |
195 | // Returns the closest containing context for a view attribute name or nothing
196 | forAttribute(attribute: string) {
197 | // eslint-disable-next-line @typescript-eslint/no-this-alias
198 | let context: Context = this;
199 | while (context) {
200 | // Find the closest context associated with a view
201 | if (context.view) {
202 | const attributes = context.attributes;
203 | if (!attributes) return;
204 | if (Object.prototype.hasOwnProperty.call(attributes, attribute)) return context;
205 | // If the attribute isn't found, but the attributes inherit, continue
206 | // looking in the next closest view context
207 | if (!attributes.inherit && !attributes.extend) return;
208 | }
209 | context = context.parent;
210 | }
211 | }
212 |
213 | forViewParent() {
214 | // eslint-disable-next-line @typescript-eslint/no-this-alias
215 | let context: Context = this;
216 | while (context) {
217 | // When a context with a `closure` property is encountered, skip to its
218 | // parent context rather than returning the nearest view's. This reference
219 | // is created by wrapping a template in a ContextClosure template
220 | if (context.closure) return context.closure.parent;
221 | // Find the closest view and return the containing context
222 | if (context.view) return context.parent;
223 | context = context.parent;
224 | }
225 | }
226 |
227 | /**
228 | * Gets the current `context` view or closest `context.parent` view
229 | *
230 | * @returns view
231 | */
232 | getView() {
233 | // eslint-disable-next-line @typescript-eslint/no-this-alias
234 | let context: Context = this;
235 | while (context) {
236 | // Find the closest view
237 | if (context.view) return context.view;
238 | context = context.parent;
239 | }
240 | }
241 |
242 | // Returns the `this` value for a context
243 | get() {
244 | const value = (this.expression) ?
245 | this.expression.get(this) :
246 | this.controller.model.data;
247 | if (this.item != null) {
248 | return value && value[this.item];
249 | }
250 | return value;
251 | }
252 |
253 | pause() {
254 | this.meta.pauseCount++;
255 | }
256 |
257 | unpause() {
258 | if (--this.meta.pauseCount) return;
259 | this.flush();
260 | }
261 |
262 | flush() {
263 | const pending = this.meta.pending;
264 | const len = pending.length;
265 | if (!len) return;
266 | this.meta.pending = [];
267 | for (let i = 0; i < len; i++) {
268 | pending[i]();
269 | }
270 | }
271 |
272 | queue(cb) {
273 | this.meta.pending.push(cb);
274 | }
275 | }
276 |
--------------------------------------------------------------------------------
/src/templates/dependencyOptions.ts:
--------------------------------------------------------------------------------
1 | import { ContextClosure, type Template } from './templates';
2 |
3 | export class DependencyOptions {
4 | ignoreTemplate?: Template;
5 |
6 | constructor(options?: { ignoreTemplate: Template }) {
7 | this.setIgnoreTemplate(options && options.ignoreTemplate);
8 | }
9 |
10 | static shouldIgnoreTemplate(template, options?: { ignoreTemplate?: Template }) {
11 | return (options) ? options.ignoreTemplate === template : false;
12 | }
13 |
14 | setIgnoreTemplate(template) {
15 | while (template instanceof ContextClosure) {
16 | template = template.template;
17 | }
18 | this.ignoreTemplate = template;
19 | }
20 | }
21 |
--------------------------------------------------------------------------------
/src/templates/index.ts:
--------------------------------------------------------------------------------
1 | export * as contexts from './contexts';
2 | export * as expressions from './expressions';
3 | export * as operatorFns from './operatorFns';
4 | export * as templates from './templates';
5 | export { DependencyOptions } from './dependencyOptions';
6 |
--------------------------------------------------------------------------------
/src/templates/operatorFns.ts:
--------------------------------------------------------------------------------
1 | // `-` and `+` can be either unary or binary, so all unary operators are
2 | // postfixed with `U` to differentiate
3 |
4 | export const get = {
5 | // Unary operators
6 | '!U': function(value) {
7 | return !value;
8 | },
9 | '-U': function(value) {
10 | return -value;
11 | },
12 | '+U': function(value) {
13 | return +value;
14 | },
15 | '~U': function(value) {
16 | return ~value;
17 | },
18 | 'typeofU': function(value) {
19 | return typeof value;
20 | },
21 | // Binary operators
22 | '||': function(left, right) {
23 | return left || right;
24 | },
25 | '&&': function(left, right) {
26 | return left && right;
27 | },
28 | '|': function(left, right) {
29 | return left | right;
30 | },
31 | '^': function(left, right) {
32 | return left ^ right;
33 | },
34 | '&': function(left, right) {
35 | return left & right;
36 | },
37 | '==': function(left, right) {
38 | // Template `==` intentionally uses same behavior as JS
39 | // eslint-disable-next-line eqeqeq
40 | return left == right;
41 | },
42 | '!=': function(left, right) {
43 | // Template `!=` intentionally uses same behavior as JS
44 | // eslint-disable-next-line eqeqeq
45 | return left != right;
46 | },
47 | '===': function(left, right) {
48 | return left === right;
49 | },
50 | '!==': function(left, right) {
51 | return left !== right;
52 | },
53 | '<': function(left, right) {
54 | return left < right;
55 | },
56 | '>': function(left, right) {
57 | return left > right;
58 | },
59 | '<=': function(left, right) {
60 | return left <= right;
61 | },
62 | '>=': function(left, right) {
63 | return left >= right;
64 | },
65 | 'instanceof': function(left, right) {
66 | return left instanceof right;
67 | },
68 | 'in': function(left, right) {
69 | return left in right;
70 | },
71 | '<<': function(left, right) {
72 | return left << right;
73 | },
74 | '>>': function(left, right) {
75 | return left >> right;
76 | },
77 | '>>>': function(left, right) {
78 | return left >>> right;
79 | },
80 | '+': function(left, right) {
81 | return left + right;
82 | },
83 | '-': function(left, right) {
84 | return left - right;
85 | },
86 | '*': function(left, right) {
87 | return left * right;
88 | },
89 | '/': function(left, right) {
90 | return left / right;
91 | },
92 | '%': function(left, right) {
93 | return left % right;
94 | },
95 | // Conditional operator
96 | '?': function(test, consequent, alternate) {
97 | return (test) ? consequent : alternate;
98 | },
99 | // Sequence
100 | ',': function(...args) {
101 | return args[args.length - 1];
102 | }
103 | };
104 |
105 | export const set = {
106 | // Unary operators
107 | '!U': function(value) {
108 | return [!value];
109 | },
110 | '-U': function(value) {
111 | return [-value];
112 | },
113 | // Binary operators
114 | '==': function(value, left, right) {
115 | if (value) return [right];
116 | },
117 | '===': function(value, left, right) {
118 | if (value) return [right];
119 | },
120 | 'in': function(value, left, right) {
121 | right[left] = true;
122 | return {1: right};
123 | },
124 | '+': function(value, left, right) {
125 | return [value - right];
126 | },
127 | '-': function(value, left, right) {
128 | return [value + right];
129 | },
130 | '*': function(value, left, right) {
131 | return [value / right];
132 | },
133 | '/': function(value, left, right) {
134 | return [value * right];
135 | }
136 | };
137 |
--------------------------------------------------------------------------------
/src/templates/util.ts:
--------------------------------------------------------------------------------
1 | const objectProtoPropNames = Object.create(null);
2 | Object.getOwnPropertyNames(Object.prototype).forEach(function(prop) {
3 | if (prop !== '__proto__') {
4 | objectProtoPropNames[prop] = true;
5 | }
6 | });
7 |
8 | export function checkKeyIsSafe(key) {
9 | if (key === '__proto__' || objectProtoPropNames[key]) {
10 | throw new Error(`Unsafe key "${key}"`);
11 | }
12 | }
13 |
14 | export function concat(a, b) {
15 | if (!a) return b;
16 | if (!b) return a;
17 | return a.concat(b);
18 | }
19 |
20 | export function hasKeys(value) {
21 | if (!value) return false;
22 | for (const key in value) {
23 | return true;
24 | }
25 | return false;
26 | }
27 |
28 | export function traverseAndCreate(node, segments) {
29 | const len = segments.length;
30 | if (!len) return node;
31 | for (let i = 0; i < len; i++) {
32 | const segment = segments[i];
33 | checkKeyIsSafe(segment);
34 | node = node[segment] || (node[segment] = {});
35 | }
36 | return node;
37 | }
38 |
--------------------------------------------------------------------------------
/src/test-utils/assertions.ts:
--------------------------------------------------------------------------------
1 | import { ComponentHarness } from './ComponentHarness';
2 |
3 | import 'chai';
4 |
5 | declare global {
6 | // eslint-disable-next-line @typescript-eslint/no-namespace, @typescript-eslint/no-unused-vars
7 | export namespace Chai {
8 | interface Assertion {
9 | html(expectedText: string | undefined, options): void;
10 | render(expectedText: string | undefined, options): void;
11 | }
12 | }
13 | }
14 |
15 | /**
16 | * @param { {window: Window } } [dom] - _optional_ - An object that will have a `window` property
17 | * set during test execution. If not provided, the global `window` will be used.
18 | * @param {Assertion} [chai.Assertion] - _optional_ - Chai's Assertion class. If provided, the
19 | * chainable expect methods `#html(expected)` and `#render(expected)` will be added to Chai.
20 | */
21 | export function assertions(dom, Assertion) {
22 | const getWindow = dom ?
23 | function() { return dom.window; } :
24 | function() { return window; };
25 |
26 | function removeComments(node) {
27 | const domDocument = getWindow().document;
28 | const clone = domDocument.importNode(node, true);
29 | // last two arguments for createTreeWalker are required in IE
30 | // NodeFilter.SHOW_COMMENT === 128
31 | const treeWalker = domDocument.createTreeWalker(clone, 128, null, false);
32 | const toRemove = [];
33 | for (let item = treeWalker.nextNode(); item != null; item = treeWalker.nextNode()) {
34 | toRemove.push(item);
35 | }
36 | for (let i = toRemove.length; i--;) {
37 | toRemove[i].parentNode.removeChild(toRemove[i]);
38 | }
39 | return clone;
40 | }
41 |
42 | function getHtml(node, parentTag) {
43 | const domDocument = getWindow().document;
44 | // We use the element, because it has a transparent content model:
45 | // https://developer.mozilla.org/en-US/docs/Web/Guide/HTML/Content_categories#Transparent_content_model
46 | //
47 | // In practice, DOM validity isn't enforced by browsers when using
48 | // appendChild and innerHTML, so specifying a valid parentTag for the node
49 | // should not be necessary
50 | const el = domDocument.createElement(parentTag || 'ins');
51 | const clone = domDocument.importNode(node, true);
52 | el.appendChild(clone);
53 | return el.innerHTML;
54 | }
55 |
56 | // Executes the parts of `Page#destroy` pertaining to the model, which get
57 | // re-done when a new Page gets created on the same model. Normally, using
58 | // `Page#destroy` would be fine, but the `.to.render` assertion wants to do
59 | // 3 rendering passes on the same data, so we can't completely clear the
60 | // model's state between the rendering passes.
61 | function resetPageModel(page) {
62 | page._removeModelListeners();
63 | for (const componentId in page._components) {
64 | const component = page._components[componentId];
65 | component.destroy();
66 | }
67 | page.model.silent().destroy('$components');
68 | }
69 |
70 | if (Assertion) {
71 | Assertion.addMethod('html', function(expected, options) {
72 | const obj = this._obj;
73 | const includeComments = options && options.includeComments;
74 | const parentTag = options && options.parentTag;
75 | const domNode = getWindow().Node;
76 |
77 | new Assertion(obj).instanceOf(domNode);
78 | new Assertion(expected).is.a('string');
79 |
80 | const fragment = (includeComments) ? obj : removeComments(obj);
81 | const html = getHtml(fragment, parentTag);
82 |
83 | this.assert(
84 | html === expected,
85 | 'expected DOM rendering to produce the HTML #{exp} but got #{act}',
86 | 'expected DOM rendering to not produce actual HTML #{act}',
87 | expected,
88 | html
89 | );
90 | });
91 |
92 | Assertion.addMethod('render', function(expected, options) {
93 | const harness = this._obj;
94 | if (expected && typeof expected === 'object') {
95 | options = expected;
96 | expected = null;
97 | }
98 | const domDocument = getWindow().document;
99 | const parentTag = (options && options.parentTag) || 'ins';
100 | let firstFailureMessage, actual;
101 |
102 | new Assertion(harness).instanceOf(ComponentHarness);
103 |
104 | // Render to a HTML string.
105 | let renderResult = harness.renderHtml(options);
106 | const htmlString = renderResult.html;
107 |
108 | // Normalize `htmlString` into the same form as the DOM would give for `element.innerHTML`.
109 | //
110 | // derby-parsing uses htmlUtil.unescapeEntities(source) on text nodes' content. That converts
111 | // HTML entities like ' ' to their corresponding Unicode characters. However, for this
112 | // assertion, if the `expected` string is provided, it will not have that same transformation.
113 | // To make the assertion work properly, normalize the actual `htmlString`.
114 | const html = normalizeHtml(htmlString);
115 |
116 | let htmlRenderingOk;
117 | if (expected == null) {
118 | // If `expected` is not provided, then we skip this check.
119 | // Set `expected` as the normalized HTML string for subsequent checks.
120 | expected = html;
121 | htmlRenderingOk = true;
122 | } else {
123 | // If `expected` was originally provided, check that the normalized HTML string is equal.
124 | new Assertion(expected).is.a('string');
125 | // Check HTML matches expected value
126 | htmlRenderingOk = html === expected;
127 | if (!htmlRenderingOk) {
128 | if (!firstFailureMessage) {
129 | firstFailureMessage = 'HTML string rendering does not match expected HTML';
130 | actual = html;
131 | }
132 | }
133 | }
134 |
135 | resetPageModel(renderResult.page);
136 |
137 | // Check DOM rendering is also equivalent.
138 | // This uses the harness "pageRendered" event to grab the rendered DOM *before* any component
139 | // `create()` methods are called, as `create()` methods can do DOM mutations.
140 | let domRenderingOk;
141 | harness.once('pageRendered', function(page) {
142 | try {
143 | new Assertion(page.fragment).html(expected, options);
144 | domRenderingOk = true;
145 | } catch (err) {
146 | domRenderingOk = false;
147 | if (!firstFailureMessage) {
148 | firstFailureMessage = err.message;
149 | actual = err.actual;
150 | }
151 | }
152 | });
153 | renderResult = harness.renderDom(options);
154 | resetPageModel(renderResult.page);
155 |
156 | // Try attaching. Attachment will throw an error if HTML doesn't match
157 | const el = domDocument.createElement(parentTag);
158 | el.innerHTML = htmlString;
159 | const innerHTML = el.innerHTML;
160 | let attachError;
161 | try {
162 | harness.attachTo(el);
163 | } catch (err) {
164 | attachError = err;
165 | if (!firstFailureMessage) {
166 | firstFailureMessage = 'expected success attaching to #{exp} but got #{act}.\n' +
167 | (attachError ? (attachError.message + attachError.stack) : '');
168 | actual = innerHTML;
169 | }
170 | }
171 | const attachOk = !attachError;
172 |
173 | // TODO: Would be nice to add a diff of the expected and actual HTML
174 | this.assert(
175 | htmlRenderingOk && domRenderingOk && attachOk,
176 | firstFailureMessage || 'rendering failed due to an unknown reason',
177 | 'expected rendering to fail but it succeeded',
178 | expected,
179 | actual
180 | );
181 | });
182 |
183 | /**
184 | * Normalize a HTML string into its `innerHTML` form.
185 | *
186 | * WARNING - Only use this with trusted HTML, e.g. developer-provided HTML.
187 | *
188 | * Assigning into `element.innerHTML` does some interesting transformations:
189 | *
190 | * - Certain safe HTML entities like """ are converted into their unescaped
191 | * single-character forms.
192 | * - Certain single characters, e.g. ">" or a non-breaking space, are converted
193 | * into their escaped HTML entity forms, e.g. ">" or " ".
194 | */
195 | const normalizeHtml = function(html) {
196 | const normalizerElement = window.document.createElement('ins');
197 | normalizerElement.innerHTML = html;
198 | return normalizerElement.innerHTML;
199 | };
200 | }
201 |
202 | return {
203 | removeComments: removeComments,
204 | getHtml: getHtml
205 | };
206 | }
207 |
--------------------------------------------------------------------------------
/src/test-utils/domTestRunner.ts:
--------------------------------------------------------------------------------
1 | import { util } from 'racer';
2 |
3 | import { assertions as registerAssertions } from './assertions';
4 | import { ComponentHarness } from './ComponentHarness';
5 |
6 | export class DomTestRunner{
7 | window?: any;
8 | document?: any;
9 | harnesses: ComponentHarness[];
10 |
11 | constructor() {
12 | this.window = null;
13 | this.document = null;
14 | this.harnesses = [];
15 | }
16 |
17 | installMochaHooks(options) {
18 | options = options || {};
19 | const jsdomOptions = options.jsdomOptions;
20 |
21 | // Set up runner's `window` and `document`.
22 | if (util.isServer) {
23 | mochaHooksForNode(this, {
24 | jsdomOptions: jsdomOptions
25 | });
26 | } else {
27 | mochaHooksForBrowser(this);
28 | }
29 | }
30 |
31 | createHarness() {
32 | const harness = new ComponentHarness();
33 | if (arguments.length > 0) {
34 | // eslint-disable-next-line prefer-spread, prefer-rest-params
35 | harness.setup.apply(harness, arguments);
36 | }
37 | this.harnesses.push(harness);
38 | return harness;
39 | }
40 | }
41 |
42 | function mochaHooksForNode(runner, options) {
43 | const jsdomOptions = options.jsdomOptions;
44 |
45 | // Use an indirect require so that Browserify doesn't try to bundle JSDOM.
46 | const JSDOM = util.serverRequire(module, 'jsdom').JSDOM;
47 |
48 | const nodeGlobal = global;
49 | // Keep a direct reference so that we're absolutely sure we clean up our own JSDOM.
50 | let jsdom;
51 |
52 | global.beforeEach(function() {
53 | jsdom = new JSDOM('', jsdomOptions);
54 | runner.window = jsdom.window;
55 | runner.document = jsdom.window.document;
56 | // Set `window` and `document` globals for Derby code that doesn't allow injecting them.
57 | nodeGlobal.window = runner.window;
58 | nodeGlobal.document = runner.document;
59 | // Initialize "input" and "change" listeners on the document.
60 | module.require('../documentListeners').add(runner.document);
61 | });
62 |
63 | global.afterEach(function() {
64 | // Destroy the pages created by the harness, so that if a test cleans up its model itself,
65 | // bindings won't throw errors due to `document` not being present.
66 | runner.harnesses.forEach(function(harness) {
67 | harness.app._pages.forEach(function(page) {
68 | page.destroy();
69 | });
70 | });
71 | runner.harnesses = [];
72 |
73 | jsdom.window.close();
74 | runner.window = null;
75 | runner.document = null;
76 | delete nodeGlobal.window;
77 | delete nodeGlobal.document;
78 | });
79 | }
80 |
81 | function mochaHooksForBrowser(runner) {
82 | global.beforeEach(function() {
83 | runner.window = global.window;
84 | runner.document = global.window.document;
85 | });
86 |
87 | global.afterEach(function() {
88 | runner.window = null;
89 | runner.document = null;
90 | });
91 | }
92 |
93 | const runner = new DomTestRunner();
94 | // Set up Chai assertion chain methods: `#html` and `#render`
95 | registerAssertions(runner, module.require('chai').Assertion);
96 |
97 | export function install(options) {
98 | runner.installMochaHooks(options);
99 | return runner;
100 | }
101 |
--------------------------------------------------------------------------------
/src/test-utils/index.ts:
--------------------------------------------------------------------------------
1 | export { assertions } from './assertions';
2 | export { ComponentHarness, type RenderOptions, PageForHarness } from './ComponentHarness';
3 | export { install as domTestRunner, DomTestRunner } from './domTestRunner';
4 |
--------------------------------------------------------------------------------
/src/textDiff.js:
--------------------------------------------------------------------------------
1 | exports.onStringInsert = onStringInsert;
2 | exports.onStringRemove = onStringRemove;
3 | exports.onTextInput = onTextInput;
4 |
5 | function onStringInsert(el, previous, index, text) {
6 | function transformCursor(cursor) {
7 | return (index < cursor) ? cursor + text.length : cursor;
8 | }
9 | if (!previous) {
10 | previous = '';
11 | }
12 | var newText = previous.slice(0, index) + text + previous.slice(index);
13 | replaceText(el, newText, transformCursor);
14 | }
15 |
16 | function onStringRemove(el, previous, index, howMany) {
17 | function transformCursor(cursor) {
18 | return (index < cursor) ? cursor - Math.min(howMany, cursor - index) : cursor;
19 | }
20 | if (!previous) {
21 | previous = '';
22 | }
23 | var newText = previous.slice(0, index) + previous.slice(index + howMany);
24 | replaceText(el, newText, transformCursor);
25 | }
26 |
27 | function replaceText(el, newText, transformCursor) {
28 | var selectionStart = transformCursor(el.selectionStart);
29 | var selectionEnd = transformCursor(el.selectionEnd);
30 |
31 | var scrollTop = el.scrollTop;
32 | el.value = newText;
33 | if (el.scrollTop !== scrollTop) {
34 | el.scrollTop = scrollTop;
35 | }
36 | if (document.activeElement === el) {
37 | el.selectionStart = selectionStart;
38 | el.selectionEnd = selectionEnd;
39 | }
40 | }
41 |
42 | function onTextInput(model, segments, value) {
43 | var previous = model._get(segments) || '';
44 | if (previous === value) return;
45 | var start = 0;
46 | while (previous.charAt(start) === value.charAt(start)) {
47 | start++;
48 | }
49 | var end = 0;
50 | while (
51 | previous.charAt(previous.length - 1 - end) === value.charAt(value.length - 1 - end) &&
52 | end + start < previous.length &&
53 | end + start < value.length
54 | ) {
55 | end++;
56 | }
57 |
58 | if (previous.length !== start + end) {
59 | var howMany = previous.length - start - end;
60 | model._stringRemove(segments, start, howMany);
61 | }
62 | if (value.length !== start + end) {
63 | var inserted = value.slice(start, value.length - end);
64 | model._stringInsert(segments, start, inserted);
65 | }
66 | }
67 |
--------------------------------------------------------------------------------
/test/.jshintrc:
--------------------------------------------------------------------------------
1 | {
2 | "node": true,
3 | "laxcomma": true,
4 | "eqnull": true,
5 | "eqeqeq": true,
6 | "indent": 2,
7 | "newcap": true,
8 | "quotmark": "single",
9 | "undef": true,
10 | "trailing": true,
11 | "shadow": true,
12 | "expr": true,
13 | "boss": true,
14 | "globals": {
15 | "describe": false,
16 | "it": false,
17 | "before": false,
18 | "after": false,
19 | "beforeEach": false,
20 | "afterEach": false
21 | }
22 | }
23 |
--------------------------------------------------------------------------------
/test/all/App.mocha.js:
--------------------------------------------------------------------------------
1 | const expect = require('chai').expect;
2 | const racer = require('racer');
3 | const sinon = require('sinon');
4 | const AppForClient = require('../../src/App').AppForClient;
5 | const { DerbyForClient } = require('../../src/Derby');
6 | const { DerbyForServer } = require('../../src/DerbyForServer');
7 |
8 | describe('App', () => {
9 | afterEach(() => {
10 | sinon.restore();
11 | });
12 |
13 | [DerbyForClient, DerbyForServer].forEach((DerbyClass) => {
14 | describe(`from ${DerbyClass.name}`, () => {
15 | it('createPage emits \'page\' event with newly created page', () => {
16 | const derby = new DerbyClass();
17 | // A properly working _init() requires a more complicated setup,
18 | // especially for AppForClient, so stub it out since createPage()
19 | // doesn't depend on anything in _init().
20 | sinon.stub(derby.App.prototype, '_init');
21 |
22 | const app = derby.createApp();
23 | app.model = racer.createModel();
24 |
25 | let pageFromEvent = null;
26 | app.on('page', (page) => {
27 | pageFromEvent = page;
28 | });
29 | const page1 = app.createPage({});
30 | expect(pageFromEvent).to.equal(page1);
31 | const page2 = app.createPage({});
32 | expect(pageFromEvent).to.equal(page2);
33 | });
34 | });
35 | });
36 | });
37 |
38 | describe('App._parseInitialData', () => {
39 | it('parses simple json', () => {
40 | const actual = AppForClient._parseInitialData('{"foo": "bar"}');
41 | expect(actual).to.deep.equal({ foo: 'bar' });
42 | });
43 |
44 | it('parses escaped json', () => {
45 | const actual = AppForClient._parseInitialData('{"foo": "<\\u0021bar><\\/bar>"}');
46 | expect(actual).to.deep.equal({ foo: '' });
47 | });
48 |
49 | it('thorws error with context for unexpected tokens', () => {
50 | expect(() => AppForClient._parseInitialData('{"foo": b}')).to.throw(
51 | /^Parse failure: Unexpected token/
52 | );
53 | });
54 | });
55 |
--------------------------------------------------------------------------------
/test/all/eventmodel.mocha.js:
--------------------------------------------------------------------------------
1 | const { EventModel } = require('../../src/eventmodel');
2 | const { expect } = require('chai');
3 |
4 | describe('eventmodel', function() {
5 | beforeEach(function() {
6 | this.model = {
7 | data: {
8 | x: 1,
9 | list: [1,2,3],
10 | objList: [
11 | {url:'/0', listName:'one'},
12 | {url:'/1', listName:'two'},
13 | {url:'/2', listName:'three'}
14 | ]
15 | }
16 | };
17 | this.em = new EventModel();
18 |
19 | var self = this;
20 |
21 | // This is a helper to update the data model and trigger EM bindings in one go.
22 | this.set = function(segments, value) {
23 | var d = self.model.data;
24 | for (var i = 0; i < segments.length - 1; i++) {
25 | d = d[segments[i]];
26 | }
27 | d[segments[segments.length - 1]] = value;
28 |
29 | self.em.set(segments);
30 | };
31 |
32 | // Lots of these tests need to check that a binding is called. This isn't
33 | // bound or anything, but its really handy code.
34 | this.updateCalled = 0;
35 | this.insertCalled = 0;
36 | this.insertArgs = null;
37 |
38 | this.binding = {
39 | update:function() {self.updateCalled++;},
40 | insert:function(index, howMany) {
41 | self.insertCalled++;
42 | this.insertArgs = {index:index, howMany:howMany};
43 | },
44 | };
45 | });
46 |
47 | describe('Initial eventmodel', function() {
48 | it('should be empty', function() {
49 | const em = new EventModel();
50 | expect(em.isEmpty()).eq(true);
51 | });
52 |
53 | it('should lazily create bindings', function() {
54 | const em = new EventModel();
55 | const binding = this.binding;
56 | expect(binding.eventModels).to.eq(undefined);
57 | em.addBinding(['x'], binding);
58 | expect(binding.eventModels).to.be.instanceOf(Object);
59 | expect(em.at(['x'])).has.ownProperty('bindings').instanceOf(Object);
60 | });
61 | });
62 |
63 | it('updates any object references under a path when remove/insert/move happens');
64 |
65 | describe('sets', function() {
66 | it('updates a binding when its value changes', function() {
67 | this.em.addBinding(['x'], this.binding);
68 | this.set(['x'], 10);
69 |
70 | expect(this.updateCalled).equal(1);
71 | });
72 |
73 | it('updates a fixed list element binding', function() {
74 | this.em.addBinding(['list', 1], this.binding);
75 | this.set(['list', 1], 10);
76 |
77 | expect(this.updateCalled).equal(1);
78 | });
79 |
80 | it('updates bound children when the parent is replaced', function() {
81 | this.em.addBinding(['list', 1], this.binding);
82 | this.set(['list'], [4,5,6]);
83 |
84 | expect(this.updateCalled).equal(1);
85 | });
86 |
87 | it('lets you bind to places with currently undefined values', function() {
88 | this.em.addBinding(['list', 10], this.binding);
89 | this.set(['list', 10], 'hi');
90 |
91 | expect(this.updateCalled).equal(1);
92 | });
93 | });
94 |
95 | describe('lists', function() {
96 | it.skip('Does not update an item binding inside a list item when something is inserted around it', function() {
97 | //var ctx = {item:1};
98 | //this.em.addBinding(['list', ctx], this.binding);
99 | });
100 | });
101 |
102 | describe('array lookup', function() {
103 | it('updates a binding if the index changes value', function() {
104 | var ref = this.em.arrayLookup(this.model, ['list'], ['x']);
105 | this.em.addBinding(['list', ref], this.binding);
106 |
107 | this.set(['x'], 0);
108 |
109 | expect(this.updateCalled).equal(1);
110 | });
111 |
112 | it('updates a binding if the resolved path changes', function() {
113 | var ref = this.em.arrayLookup(this.model, ['list'], ['x']);
114 | this.em.addBinding(['list', ref], this.binding);
115 |
116 | this.set(['list', 1], 10);
117 | expect(this.updateCalled).equal(1);
118 | });
119 |
120 | it('reuses the array reference if we call arrayLookup again', function() {
121 | var ref1 = this.em.arrayLookup(this.model, ['list'], ['x']);
122 | var ref2 = this.em.arrayLookup(this.model, ['list'], ['x']);
123 | expect(ref1).equal(ref2);
124 | });
125 |
126 | it('reuses the array reference if we call arrayLookup after moving the inner value', function() {
127 | var ref1 = this.em.arrayLookup(this.model, ['list'], ['x']);
128 |
129 | this.set(['x', 0]);
130 | var ref2 = this.em.arrayLookup(this.model, ['list'], ['x']);
131 |
132 | expect(ref1).equal(ref2);
133 | });
134 |
135 | it('reuses the array reference if we call arrayLookup after moving the outer value', function() {
136 | var ref1 = this.em.arrayLookup(this.model, ['list'], ['x']);
137 |
138 | this.set(['list', 1], 10);
139 | var ref2 = this.em.arrayLookup(this.model, ['list'], ['x']);
140 |
141 | expect(ref1).equal(ref2);
142 | });
143 |
144 | it('allows chained references', function() {
145 | // For this test we'll try and access objList[list[x]].listName. x is 1,
146 | // list[1] is 2 and objList[2].listName is 'three'.
147 |
148 | var ref1 = this.em.arrayLookup(this.model, ['list'], ['x']);
149 | var ref2 = this.em.arrayLookup(this.model, ['objList'], ['list', ref1]);
150 |
151 | this.em.addBinding(['objList', ref2, 'listName'], this.binding);
152 |
153 | this.set(['objList', 2, 'listName'], 'internet');
154 | expect(this.updateCalled).equal(1);
155 |
156 | this.set(['list', 1], 0);
157 | expect(this.updateCalled).equal(2);
158 |
159 | this.set(['x'], 0);
160 | expect(this.updateCalled).equal(3);
161 |
162 | // Going back out again to make sure that all the bindings have been updated correctly.
163 | this.set(['list', 0], 0);
164 | expect(this.updateCalled).equal(4);
165 |
166 | this.set(['objList', 0, 'listName'], 'superman');
167 | expect(this.updateCalled).equal(5);
168 |
169 | // Some things that should not update the binding.
170 | this.set(['objList', 2, 'listName'], 'blah');
171 | this.set(['objList', 1, 'listName'], 'superduper');
172 | this.set(['list', 1], 1);
173 | expect(this.updateCalled).equal(5);
174 | });
175 |
176 | it('lets you bind to a property in an object', function() {
177 | this.set(['x'], 'url');
178 | var ref = this.em.arrayLookup(this.model, ['objList', 1], ['x']);
179 |
180 | this.em.addBinding(['objList', 1, ref], this.binding);
181 |
182 | this.set(['objList', 1, 'url'], 'http://example.com');
183 | expect(this.updateCalled).equal(1);
184 |
185 | this.set(['x'], 'listName');
186 | expect(this.updateCalled).equal(2);
187 | });
188 | });
189 | });
190 |
191 |
--------------------------------------------------------------------------------
/test/all/parsing/truthy.mocha.js:
--------------------------------------------------------------------------------
1 | var expect = require('chai').expect;
2 | var parsing = require('../../../src/parsing');
3 |
4 | describe('template truthy', function() {
5 |
6 | it('gets standard truthy value for if block', function() {
7 | expect(parsing.createExpression('if false').truthy()).equal(false);
8 | expect(parsing.createExpression('if undefined').truthy()).equal(false);
9 | expect(parsing.createExpression('if null').truthy()).equal(false);
10 | expect(parsing.createExpression('if ""').truthy()).equal(false);
11 | expect(parsing.createExpression('if []').truthy()).equal(false);
12 |
13 | expect(parsing.createExpression('if true').truthy()).equal(true);
14 | expect(parsing.createExpression('if 0').truthy()).equal(false);
15 | expect(parsing.createExpression('if 1').truthy()).equal(true);
16 | expect(parsing.createExpression('if "Hi"').truthy()).equal(true);
17 | expect(parsing.createExpression('if [0]').truthy()).equal(true);
18 | expect(parsing.createExpression('if {}').truthy()).equal(true);
19 | expect(parsing.createExpression('if {foo: 0}').truthy()).equal(true);
20 | });
21 |
22 | it('gets inverse truthy value for unless block', function() {
23 | expect(parsing.createExpression('unless false').truthy()).equal(true);
24 | expect(parsing.createExpression('unless undefined').truthy()).equal(true);
25 | expect(parsing.createExpression('unless null').truthy()).equal(true);
26 | expect(parsing.createExpression('unless ""').truthy()).equal(true);
27 | expect(parsing.createExpression('unless []').truthy()).equal(true);
28 |
29 | expect(parsing.createExpression('unless true').truthy()).equal(false);
30 | expect(parsing.createExpression('unless 0').truthy()).equal(true);
31 | expect(parsing.createExpression('unless 1').truthy()).equal(false);
32 | expect(parsing.createExpression('unless "Hi"').truthy()).equal(false);
33 | expect(parsing.createExpression('unless [0]').truthy()).equal(false);
34 | expect(parsing.createExpression('unless {}').truthy()).equal(false);
35 | expect(parsing.createExpression('unless {foo: 0}').truthy()).equal(false);
36 | });
37 |
38 | it('gets always truthy value for else block', function() {
39 | parsing.createExpression('else');
40 | expect(parsing.createExpression('else').truthy()).equal(true);
41 | });
42 |
43 | });
44 |
--------------------------------------------------------------------------------
/test/all/templates/templates.mocha.js:
--------------------------------------------------------------------------------
1 | var expect = require('chai').expect;
2 | var templates = require('../../../src/templates/templates');
3 |
4 | describe('Views', function() {
5 |
6 | it('registers and finds a view', function() {
7 | var views = new templates.Views();
8 | views.register('greeting', 'Hi');
9 | var view = views.find('greeting');
10 | expect(view.source).equal('Hi');
11 | });
12 |
13 | it('registers and finds a nested view', function() {
14 | var views = new templates.Views();
15 | views.register('greetings:informal', 'Hi');
16 | var view = views.find('greetings:informal');
17 | expect(view.source).equal('Hi');
18 | });
19 |
20 | it('finds a view relatively', function() {
21 | var views = new templates.Views();
22 | views.register('greetings:informal', 'Hi');
23 | var view = views.find('informal', 'greetings');
24 | expect(view.source).equal('Hi');
25 | });
26 |
27 | it('does not find a view in a child namespace', function() {
28 | var views = new templates.Views();
29 | views.register('greetings:informal', 'Hi');
30 | var view = views.find('informal');
31 | expect(view).equal(undefined);
32 | });
33 |
34 | it('registers and finds an index view', function() {
35 | var views = new templates.Views();
36 | views.register('greetings:informal:index', 'Hi');
37 | var view = views.find('greetings:informal');
38 | expect(view.source).equal('Hi');
39 | });
40 |
41 | });
42 |
43 | describe('Hooks', function() {
44 | it('derives valid module attribute from base class', function() {
45 | class TestHook extends templates.MarkupHook {
46 | constructor() {
47 | super();
48 | this.name = 'TestHook';
49 | }
50 | }
51 | var testHook = new TestHook();
52 | expect(testHook.name).to.equal('TestHook');
53 | expect(testHook.module).to.equal('templates');
54 | });
55 | it('has valid module name', function() {
56 | var hook = new templates.ComponentOn('foo');
57 | expect(hook.name).to.equal('foo');
58 | expect(hook.module).to.equal('templates');
59 | });
60 | });
61 |
--------------------------------------------------------------------------------
/test/dom/components.mocha.js:
--------------------------------------------------------------------------------
1 | const expect = require('chai').expect;
2 | const pathLib = require('node:path');
3 | const { Component } = require('../../src/components');
4 | const domTestRunner = require('../../src/test-utils/domTestRunner');
5 |
6 | describe('components', function() {
7 | const runner = domTestRunner.install();
8 |
9 | describe('app.component registration', function() {
10 | describe('passing just component class', function() {
11 | describe('with static view prop', function() {
12 | it('external view file', function() {
13 | const harness = runner.createHarness();
14 |
15 | function SimpleBox() {}
16 | SimpleBox.view = {
17 | is: 'simple-box',
18 | // Static `view.file` property, defining path of view file
19 | file: pathLib.resolve(__dirname, '../fixtures/simple-box')
20 | };
21 | harness.app.component(SimpleBox);
22 |
23 | harness.setup('');
24 | expect(harness.renderHtml().html).to.equal('');
25 | });
26 |
27 | it('inlined view.source', function() {
28 | const harness = runner.createHarness();
29 |
30 | function SimpleBox() {}
31 | SimpleBox.view = {
32 | is: 'simple-box',
33 | source: '