Holistic approach for making simple PWAs and complex HTML5 pages from Clojure. Bindings for hiccup and garden (css).
So it's like a layer of knowledge about the real world on top of Hiccup.
Out of the box:
- Precaching Service Worker generation based on Workbox
- Cache-busting for local assets
- That
name="viewport"
meta tag that you need for responsive pages and language tag - Meta for SEO, Twitter, Facebook (Open Graph), link sharing
- Clojure stylesheets with
garden
- Clojure markup rendered with
hiccup
- Async stylesheets loading
Java 8 or later.
(ns pages.home)
(defn page [req]
;; essentials
{:title "Lightpad"
:body [:body.page [:h1 "Ah, a Page!"]]
:head-tags [[:meta {:name "custom" :property "stuff"}]]
:stylesheet-async "large-stuff.css" ; injects an async renderer(s)
:script "/app.js" ; async by default
:garden-css [:h1 {:font-size :20px}] ; critical path css (or just inline the whole thing)
:garden-css-cache? true ; uses simple-dimple memoize cache, so only lives in the lifecycle
;; seo and meta
:description "Like a notepad but cyberpunk"
:twitter-site "@lightpad_ai"
;; images
:favicon "https://lightpad.ai/favicon.png"
:og-image "https://lightpad.ai/og-image.png"
;; PWA stuff
:manifest true
:lang "en"
:theme-color "hsl(0, 0%, 96%)"
:service-worker "/service-worker.js" ; will inject also a service worker lifecycle script
:sw-default-url "/app"
:sw-add-assets ["/icons/fonts/icomoon.woff", "/lightning-150.png"]})
(ns server
(:require [page-renderer.api :as pr]
[compojure.core :refer [defroutes GET]]
[pages.home :as p]))
(defroutes
(GET "/" req
{:status 200
:headers {"Content-Type" "text/html"}
:body (pr/render-page (p/page req)})
(GET "/service-worker.js" req
{:status 200
:headers {"Content-Type" "text/javascript"}
; will generate a simple Workbox-based service worker on the fly with cache-busting
:body (pr/generate-service-worker (p/page req))})
(GET "/quicker-way" req (pr/respond-page (p/page req))))
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<link href="/favicon.png" rel="icon" type="image/png">
<meta content="width=device-width, initial-scale=1, maximum-scale=1" name="viewport">
<title>Page</title>
<meta content="Some bird stuff" name="description">
<meta content="summary" name="twitter:card">
<meta content="birds.org" name="twitter:site">
<meta content="Some bird stuff" name="twitter:description">
<meta content="https://birds.org/great-tit.png?mtime=1560280129605" name="twitter:image">
<meta content="Page" property="og:title">
<meta content="Some bird stuff" property="og:description">
<meta content="https://birds.org/great-tit.png?mtime=1560280129605" property="og:image">
<style id="inline-css--garden">
h1 {
font-size: 20px;
}
</style>
<!-- Service Worker Lifecycle Snippet -->
<script>
import { Workbox } from 'https://storage.googleapis.com/workbox-cdn/releases/4.1.0/workbox-window.prod.mjs';
const promptStr = 'New version of the application is downloaded, do you want to update? May take two reloads.';
function createUIPrompt(opts) {
if (confirm(promptStr)) {
opts.onAccept()
}
}
if ('serviceWorker' in navigator) {
const wb = new Workbox('/service-worker.js');
wb.addEventListener('waiting', (event) => {
const prompt = createUIPrompt({
onAccept: async () => {
wb.addEventListener('activated', (event) => {
console.log('sw-init: activated')
window.location.reload();
})
wb.addEventListener('controlling', (event) => {
console.log('sw-init: controlling')
});
wb.messageSW({type: 'SKIP_WAITING'});
}
})
});
wb.register();
}
</script>
</head>
<body class="page">
<h1>Ah, a Page!</h1>
<script>
(function(){
var link = document.createElement('link');
link.rel='stylesheet';
link.href='large-stuff.css';
link.type='text/css';
document.head.appendChild(link);
})()
</script>
</body>
</html>
importScripts('https://storage.googleapis.com/workbox-cdn/releases/4.3.1/workbox-sw.js')
workbox.precaching.precacheAndRoute([
{ url: '/heavy-stuff.css', revision: 'file-hash' },
{ url: '/fonts/icomoon.woff', revision: 'file-hash' },
{ url: '/lightpad/compiled/app.js', revision: 'file-hash' },
{ url: '/favicon.png', revision: 'file-hash' },
{ url: '/app', revision: 'file-hash' }
], { ignoreURLParametersMatching: [/hash/] })
workbox.routing.registerNavigationRoute(
workbox.precaching.getCacheKeyForURL('/app'), {
whitelist: [ /^\/app/ ],
blacklist: [ /^\/app\/service-worker.js/ ]
}
)
workbox.routing.setCatchHandler(({event}) => {
console.log('swm: event ', event)
})
addEventListener('message', (event) => {
if (event.data && event.data.type === 'SKIP_WAITING') {
console.log('swm: skipping waiting')
skipWaiting()
}
})
self.addEventListener('activate', () => {
console.log('swm: activated')
})
self.addEventListener('install', () => {
console.log('swm: installed')
})
Use page-renderer.api
namespace.
(defn ^String render-page [renderable])
Produces an html string.
(defn ^String generate-service-worker [renderable])
Produces a JavaScript ServiceWorker script text. Service worker will additionally load Workbox script.
(defn ^Map respond-page [renderable])
Produces Ring compatible response map with status 200.
(defn ^Map respond-service-worker [^Map renderable])
Produces Ring compatible response map with status 200.
renderable
– is a map that may have the following fields
^Vector :body
- a vector for Hiccup to render into HTML of the document's body^String :title
- content for title tag^String :favicon
- favicon's url^String :og-image
- Open Graph image URL^String :script
- script name, will be loaded asynchronously^String :stylesheet
- stylesheet filename, will be plugged into the head, will cause browser waiting for download.
^String :stylesheet-async
- stylesheet filename, will be loaded asynchronously by script.^String :garden-css
- data structure for Garden CSS^String :garden-css-caching?
- enable memoize Garden CSS (default: false)^String :script-sync
- script name, will be loaded synchronously^String :js-module
- entry point for JS modules. If you prefer your scripts to be served as modules^Boolean :skip-cachebusting?
- will skip automatic cachebusting if set. Defaults to false.^String :on-dom-interactive-js
- a js snippet to run once DOM is interactive or ready.^String/Collection<String> :stylesheet-inline
- stylesheet filename, will be inlined into the head.
-
^String :link-image-src
- url to image-src -
^String :link-apple-icon
- url to image used for apple-touch-icon link -
^String :link-apple-startup-image
- url to image used for apple-touch-startup-image link -
^String :theme-color
- theme color for PWA (defaults to white) -
^String/Boolean :manifest
- truthy value will add a manifest link If a string is passed – it'll be treated as a manifest url. Otherwise '/manifest.json' will be specified. -
^String/Boolean :service-worker
- service worker url, defaults to /service-worker.js -
^String :sw-default-url
– application default url. Must be an absolute path like '/app'. Defaults to '/'. Will be used in a regexp. -
^List<String> :sw-add-assets
- a collection of additional assets you want to precache, like ["/fonts/icon-font.woff" "/logo.png"]
^String :lang
- when provided will render a meta tag and a document attribute for page language.^String :meta-title
- content for the title tag (preferred)^String :meta-keywords
- content for the keywords tag^String :meta-description
- meta description^Map :meta-props
– meta which must be rendered as props {'fb:app_id' 123}^String :head-tags
- data structure to render into HTML of the document's head
Open Graph meta (@link https://ogp.me)
^String :og-title
- OpenGraph title^String :og-description
- OpenGraph description^String :og-image
- absolute url to image for OpenGraph^String :og-type
- OpenGraph object type^String :og-url
- OpenGraph page permalink
Twitter meta (@link https://developer.twitter.com/en/docs/tweets/optimize-with-cards/guides/getting-started)
^String :twitter-site
- Twitter @username. Required for all Twitter meta to render^String :twitter-creator
- Twitter @username.^Keyword :twitter-card-type
- Twitter card type one of #{:summary :summary_large_image :app :player}^String :twitter-description
- Twitter card description^String :twitter-image
- Twitter image link. Twitter images are useu^String :twitter-image-alt
- Twitter image alt
page-renderer
allows you to produce a full-blown offline-ready
PWA fast.
Your users will be able to "install" it as a PWA app on mobile platforms or as Chrome
app on desktop platforms. All you need to do is just add another route to your scheme.
If you use service-worker
field then page-renderer
will generate
a precaching service worker. The worker utilizes
Workbox (by Google)
and will precache all the assets that you've defined in renderable
, and will be able
to serve them offline. It also does proper cache-busting with hashes.
page-renderer
will also inject a service worker lifecycle management script into
your page so that your users will be prompted to download a newer version of your
website when it's ready.
page-renderer
provides very basic, but bulletproof cache-busting by providing
a url param with content-hash (or last modification timestamp), like /file?hash=abec112221122
.
For every stylesheet, script and image on resource paths – it will generate
a content hash. If the file can't be found on the classpath
or inside a local resources/public
directory it will receive the library load time,
roughly equaling the application start time.
- Liverm0r/PWA-clojure – PWA example by Artur Dumchev
- Plus Minus game by Artur Dumchev. Repo
Personally I use it for all my website projects including:
- Lightpad.ai – includes generated service worker, installable PWA
- Spacegangster.io – my website
If you are using a frontend proxy server like Nginx – don't forget to prevent it from serving service-worker as a static asset. My js assets block looks like this
location ~ ^(?!/service-worker).*\.(?:js|css|svg)$ {
etag off;
expires 1y;
gzip_vary on;
add_header Cache-Control "public";
access_log off;
}
Also note the switched off etag. If you use page-renderer you can turn off etag and use expires header only for more aggressive caching and preventing avoidable requests. See details in this thread on StackOverflow.
Copyright © 2019 Ivan Fedorov
Distributed under the MIT License.