Creating Static Pages

This tutorial covers creating static or server side rendered pages with Live Elements. It goes through configuring the server, rendering the content, adding events and behaviors, and creating reusable components within this context.

It uses the setup made in the first project article. Basically, we create an empty folder, and then use lvweb inside the folder to generate a starting webpack project:

mkdir staticpages
cd staticpages
npm i live-elements-web-cli
npx lvweb init
npm i
lvweb serve

Now, if we go to localhost:8080, we should see the following text:

Welcome to Live Elements

In the generated sample, the rendered component in app/Home.lv is configured in the bundle file (bundle/bundle.lv):

import live-elements-web-server.bundle
import live-elements-web-server.router
import live-elements-web-server.style
import .app

instance bundle Bundle{
    ViewRoute{ url: '/' c: Home }
    Stylesheet{ src: './styles/style.css' output: 'style.css' }
}

The ViewRoute tells the server to send this component for rendering at the root url: '/'. By default the route is rendered on the client side, which means the page's content is rendered and updated dynamically. If we were to inspect the page's source code, it will initially have an empty body with a script that will render the content:

<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8">
    <title>Index</title>
  </head>
  <body data-content-type="main">
    <script src="/scripts/home.bundle.js"></script>
  </body>
</html>

This is controlled by the render property. The render property, is set by default to ViewRoute.CSR:

ViewRoute{ url: '/' c: Home render: ViewRoute.CSR }

We can set the render property to ViewRoute.SSR, and then restart the server:

// ...

instance bundle Bundle{
    ViewRoute{ url: '/' c: Home render: ViewRoute.SSR }
    Stylesheet{ src: './styles/style.css' output: 'style.css' }
}
lvweb serve

If we check out the page source code again, unfortunately we won't see any changes. And that's because the page is still being rendered on the client side. This is actually due to lvweb being run in development mode. In development mode, lvweb prefers client side rendering, because it can reload pages live as they are being saved, making them easier and faster to develop. So, even server side rendered pages will only be rendered on the server when they are released in production, which is by compiling an optimized build, and then running the new build:

lvweb compile
lvweb run

Testing server side rendered pages this way is a bit cumberstone, as with any small change, we have to recompile the entire website before testing it again. It's why lvweb serve has a renderMode argument, through which we can tell lvweb to render all views as if they were in production:

lvweb serve --renderMode production

Looking at our page source code again, we will now see the entire source:

<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8">
    <title>Live Elements</title>
    <link rel="stylesheet" href="/styles/style.css">
  </head>
  <body data-content-type="main">
    <h1>Welcome to Live Elements!</h1>
    <script src="/scripts/home.bundle.js"></script>
  </body>
</html>

We've made the page render server side. When compiling for production, this page will be rendered, cached and served with minimal resources from our server. It's useful for writing documentation pages, presentation pages, or other static content.

Let's try to add some interactions to our page.

Events & Interactions

In the previous quick start tutorial, we've added a button to our page to alert a message. Let's add the same button in app/Home.lv:

import live-web.dom
import live-elements-web-server.view

component Home < PageView{
    head: PageProperties{
        title: "Live Elements"
        StyleLink{ href: '/styles/style.css' }
    }
    Body{
        H1`Welcome to Live Elements!`
        Button{ 
            on click: () => { alert('Button clicked.') }
            T`Clickable`
        }
    }
}

We should also reset our style to have a better view of the button. We can set styles/style.css to:

body{
    font-family: Arial, Helvetica, sans-serif; 
    font-size: 20px; 
    color: black; 
    margin: 0px; 
    padding: 0px; 
}

If we restart the server now lvweb server --renderMode production, we will notice the button is there, however, clicking it won't have any effect:

This is because the button is rendered to the dom on the server side, without any events. When the server renders all the elements to the dom, it won't render any events or bindings, as most of them don't have an equivalent in html or javascript DOM functions. Take the following example:

