Overview
Why ZenJS?
ZenJS is an open source Deno-based framework which dramatically simplifies the process of building multipage web applications with SPA-like interactivity.
Rather than requiring a separate server-side API app, ZenJS is a single server-rendered app with built-in tools for handling interactivity with the ZenJS "action loop".
Server-rendered templates
By working with the web, keeping application state on the server, and sending pure HTML down the wire, our application becomes much easier to work with. The ZenJS action loop makes the user experience very SPA-like with no clunky full-page reloads.
Features
- Starter website with mobile menu
- Starter CSS with fluid typography/spaces, form styles, and more
- File-based routing with "merge" mode for smooth navigation
- Robust templating with Nunjucks
- Action loop for client/server interactivity
- MongoDB driver with custom validation
- Server-side sessions
- Admin system with users and authentication
- Server-side Twind for full Tailwind support
- Dev mode instant live reloads with no build step
Code example
Let's look at some actual code that might be used to add an item to the shopping cart.
/pages/products.njk
<button z-click="cart.addToCart" z-payload="123">Add To Cart</button>
<aside id="cart">
{% for item in cartItems %}
<div>Product: {{ item.name }} {{ item.price }}</div>
{% endfor %}
</aside>
- When the button is clicked, a request will be sent to the
addToCart
action on the server. - The
addToCart
action will do some processing, then render the#cart
element as its response. - The
cartItems
array will be looped through and rendered using Nunjucks. - The client-side JavaScript will receive the response and morph the updated
#cart
element into the current page.
That's the ZenJS action loop in a nutshell. It's a simple, elegant way to add interactivity to your web pages.
Quick start
Requirements
1. Install Deno
Mac: brew install deno
Linux: curl -fsSL https://deno.land/x/install/install.sh | sh
Then check that it works with deno --version
.
2. Get the ZenJS Starter Kit
The ZenJS Starter Kit contains a boilerplate ZenJS web app with responsive web templates and an admin system.
zenjs-starter.zipHello world!
Unzip the starter kit and cd
into the directory. Then run: deno task dev
.
- The boilerplate app should now be running at
localhost:8080
. - Edit the templates in the
pages/
directory to say "Hello world!" or whatever you like. - While in dev mode, your changes will be instantly morphed into the browser with no refresh and without even affecting current scroll position.
- Tailwind classes can also be edited and morphed in real-time thanks to Twind.
Install MongoDB locally or use a service like Atlas
ZenJS can be used with or without MongoDB. For projects not requiring a database, ZenJS is still a great way to quickly get a Deno-based site up and running with a basic responsive boilerplate, Nunjucks server side templates, the actions loop, server-side sessions, and the many other features included with the ZenJS Starter kit.
If using a database, the easiest way is to
sign up for a free MongoDB Atlas
account, then save the MongoDB URL to the .env
file in the root of your app.
Alternatively, you can install a local instance of MongoDB and use
localhost:27017
as the MongoDB URL in .env
.
Concepts
How it works
Starting with a simple Deno application running an Oak web server (similar to Koa or Express), the ZenJS library is imported to handle requests for "pages" and "actions".
This is a key concept. Most web applications tend to have "pages" with an initial state, then "actions" the user can take to interact with that page. ZenJS is built from the ground up to support this behavior.
Pages
Pages are server-rendered templates made available with file-based routing. So a
GET
request for /about-us
would be routed to /pages/about-us.njk
. The
.njk
extension indicates that our templates are written in the Nunjucks
templating language. If you've used Jinja2, or Twig, or even Vue or Svelte
templating, it should feel pretty familiar. Remember, these templates are
rendered on the server and sent down the wire as plain vanilla HTML.
Actions
It's very common in web applications for users to take some "action" on the
client-side which triggers a server-side action and results in part of the DOM
being updated on the client side. This "action loop" is elegantly handled with
built-in tools provided by ZenJS. For example, by simply adding a "z-tag"
attribute like z-click="cart.addToCart"
to a button element, the zen.js
client-side JavaScript included in the starter will add an event listener to
that button which sends an AJAX fetch request to the server when clicked. An
addToCart
action method defined in the carts.js
module can be defined to do
server-side processing and respond with HTML elements to be morphed into the
current page. This is the heart and soul of ZenJS. See "Action loop" below.
Z-Tags
Z-Tags are used to declaratively add interactivity to your HTML. You can trigger
a server-side action by adding an attribute like z-<event>
and assigning a
value of "<moduleName>.<methodName>"
. Most standard DOM events are available
including z-click
, z-mouseover
, z-submit
, and others. There's also a
z-merge
tag for using "merge" behavior so that only the body
element is
swapped out for smoother navigation. There's even z-active
which accepts a
Boolean argument for whether to add .active
the element's classlist. See
Z-Tags below to learn more.
Action loop
ZenJS strives to make client/server interactivity as simple as possible. For
example, when a user clicks an "Add To Cart" button in a typical SPA app, you
might normally set up the fetch request, then create an endpoint on the server
to process the action and generate a response. Then back on the client side,
your response handler would receive the result and parse it into the DOM. This
is a common enough pattern that it makes sense to automate this loop. ZenJS
accomplishes this by bundling a small JavaScript file in the starter kit which
scans your DOM for z-<event>
attributes and attaches event listeners so that
when the specified event is triggered, the loop is processed like this:
- A fetch request is sent to:
POST /@/<moduleName>?<methodName>
. - If a matching action module and actions method is found on the server, it will be executed.
- The actions method will typically do some server-side processing, then render
at least one DOM element. For example:
ctx.render("#sidebar");
. - The client side JavaScript that made the AJAX request will receive the response and update the element in place using the amazing Morphdom library.
This makes it very simple to add interactivity to your web pages without having to write a bunch of boilerplate code and without having to worry about implementation details of how the action loop works. Only specified elements are updated in place with no page refresh resulting in a smooth, SPA-like user experience.
Deno primer
If you're new to Deno, it may be helpful to briefly cover some of the key differences between Deno and its predecessor, Node. Ryan Dahl, the creator of Node, went on to create Deno as a modern successor to Node. So Deno is very simlar in most regards. They are both JavaScript runtimes built on top of the V8 engine – but there are a few differences worth knowing about.
Coming from Node?
If you've only used Node before, don't worry! Coming from Node to Deno is a breeze. Almost everything you've learned in Node will apply – and you'll soon learn to love Deno's smarter approach to package management and everything else that makes Deno a next-gen JavaScript runtime.
Package management
You may have noticed that in the above steps to "Hello world", there's no
npm install
or similar command to install dependencies. This is because with
Deno, dependencies are loaded directly via a URL or local filepath.
The convention (which is also used in the starter kit) is to create a file
called deps.js
which imports dependencies and then exports them so that a
single version is available throughout the app. To upgrade a module to a newer
version, you only need to change the version number for that module in deps.js
and restart your app. Have a look at deps.js
to see how simple this is.
Local cache
Deno caches dependency modules locally so that after the first time they're
pulled down from the net, they'll load much faster from that point forward.
Rather than having a node_modules
directory in each app, the system user has
just one Deno cache for the whole system (similar to pnpm
for Node). To see
where Deno cache is located on your system, you can use the command deno info
.
NPM modules
Deno recently introduced stable support for NPM. This means that with rare exceptions, any module available for Node can also be used with Deno. Learn more here.
Tasks
Deno provides support for tasks
to be defined in deno.json
. This is very
similar to Node support for scripts
defined in package.json
. Have a look at
the deno.json
file included with the starter to see the tasks included with
ZenJS.
Deno API
Besides just parsing standard JavaScript, both Node and Deno include additional
APIs for things like filesystem interaction. Deno tries wherever possible to
implement APIs that are identical to the browser version. For example the
fetch
API is identical.
For APIs which have no browser equivilent, these are namespaced under the Deno
object. So for example to read the current working directory in Node, you might
use __dirname
or process.cwd()
. In Deno, this would be Deno.cwd()
.
TypeScript
Deno is a JavaScript and TypeScript runtime. There is no build step required for
either. To use TypeScript in Deno, you simply use the .ts
extension instead of
.js
. That's it. By default, ZenJS uses .js
files for server-side code such
as action modules and schemas. If you'd rather use TypeScript just use a
.ts
extension and you're good to go. ZenJS with Deno under the hood is happy
to parse plain JavaScript or TypeScript.
Next steps
If want to take a deeper dive and learn more about Deno, the official manual is a great place to start.
Using ZenJS
The ZenJS server-side module is included in the starter kit in the top level
zen/
directory. This module works in tandem with the client-side file
/static/script/zen.js
to provide all the features and functionality of ZenJS
including file-based routing, Nunjucks templating, the action loop, and so on.
Because the ZenJS module is included in the starter kit, nothing is hidden away
in a black box. You can see exactly how it works and even modify it to suit your
needs. Just keep in mind that if you do modify the ZenJS module, you won't be
able to use deno task update
to update to the latest version of ZenJS.
Routing
All GET
requests are routed to Nunjucks templates found in the pages/
directory. The routing system will first try to match directly, for example to
/pages/books.njk
, and then to an index file like /pages/books/index.njk
.
Requests may also include route parameters, querystrings, and hashes. These
values will be available in actions modules as properties of the ctx
object
discussed later in this document.
Why just GET requests?
ZenJS implements a pattern that distinguishes between pages and actions. Pages are templates which are rendered and sent to the browser. Actions are used to execute functions on the server and update the DOM without a full page refresh. This is done by sending a POST request to an action module which then returns one or more DOM elements to the client. Since interactions with the server are handled via actions, there's no need to route requests for other HTTP methods to pages.
Hiding pages from the router
For files and directories in pages/
such as layouts or partials templates
which should not generate a route, prefix the file or directory name with an
underscore as seen in _layout.njk
. When the routing system generates routes,
it will ignore any files or directories starting with an underscore.
Dynamic routes
Dynamic routes are supported by using the +
prefix in the directory name. For
example /pages/books/+id/index.njk
will match /books/123
and /books/abc
but not /books/123/456
. The value of the dynamic route parameter will be
available in the ctx
object as ctx.params.id
.
404 and 500 error templates
When an error is encountered, the routing system will look for the nearest
sibling or parent error file. For example, a request for /books/no-such-book
where no template exists is a 404. So ZenJS looks for the error template at
/books/no-such-book/_404.njk
. If none is found there, ZenJS will walk up the
directory tree until it finds a _404.njk
or reaches the top of the project and
uses a default 404 template. The same is true for 500 errors and also when
errors are triggered programatically – for example with ctx._404()
.
Static files
Static files are served from the static/
directory. The static/
directory is
the root of the static file server. So for example, to serve the file
/static/images/logo.png
, the URL would be /images/logo.png
.
Nunjucks
Pages are parsed with Nunjucks for robust templating with features like extending or including other templates, including dynamic data in templates, loops and conditionals, and much more. The syntax is very similar to Jinja2, Twig, or Handlebars using the same double curly braces for variables and tags. Templates are cached in memory for optimal performance.
You can read the full Nunjucks docs – but here are some of the most common features likely to be used in ZenJS:
Extend a layout
In /pages/index.njk
found in the starter, you can see that block
and
extends
are used to wrap this page in a layout. Here's the pattern to do that:
/pages/_layout.njk
<html>
<body>
{% block content %}
<!-- child content appears here -->
{% endblock %}
</body>
</html>
/pages/index.njk
{% extends "_layout.html" %}
{% block content %}
<h1>Hello world!</h1>
{% endblock %}
Include a partial
To include a partial template, use the Nunjucks include
tag. For example, to
include the partial template /pages/_sidebar.njk
in the layout template above,
you would use the following:
{% include "_sidebar.njk" %}
Remember that the path to partials is relative to the pages/
directory and
not the template that includes it.
Notice that in both exampes above, the filename starts with an underscore to prevent a route being created for direct access to these files.
Loop through an array
To loop through an array, use the Nunjucks for
tag. For example, to loop
through an array of books and display their titles, you could use the following:
{% for book in books %}
<h2>{{ book.title }}</h2>
{% endfor %}
Conditionals
To conditionally display data in a template, use the Nunjucks if
tag. For
example, to display a book's description only if it exists, you could use the
following:
{% if book.description %}
<p>{{ book.description }}</p>
{% endif %}
Filters
All of Nunjucks templating features including built-in filters are available.
See the Nunjucks docs for
more information. You can also add your own custom filters and tags by adding
them to the config/nunjucks.config.js
.
Markdown
Markdown is supported in Nunjucks templates using a custom markdown
tag which
is included in the starter kit and can be used like this:
{% markdown "privacy-policy.md" %}
Style
Beyond the functional aspects of building an app, ZenJS also includes built-in patterns and boilerplate code for managing your application style.
- Global
zen.css
for global styles including site layout, typography, and common elements - CSS Variables for colors, font-sizes, and spacing
- Fully responsive typography and spacing using Utopia
- Twind library included for styling with Tailwind utility classes
Recommended approach
It is strongly recommended to use the global zen.css
only for site-wide
styles such as typography and form elements and to use Tailwind classes for
"one-off" styles such as the padding around an image in a hero section.
In /static/css/zen.css
, global variables are set for colors, font-sizes, and
spacing. Classless styles are created for common elements like table
, ul
,
and form elements like fieldset
and input
. Only truly global styles should
be added to this file.
For styles which are specific to a single element, use Tailwind utility classes.
For styles which are specific to a single template but are used multiple times
within it, use a <style>
tag in that template.
Global styles
The global zen.css
file defines CSS variables and styles for elements
globally. It is divided into named sections:
- Variables
- Reset
- Layout
- Typography
- Lists
- Tables
- Forms
- Buttons
- Other
Fluid typography and spacing
The Utopia Fluid Responsive Design System is included for fluid typography and spacing. This allows for font sizes and space values for things like margins and padding to scale up and down according to the viewport size. This is extremely simple to implement and adjust and ensures that font-sizes and spacing are always in a harmonious ratio across all screen sizes.
See the "Variables" section of zen.css
and fluid.css
for more information.
Layout
The zen.css
file includes rules for common layout related elements such as
html
, body
, main
, section
, .wrapper
, and fluid-wrap
. The actual
style of the layout, things like header
, nav
, and footer
styles are
defined in _layout.njk
.
html
and body
set basic viewport parameters and define the body as a flexbox
container.
main
is typically used to wrap page content files (see /pages/index.njk
).
section
is used to wrap blocks of content on a page is constrained to
.wrapper
width by default. In case you want a page section to span full width,
just add a .w-full
class.
.wrapper
class can be added to any element to make it behave like a section
tag, constraining its width, applying fluid padding, and centering horizontally.
fluid-wrap
is used to wrap blocks of content on a page that contain flex
children with a min-width. Child elements will grow and shrink according to flex
properties but will never be smaller than their min-width, wrapping when
necessary. This is useful in responsive forms. Just wrap two or more elements in
a fluid-wrap
and they will wrap when necessary.
Form elements
Forms are the heart of almost every web app. The zen.css
file includes styles
for form
, fieldset
, legend
, label
, input
, textarea
, and select
. If
using the basic form elements provided by ZenJS, you should wrap each element
and its corresponding label in a fieldset
along with any .form-note
or other
elements related to this field.
By following this basic pattern, forms will be styled beautifully and
consistently throughout the app with no additional work. However, you can always
customize zen.css
as needed.
Here is a typical ZenJS form used for signing in:
<form z-submit="users.signIn">
<fieldset z-invalid="{{ invalid.username }}">
<label for="username">Username</label>
<input type="text" id="username" name="username" />
</fieldset>
<fieldset z-invalid="{{ invalid.password }}">
<label for="password">Password</label>
<input type="password" id="password" name="password" />
</fieldset>
<button type="submit">Submit</button>
</form>
Notice that every form element is wrapped in a fieldset. This is important for styling and consistent form layout.
Radios and checkboxes
Use a single fieldset
for a group of radios or checkboxes. The fieldset
should have a class of radios
or checkboxes
and each input
should be
wrapped in its own label tag.
<fieldset class="radios">
<legend>Style</legend>
<label>
<input type="radio" name="style" value="modern" />
Modern
</label>
<label>
<input type="radio" name="style" value="traditional" />
Traditional
</label>
</fieldset>
Tailwind utility classes
ZenJS includes the Twind library for styling with Tailwind utility classes.
Twind scans your template for Tailwind classes and generates CSS for them. In
the head
section of _layout.html
, you can see that Twind is imported and the
generated styles are injected into the page. You can use any Tailwind classes
including responsive classes like md:hidden
and pseudo classes like
hover:bg-blue-500
as well as classes with arbitrary values like m-[2rem]
.
The Twind config is also extended to include colors and fluid typography and
spacing defined in zen.css
. See twind.config.js
for more information.
Helper classes
The zen.css
file includes a few helper classes for common tasks:
.wrapper
- constrain an element to same limits assection
.fade-in
- fade in an element with a transition.checkboxes
- style a fieldset as a group of checkboxes.radios
- style a fieldset as a group of radios.row
- added to checkboxes or radios fieldset to display them in a row.invalid
- Added to a form element, fieldset, or form to display invalid styles.form-note
- Add to a fieldset to display a note within a fieldset.center
- Center children horizontally and vertically.debug
- Add a checked border to an element for debugging layout.revert
- Revert an element and its children to its default style
Style guide
The boilerplate starter includes a style guide page at the route /style
. This
allows you to see your global variables and styles in action. You can edit
zen.css
and see your changes here in real time. This is a great way to
experiment with different colors, fonts, and spacing and see the results
immediately. Here is the style guide for
this site.
Z-Tags
Z-Tags are custom HTML attributes like z-click
or z-merge
which add
functionality to pages. Most Z-Tags are processed by the client-side zen.js
script to add interactivity to templates and to update the DOM without a full
page refresh (the ZenJS action loop). A few Z-Tags like z-active
are processed
on the server-side after Nunjucks parses the template.
z-init
The z-init
attribute is used to initialize a page. For example, to initialize
a page with a list of books, you could add the z-init
attribute to the main
element:
<main z-init="books.list">
<!-- page content -->
</main>
This will find the action method named list
as a property of the init
object
in /actions/books.js
and execute it. The init method response will normally be
a full HTML page which will be sent to the browser and rendered.
In many cases, you only need one init function. For those cases, just define a
single init()
function in the actions file and supply just the module name to
z-init
like this:
<main z-init="books">
<!-- page content -->
</main>
Stacking multiple z-init tags
It is sometimes useful to stack multiple z-init
tags in a template. For
example, you might want to add a z-init="checkStatus"
to a layout for a
section of your app. After all Nunjucks extends
and includes
are parsed and
the template is built, z-init
tags will be processed in the order they appear
in the document. So in this example, the checkStatus
method will be called
first. In order to advance to the next z-init
in the stack, use ctx.next()
as explained in the ctx section below.
z-merge
The z-merge
attribute can be used so that navigation between pages is handled
by the client-side zen.js
script using an AJAX request to swap out the body
of the page and morph the head
of the page. The result is a seamless
navigation experience without a full page refresh. The starter includes a
z-merge
attribute on the html
element in the layout template:
<html lang="en" z-merge>
Typically, z-merge
is added to a top level element such as html
or body
and will be applied to child anchor tags with href
values which are relative
links and not downloads.
z-merge
can be selectively disabled by adding the z-merge="false"
attribute
to a child element. Then all children of this element will behave normally and
will not be merged.
IMPORTANT: Because z-merge
is really just an AJAX call to replace a chunk
of the DOM (the body
element specifically), we need to take care when using
<script>
tags within a page. The normal, expected JavaScript behavior when
updating the DOM with content containing a <script>
tag is for the tag to be
added as expected but for the actual script within the tag to not execute. The
client side zen.js handles this by explicitly removing and re-adding all
<script>
tags within the body element causing them to execute again. However,
be cautious not to do things like
document.addEventListener(<event>, <function>)
because this will execute every
time you navigate to the page causing multiple event listeners.
Also declaring global scope variables like const foo="bar"
can cause JS errors
on subsequest page visits because reexecuting this line of code effectively
attempts to reset the value of a const. The simple workaround for this is to
wrap your code in a self-executing function (IIFE) so that scope is always local
to the function which is destroyed and recreated each time the page is merged.
Using the Cash (tiny JQuery clone) library included with the default ZenJS
Starter Kit, we can do that like this:
<script>
$(function() {
const foo = "bar";
})
</script>
z-target
The z-target
attribute can be used to specify a target element for a merge.
This allows you to navigate to a new page with a new URL but specify only a
single element or a list of elements to be merged into the current page. For
example, while on the /books
list page, you might want to navigate to the
/books/1
detail page and merge only the #book-detail
element into the
current page:
<div id="books-list">
<a href="/books/123" z-target="#book-detail">Book 123</a>
<a href="/books/456" z-target="#book-detail">Book 456</a>
</div>
<div id="book-detail">
<!-- book detail content -->
</div>
By default, z-merge
will morph the head
element of the page and replace the
body
. Using z-target
will morph the head
and replace only the specified
elements. Note that z-target
can be added to anchor tags or any other element with a z-<action>
attribute.
z-<event>
The z-
prefix followed by the name of a standard DOM event such as click
or
submit
is used to trigger a ZenJS action loop. For example, to trigger an
action when a button is clicked, you would add the z-click
attribute to the
button element:
<button z-click="users.signOut">Sign Out</button>
When the button is clicked, the action method named signOut
in
/actions/users.js
will be executed. Typically, the action method response will
include one or more HTML elements which will be morphed into the DOM by the
client-side zen.js
script.
z-ready
The z-ready
attribute is used to trigger an action when the element is fetched
either by direct page request or z-merge
request. This allows you to load the
page as quickly as possible and then load additional elements which may take
longer due to additional database queries or other processing. For example, you
could add a z-ready
attribute to #books-list
to trigger an action on page
load to fetch the list of books and re-render the element and replace the
spinner.
<div id="books-list" z-ready="books.list">
{% if books %}
{% for book in books %}
<a href="/books/{{ book._id }}">{{ book.title }}</a>
{% endfor %}
{% else %}
{% include "_/spinner.njk" %}
{% endif %}
</main>
Note that a z-ready
action cannot trigger itself causing an infinite loop.
When a z-ready
action is triggered, its attribute is prepended with "✓". For
example z-ready="books.list"
will become z-ready="✓books.list"
and then this
action will not be triggered again.
An action may also be prefixed with @
to indicate that it should be triggered
only one time. For example, z-ready="@books.list"
will trigger the action
once, then the action name will be changed to z-ready="✓books.list"
and the
action will not be triggered again.
z-payload
The z-payload
attribute is used to pass data to an action. For example, to
pass the value of a book's id to an action, you would add the z-payload
attribute to the form field:
<button z-click="books.delete" z-payload="{{ book._id }}">
Delete
</button>
When the form is submitted, the value of book._id
will be passed to the action
method as a property of the ctx
object which is discussed in more detail later
in this document.
z-submit
The z-submit
attribute is used to trigger an action when a form is submitted.
For example, to trigger an action when a form is submitted, you would add the
z-submit
attribute to the form element:
<form z-submit="books.create">
<input type="text" name="title" placeholder="Title">
<input type="text" name="author" placeholder="Author">
<button type="submit">Create</button>
</form>
When the form is submitted, the action method named create
in
/actions/books.js
will be executed.
While this is also a z-<event>
attribute, it is discussed separately because
its behavior is slightly different:
- It can only be applied to a form element
- Its payload is the complete form data
z-active
The z-active
atttribute receives a boolean value and adds the active
class
to the element if the value is true
. Otherwise, the active
class is removed.
This is useful for adding a class to a navigation link to indicate which page is
currently active.
Example:
<a href="/books" z-active="{{ $meta.path.startsWith('/books') }}">Books</a>
As a shortcut for the above, you can use the z-active
attribute with the value
@
. This will add the active
class if the current window.location.href
starts with the value of the href
attribute of this element.
Example:
<a href="/books" z-active="@">Books</a>
This processing is done on the server-side after Nunjucks parses the template.
z-invalid
The z-invalid
attribute receives a string which should be empty if the field
is valid and should contain an error message if the field is invalid.
The client-side zen.js script will then process elements with z-invalid
attributes. When a field's z-invalid
attribute is a non-empty string, the
.invalid
class is added to that element and its parent <fieldset>
element.
The content of the z-invalid
attribute (the error message) is used to populate
<z-invalid>
custom HTML tag if present within the same parent <fieldset>
.
When a form contains one or more z-invalid
elements with a non-empty string
value, the form itself will also receive the .invalid
class and the form's
submit button will be disabled.
Additionally, an elment's z-invalid
value will be cleared on focus and form
re-parsed adding/removing .invalid
classes and enabling or disabling the
submit button accordingly. This allows the user to clear the error while editing
the field. See validation for implementation details.
z-validate
This attribute can be used a shortcut rather than adding a z-blur
attribute on
every field within a form to trigger a field validation. Instead add
z-validate="<module.method>"
to the parent form
element to activate
automatic validation of any fields within the form containing a z-invalid
attribute when they are blurred.
For example:
<form z-submit="books.create" z-validate="books.validate">
In this case, all child fields with a z-invalid
attribute will trigger the
books.validate
action when blurred.
z-disabled
The z-disabled
attribute receives a boolean value and adds the disabled
attribute to the element if the value is true
. Otherwise, the disabled
attribute is removed. This is useful for disabling a form input based on server
state.
z-checked
The z-checked
attribute receives a boolean value and adds the checked
attribute to the element if the value is true
. Otherwise, the checked
attribute is removed. This is useful for checking a checkbox based on server
state.
z-selected
The z-selected
attribute receives a boolean value and adds the selected
attribute to the element if the value is true
. Otherwise, the selected
attribute is removed. This is useful for selecting an option in a select element
based on server state.
<z-date>
This is a special custom HTML tag (unlike the other "z-tags" which are actually attributes applied to an HTML tag).
It is very common to have a date value returned from a database used to populate a template value like:
Order Date: {{ order.date }}
A custom Nunjucks filter could be used for this purpose; but because Nunjucks is processed on the server, that means all
dates would be formatted according to UTC time or the server's local time rather than the user's local time. By wrapping
a time in <z-date>
tags, the date will be formatted on the client side and offset to the user's local time.
The <z-date>
tag works with stringified JS Date objects like "2023-08-24T00:28:52.034+00:00" or with UTC timestamp integers like "1692836840197". When saving timestamps to the database, it is recommended to use a new Date()
constructor which saves the date as a BSON Date object in MongoDB.
Additionally, a format
attribute can be passed to specify one of the following preset formatting types:
date
(default) ex: "Thursday, June 15, 2023"time
ex: "3:02 PM"datetime
ex: "Thursday, June 15, 2023 at 3:02 PM"relative
ex: Today at 3:00 PM"
The relative
option is similar to datetime
but will replace the date with
strings: "Today", and "Yesterday" when appropriate.
Actions
The ZenJS action loop starts with a z-<action>
attribute which triggers an
AJAX call to a server-side action method. The action method is responsible for
processing the request and returning a response which is used to update the DOM.
For example, to delete a book, we might have a button with the z-click
attribute:
<button z-click="books.delete" z-payload="{{ book._id }}">
Delete
</button>
When the button is clicked, the action method named delete
in
/actions/books.js
will be executed. The action method receives a ctx
object
with several methods and properties which can be used to process the request and
return a response.
The /actions
directory is where you will create your action modules. Each
module may export an init
function or object, an actions
object, and/or an
auth
function. For example, the /actions/books.js
module might look like
this:
import db from "/deps.js";
export const init = async (ctx, $) => {
$.books = await db.collection("books").find().toArray();
ctx.render();
},
export const actions = {
delete: (ctx, $) => {
await db("collection").delete(_id: ctx.payloadId);
$.books = await db("collection").find().toArray();
ctx.render("#books");
}
};
In this example, we import the db
object and export an init
function and an
actions
object containing one action method delete
.
- The
init
function is executed when the page loads assuming that somewhere on the page, there's an attribute likez-init="books"
. - The
delete
action method is executed when the button in the example above is clicked because our sample HTML has button element withz-click="books.delete"
.
Every action method receives two arguments: ctx
and $
. The ctx
argument is
a Context object used to process the request and return a response.
The $
argument is a reference to the ctx.$
object which holds server-side
state. Setting any property on the $
object will become available to Nunjucks
templates. For example, setting $.name = "Bob"
will make the name
variable
available to Nunjucks templates.
Hello, {{ name }}.
Auth
In addition to init
and actions
, we can also optionally export an auth
function which runs before every init
and actions
action method and should
be written to return either true
or false
. If it returns false
, then the
action will not execute. If no auth
function is defined or if an auth
function is defined and returns true
, then action will execute.
A typical auth
function might check to see if the current logged in user is an
admin. If not, redirect to the log in page and return false
like this:
export const auth = (ctx) => {
const user = ctx.session.get("user");
if (!user?.roles?.includes("admin")) {
ctx.redirect("/admin/sign-in");
return false;
}
return true;
};
CTX
The ctx
object contains several methods and properties which can be used to
process the request and return a response.
ctx properties
ctx.trigger
When an action is triggered by a z-<event>
attribute, this is an object
containing the values of id
, name
, and value
attributes of the element
that triggered the action.
ctx.payload
The payload is the value of z-payload
on the element that triggered the action
or the entire form data if the action was triggered by z-submit
on a form
element.
ctx.session
The session object holds data that persists between requests. See "Sessions" section for more info.
ctx.hash
The hash of the current URL.
ctx.headers
The request headers sent when current page was loaded.
ctx.host
The host of the current URL.
ctx.hostname
The hostname of the current URL.
ctx.href
The full URL of the current page.
ctx.ip
The IP address of the client.
ctx.method
The HTTP method of the request.
ctx.params
The params of the current URL.
ctx.pathname
The pathname of the current URL.
ctx.port
The port of the current URL.
ctx.protocol
The protocol of the current URL.
ctx.query
The query string of the current URL.
ctx.search
The search string of the current URL.
ctx.secure
A boolean indicating whether the current URL is secure.
ctx.session
An object containing the current session keys and values. See "Sessions".
ctx methods
ctx.render([<id-or-ids>])
This is the workhorse of ZenJS actions. It is used to render the full page in an init method or to render one or more elements in an action method.
In an init method, you can call ctx.render()
with no arguments to render the
entire page.
In an action method, call it with an element ID or array of element IDs to render only those elements.
Example: ctx.render(['#books', '#authors'])
The current state of $
and $meta
will be parsed into the compiled template
and sent as a regular HTTP response.
ctx.redirect(<url>)
Redirects to a relative or absolute URL. For direct HTTP requests in an init
method which are not using z-merge
, a standard redirect header is sent. For
z-merge
requests and all redirects from within <custom-action>
handlers, a
header is set to invoke the redirect.
Example: ctx.redirect("/login")
ctx._404()
Renders the nearest 404 page template for this path. Example: ctx._404()
ctx._500(<message>)
Renders the nearest 500 page template for this path with optional error message.
Example: ctx._500("Failed")
ctx.setHeader(<string>)
Set a header to be sent with the response.
Example: ctx.setHeader("X-Test", "foobar")
ctx.setLocation(<string>)
Set a header containing a new URL for the client JS to replace the browser location. This is useful for cases where an action might for example load a specific record causing the viewport to no longer be relevant to the current URL. Use this method to correct that.
Example: ctx.setLocation("/books/123")
Note that this results in a similar outcome to using z-target
on an <a>
element with the href
set to the desired new location. Depending on the
specific use case, either approach may be used.
ctx.flash(<message>)
Set a flash message to the session to read once and then destroyed.
Example: ctx.flash("You have been logged out.")
ctx.next()
Call the next z-init
tag for further processing. This is used when stacking
multiple z-init
tags on a single page. If no more z-init
tags are found, an
error will be thrown.
ctx.getPayloadId()
If the payload is an object or a JSON string, the value of the _id
property
will be cast as an ObjectId and returned by this method. If the payload is a
string, the value will be cast to an ObjectId and returned. Otherwise, an empty
string will be returned. This is a handy shortcut for MongoDB queries in actions
methods.
ctx.getPayloadFields()
If the payload is an object or a JSON string with an _id
property, this method
will return the full payload object with the _id
property removed. This is
useful for updating a document in the database without attempting to overwrite
the _id
property which is disallowed.
ctx.getPayloadJSON()
If the payload is a JSON string, the JSON will be parsed and returned as an object.
ctx.ignore()
Ignore the request and do nothing.
Sessions
ZenJS uses the Oak
Sessions middleware to manage sessions. The session object is available in
the ctx.session
property of the ctx
object.
Sessions are used to persist data between requests. For example, you might use
sessions to store a user's login status or shopping cart items. It is also used
for flash messages and for maintaining application state $
between actions on
a page.
The docs for Oak Session say to use
ctx.state.session
but this refers to Oak'sctx
object, not ZenJS'sctx
object. In ZenJS, usectx.session
instead ofctx.state.session
.
Basic usage
The ctx.session
object has five methods:
ctx.session.get(<key>)
Get a value from the session.
Example: ctx.session.get("user")
ctx.session.set(<key>, <value>)
Set a value in the session.
Example: ctx.session.set("user", { id: 1, name: "Bob" })
ctx.session.has(<key>)
Check if a value exists in the session.
ctx.session.flash(<key>, <value>)
Set a value in the session to be read once and then removed the first time it's
accessed with ctx.get
. Useful for things like alert messages.
ctx.session.deleteSession()
Destroy the session entirely.
Session Store and Hours
The storage strategy and session expiration time are set in .env
as
SESSION_STORE
and SESSION_HOURS
. In dev mode, the recommended (default)
value for SESSION_STORE
is memory
. In production mode, the recommended
SESSION_STORE
is redis
because Redis is an in-memory database which is
extremely fast and is also very simple to install with brew install redis
on
Mac or sudo apt install redis
on Linux.
In some cases, you may want to use MongoDB as the session store for example if
deploying to a platform like Deno Deploy or Cloudflare Workers which do not
allow local installation of Redis. In this case, you can set SESSION_STORE
to
mongo
.
The default SESSION_HOURS
value is 2
hours. Rolling sessions are supported
meaning that the expiration time is reset every time the session is accessed.
Database
For database operations, ZenJS uses the Deno MongoDB driver which is very well
tested and well documented. See
docs with example CRUD
operations here. The driver is available in the db
object which can be
imported from zen/deps.js. Typically in an action module, you will import the
db
object like this:
import { db } from "/deps.js";
Then you can use the db
object to perform database operations. For example, to
find a user by username, you would use the following:
$.user = await db.collection("users").findOne({ username: "bob" });
Remember that when using the MongoDB driver, you must use await
when calling
database methods. Also remember to call toArray()
to get an array from a
returned cursor for methods like find()
.
$.users = await db.collection("users").find().toArray();
Note that validation is handled as a separate step. See the "Validation" section below.
Validation
In ZenJS, all validation is done on the server side including both form level
validation and field level validation. When a form is submitted and fails
validation, we should assign the validation errors to a local state variable like
$.invalid
, then call ctx.render("#book-form")
to re-render the form with the
updated value of $.invalid
. The fields in this form will use this value to
populate the value of a form's z-invalid
attributes like this:
<input id="title" name="title" z-invalid="{{ invalid.title }}"/>
The value of invalid.title
in this example might be rendered with an error
string like this:
<input name="title" name="title" z-invalid="Title is required"/>
If a z-invalid
attribute is populated with a non-empty string, the client side
zen.js script will then take these actions:
- Class
.invalid
will be added to this field, its parent<fieldset>
element, and its parent<form>
element (remember that the convention in ZenJS is to wrap each form field in a fieldset). - If a custom element
<z-invalid><z-invalid>
is present as a child of the samefieldset
, it will be populated with the value ofz-invalid
, e.g., "Title is required". - When the field receives a
focus
event, the value ofz-invalid
is set to an empty string, causing failed validation to be cleared for this form while the user is interacting with it (until it's blurred again or the form is submitted). - If the form contains any kind of submit button, the
disabled
attribute will be added to it. - If the parent form contains a
z-validate
attibute with an action module and method as its value, then when the field receives ablur
event, that action will fire on the server-side, typically to re-render just this field with an updatedz-invalid
value.
Breaking it down
That may sound like a lot, but if you break it down, it's actually pretty
sensible. How do we know if any fields in a form have failed validation? Because
fields which have failed server-side validation will have a non-empty value for
z-invalid
. And if any fields are invalid, we want to add the class .invalid
to the field, its parent fieldset
, and its parent form
(this allows you to
easily use CSS selectors to style failed validations however you like). And of
course, if a form has failed validation, it makes sense to also disable its
submit button.
If the form has an action defined for z-validate
then blur
listeners are
added for field-level validation alongside the focus listeners which clear
validation for the field. For example, if a user enters a the value "bob" in the
"pages" field whose schema requires a valid number, then when this field is
blurred, and books.validate
is called (as specified in the form's z-validate
attribute), we'll re-render this field with z-invalid
set to the current
validation error message or an empty string if it passed. But then when the user
clicks back into that field to correct it, it makes sense to temporarily clear
the error while they're typing.
Excluding fields from validation
If you want to use z-validate
to automate validation for most of the form, but
do not want the client-side magic to consider specific fields, simply don't
include a z-invalid
attribute for that field. Only fields with a z-invalid
attribute are parsed.
Server side
So now we know how the client side handles the presence or absence of
z-invalid
values, but how do we populate those values in the first place? The
simplified answer is to say that we use a schema to validate an object or field, then
use that result to populate the value of $.invalid
or whatever you've chosen
to call your state object containing validation failure with strings keyed by their
field names. It helps to consider the two separate tasks one at a time: Form
level validation before database operations like insert or update, and field
level validations for example when a field is blurred. But in both cases, we
start by creating a schema.
Schema
The Zen library includes a custom schema parsing and validation module which covers most validation and transformation
requirements and also supports custom parsing for special cases. Schema objects created with schema()
have a schema
property as well as parse
, parsePartial
, and parseProperty
methods. Each of these methods will always return
either an object containing data
or invalid
. Create additional schema files in the schema/
directory.
/schema/User.js
can be referenced as a guide.
Example:
import { schema } from "/zen/deps.js";
export const Book = schema({
title: "string|min:3",
pages: "number|optional",
});
Now this schema can be imported into an action module and used for data transformation and validation:
import { Book } from "/schema/Book.js";
const { data, invalid } = Book.parse(ctx.payload);
In this example, the full payload of the request is passed which often may include the _id
property. Since _id
isn't defined in our schema, it be silently stripped from the data
object.
Handling the returned data
or invalid
object
Zen Schema will always return an object containing either a data
or invalid
object.
If validation passes, a data
object will be returned containing the parsed data which includes only properties
defined in the schema and transformed according to its data type. For example, pages
is submitted as an input field
in a web form, this arrives at the server as a string type (because all values submitted through web forms are
strings). But because the specified type for pages
is "number"
, the returned value in data.pages
will be a
number
type ready for insertion to the database.
If validation fails, the invalid
object will contain the validation errors keyed by the field name. For example if "abc" was submitted through a web form for pages
, the return value for invalid.pages
would be "Pages must be a number"
. This makes it easy to populate the $.invalid
state object (or any other state property name).
A typical pattern for handling the schema object parse result in a ZenJS action method is to check for the presence of
invalid
and if present, populate the invalid state object, re-render the form, and return out of the action, short-circuiting the rest of the function like this:
create: (async (ctx, $) => {
const { data, invalid } = Book.parse(ctx.payload);
if (invalid) {
$.invalid = invalid;
$.book = ctx.payload;
ctx.render("#panel");
return;
}
// No errors, save parsed data to db.
db.collection("books").insert(data);
ctx.redirect("/books");
});
In line 2, we start by parsing the submitted data. If validation failed, we assign that to a state variable in line 4
which by convention is named $.invalid
but can be anything you like.
In line 5, we assign the submitted values to $.book
so that the form is rendered with submitted values populated, then
render the form and return out of the function.
This will result in the form being re-rendered in place on the client with the form fields' z-invalid
values set
according to the results of schema validation. Then the client-side processing will set .invalid
classes, populate
<z-invalid>
elements, and enable/disable the submit button.
Validating a partial object
In some cases, you may want to validate only a partial object. For example, when updating a record, you may want to
validate only the fields that were submitted. In this case, use the parsePartial()
method instead of parse()
like
this:
const { data, invalid } = Book.parsePartial(ctx.payload);
Note that only submitted fields will be validated and returned in the data
object. Any fields not submitted will be
ignored regardless whether specified as optional
or required (default).
Validating a single form field
It is common to validate individual form fields and provide user feedback while the form is being edited and before being submitted. This is traditionally done on the client side requiring two entirely separate schemes for validation on the client side and then again on the server side. ZenJS simplifies this by handling all validations including field-level validation on the server.
Validating a single field is similar to full validation. But here we'll use ctx.trigger
values rather than
ctx.payload
to get the id
, name
, and value
of the single field that triggered this action.
validate: (ctx, $) => {
const { id, name, value } = ctx.trigger;
const { invalid, data } = Book.parseProperty(name, value);
invalid
? $.invalid = { ...$.invalid, ...invalid }
: delete $.invalid?.[name];
$.book[name] = value;
ctx.render(id);
},
Notice in line 3, we call the schema's parseProperty()
method. This checks only the one specified field against the
schema. For nested properties, use dot notation like parseProperty("address.city", "New York")
.
In lines 5 and 6, we set the value of $.invalid
to the current value overwritten by the new result of validation for
this one field – or the property for that field is deleted if it passed validation.
Finally, we make sure the field is populated with the value submitted and re-render it.
Note that this method is named validate
by convention but you're free to name it whatever you like.
Schema types and options
A schema item must start with a data type followed by zero or more options. All types support optional
, default
, label
, and
custom functions. optional
makes the property optional.default
sets the default value (effectively making it also optional). label
allows you to specify a custom label
for the field name used in invalid
message values and uses the key set to titlecase by default. For example, if the
field name is title
, the default label will be "Title". The label
option allows you to override this. For example,
label:Book Title
will result in "Book Title" being used in invalid
messages instead of "Title".
Available types and their respective options include:
"string"
Returns the parsed data
property as a string or returns an invalid
object.
min:<number>
: Minimum lengthmax:<number>
: Maximum lengthstartsWith:<string>
: Must start with specified stringendsWith:<string>
: Must end with specified stringincludes:<string>
: Must include specified stringemail
: Must match valid email formaturl
: Must match valid url formatid
: Must match valid MongoDB ObjectId formatoptional
: Field is optionaldefault
: Set a default string valuelabel
: A custom label for invalid messages<string>
: A custom function
"number"
Returns the parsed data
property as a number or returns an invalid
object.
min:<number>
: Minimum valuemax:<number>
: Maximum valueinteger
: Must be an integeroptional
: Field is optionaldefault
: Set a default number valuelabel
: A custom label for invalid messages<string>
: A custom function
"boolean"
Returns the parsed data
property as a number or returns an invalid
object. The following values are coerced to
true
: true
, "true"
, "1"
, 1
. The following values are coerced to false
: false
,
"false"
, "0"
, 0
.
optional
: Field is optionaldefault
: Set a default boolean valuelabel
: A custom label for invalid messages<string>
: A custom function
object
Returns the object as is or returns an invalid
object. Note that this type is useful for validating objects of unknown
shape. If you know the shape of the object, it's better to use a nested object schema.
optional
: Field is optional- default is not supported for this type
label
: A custom label for invalid messages<string>
: A custom function
date
Returns the object as a date or returns an invalid
object.
optional
: Field is optional- default is not supported for this type
label
: A custom label for invalid messages<string>
: A custom function
any
Returns the value as is. Any value is accepted.
optional
: Field is optional- default is not supported for this type
label
: A custom label for invalid messages<string>
: A custom function
[string]
, [number]
, [boolean]
, [object]
, [date]
, [any]
These are the array equivalents of the above types. For example, [string]
will return an array of strings or an
invalid
object.
Custom functions
Any custom function can be used as a schema option in conjunction with a standard type. To do this, create a function
which receives the value and returns an object containing either data
or invalid
, then pass the function as a second
argument to the schema()
function. Then reference the function name as a schema option like this:
const { data, invalid } = schema({
pages: "number|isEven",
}, isEven);
function isEven(value) {
return value % 2 === 0
? { data: value }
: { invalid: "must be an even number" };
}
In line 2, we use the isEven
custom function as a schema option. Then in line 3, pass in the isEven
function defined
in line 5 which receives the value and returns either data
or invalid
depending on whether the value is divisible by
2. Note that the string assigned to invalid
will be prepended with the label
value, e.g., "Pages must be an even
number".
Admin
The starter kit includes an admin system which initially includes CRUD for users. You can add additional schemas and actions as needed. The users CRUD is a good example of how to add additional schema and actions.
To create the first user, a script is included in the devops
directory. Run
the script with the following command:
deno task admin
Devops
The devops
directory contains scripts for common tasks such as creating a new
admin user, deploying to remote server, and PM2 scripts which are run on the
live server to start/stop PM2.
(more docs coming soon...)