import live-web.dom
import live-web.model
import live-elements-web-server.view

component Counter < State{
    int counter : 0
}

component CounterView < PageView{
    id: counterView // id to reference inside the hierarchy
    head: PageProperties{ StyleLink`/styles/demo.css` }

    // create the counter state
    Counter state: Counter{}

    Div{ classes: ['center', 'full']
        Div{ classes: ['counter']
            P{ 
                T{ text: 'Counter value: ' + counterView.state.counter } 
            }
            Button{ on click: () => {counterView.state.counter++} T`+` }
            Button{ on click: () => {counterView.state.counter--} T`-` }
        }
    }
}

The example uses states, property bindings, and events to create a dynamic page. When rendering the page on the client side, all this functionality will work because the live elements code will be sent to the client in order to render and update the page accordingly. On the server side, the code will only be used to render once and save the page in its html form.

One way to add events to the page is through hydration. It's when the page is rendered on the server side, but the rendering code is also sent along with the rendered page to the client. The events from the rendered page are attached back to the DOM structure the page was rendered from. This would attach and reactivate all the functionality as if the page was rendered directly on the client.

This is useful in some cases, but it comes with a drawback. Hydrating a server-rendered page on the client side will increase load times and resource usage, as event listeners need to be reattached, and dynamic functionalities re-enabled, but also the entire javascript DOM structure needs to be downloaded by the browser additionaly to the rendered DOM.

Live Elements currently doesn't support hydration, but it's not far down the roadmap, and should be available soon.

In some cases hydration is worth it, in others not so much. For a page with lots of content and few interactions, like one or two buttons, hydration would be overkill. In this siutation, there are 2 options:

  • Include a separate javascript file to handle the few interactions on the page, and link to the file via a script tag. This the most common approach.
  • Use DOMBehavior. An new approach specific to Live Elements.

DOMBehavior

DOMBehavior is a component that attaches events to the rendered DOM tree. It works on both client side and server side rendered pages. This is how a DOMBehavior would attach to a Button:

import live-web.dom
import live-elements-web-server.view

component Home < PageView{
    head: PageProperties{
        title: "Live Elements"
        StyleLink{ href: '/styles/style.css' }
    }
    Body{
        H1`Welcome to Live Elements!`
        Button{ 
            T`Clickable` 

            DOMBehavior{
                domEvents = {
                    click: (e) => {
                        alert('Button Clicked')
                    }
                }
            }
        }
    }
}

DOMBehavior has a property called domEvents, which accepts an object where each key is a DOM event name, and values are handler functions for those events. By default, DOMBehavior will attach these events to its parent in the hiararchy. In the eample above this is the Button. We can also specify a different element to attach these events to using the target property:

Button{ id: customButton T`Custom button`}

DOMBehavior{
    target: customButton
    domEvents = {
        click: (e) => {
            alert('Button Clicked')
        }
    }
}

DOMBehavior works both when rendered on the client and on the server side. Behind the scenes, DOMBehavior does the following:

  • If the page is rendered on the client side, it simply waits for the component to have a DOM object attached. In this case, it waits for the Button to get rendered to the DOM, which will set the Button dom property.
  • If the page is rendered on the server side, it marks the button as having a click event. Then, after the page finishes rendering, it will create a script with the Button's functionality and attach it to the rendered page. If multiple DOMBehavior components are used throghout the page, each one will get stored with it's functionality inside the script. When the page gets rendered, the script will attach all the behaviors to the marked DOM elements.

Note that DOMBehavior event handlers should only be used with html DOM elements. When implementing these functions, do not use or reference any live elements objects.

Button{ 
    id: customButton
    T`Clickable` 
    string message: ''
    
    DOMBehavior{
        domEvents = {
            click: (e) => {
                alert(customButton.message)
            }
        }
    }
}

In the example above, if using server side rendering, the reference to button.message will be lost on the client side, and this will error. You can set an attribute to the DOM instead:

Button{ 
    T`Clickable` 
    props = { data: { message: 'click message' } }
    
    DOMBehavior{
        domEvents = {
            click: (e) => {
                alert(e.target.dataset.message)
            }
        }
    }
}

So, why use DOMBehavior? The main reason is modularity. Using DOMBehavior inside a component makes it reusable without needing to attach any extra scripts to a page. A component, whether client side or server side rendered, can encapsulate it's entire logic inside DOMBehaviors.

Take a tree-view for example. This website uses it for it's sidebar navigation. The tree-view has minimal functionality, as it only needs to collapse or expand its items when clicked. We can delegate that functionality with DOMBehavior, and create a reusable TreeView component quite easily:

import live-web.dom
import live-web.behavior
import live-web.model

component TreeNode < State{
    default children: []

    string label: ''
    boolean expanded: false
}

component TreeNodeView < Li{
    id: treeNodeView
    TreeNode node: null
    Button{
        Span{ 
            T{
                text: (treeNodeView.node && treeNodeView.node.children.length > 0) 
                    ? (treeNodeView.node.expanded ? '-' : '+') 
                    : '' 
            }
        }
        T{ text: treeNodeView.node ? treeNodeView.node.label : '' }

        DOMBehavior{
            domEvents = { click: (e) => {
                const ul = e.target.parentNode.querySelector('ul')
                if ( !ul.hasChildNodes() )
                    return
                if ( ul.classList.contains('hidden') ){
                    e.target.parentNode.querySelector('span').innerHTML = '-'
                    ul.classList.remove('hidden')
                } else {
                    e.target.parentNode.querySelector('span').innerHTML = '+'
                    ul.classList.add('hidden')
                }
            }}
        }
    }
    TreeView{
        classes: [treeNodeView.node && treeNodeView.node.expanded ? '' : 'hidden']
        state: treeNodeView.node
    }
}

component TreeView < Ul{
    TreeNode state: null
    children: this.state ? this.state.children.map(child => (TreeNodeView{ node: child })) : []
}

We're using a TreeNode as a state. Each TreeNode has a TreeNodeView as it's view. The TreeNodeView acts like a button that expands or collapses it's child nodes, which are recursively stored under a TreeView component. To expand/collapse, we only use DOM object classes to check if the tree is expanded or not:

const ul = e.target.parentNode.querySelector('ul')
if ( ul.classList.contains('hidden') ){
    e.target.parentNode.querySelector('span').innerHTML = '-'
    ul.classList.remove('hidden')
} else {
    e.target.parentNode.querySelector('span').innerHTML = '+'
    ul.classList.add('hidden')
}

We can save the TreeView and associated components above in app/TreeView.lv and then use them in the Home.lv page:

import live-web.dom
import live-web.behavior
import live-elements-web-server.view

component Home < PageView{

    head: PageProperties{
        title: "Live Elements"
        StyleLink{ href: '/styles/style.css' }
    }

    Body{
        H1`Welcome to Live Elements!`
        TreeView{
            state: TreeNode{
                TreeNode{ label: 'Chapter 1' }
                TreeNode{ label: 'Chapter 2' 
                    TreeNode{ label: 'Subchapter 1' }
                }
                TreeNode{ label: 'Chapter 3' 
                    TreeNode{ label: 'Subchapter 1' }
                    TreeNode{ label: 'Subchapter 2' }
                }
            }
        }
    }
}

We can also apply a minimal style to the view to make it a bit more visible:

body{
  color: black;
}
li{
  list-style: none;
}
li button{
  background: none;
  border: none;
}
.hidden{
  display: none;
}

Summary

This tutorial covered the following:

  • Setting up a ViewRoute to render server side
  • Configuring lvweb to render in production
  • Using DOMBehavior to attach events to both client and server rendered pages
  • Creating a reusable component with DOMBehavior