← Public packages
@kentcdodds/christ-paintings
Official Latter-day Saint paintings of Jesus Christ, one at a time, to show children during church, plus coloring pages of the Savior. Swipe to move. Works offline.
src/app.ts
440 lines · 17.7 KB · TypeScriptimport { packageContext } from 'kody:runtime'
import { ABOUT_CSS, ABOUT_JS, UPDATE_TOAST_CSS, aboutBodyHtml } from './about-page.ts'
import { CLIENT_JS } from './client-script.ts'
import { COLORING_CSS, COLORING_JS, coloringBodyHtml } from './coloring-page.ts'
import { allImagePaths, imageSourceFor, serveImage } from './images.ts'
import { PAINTINGS } from './paintings.ts'
import { readRecordedVersion } from './record-version.ts'
import { OG_IMAGE_JPEG } from './og-image.ts'
import { CSS } from './styles.ts'
import { UPDATE_JS } from './update-script.ts'
import type { AppVersion } from './version.ts'
// Bump to rotate the service worker's shell cache. Images live in a separate cache keyed
// by path, so they survive shell updates.
const VERSION = '4'
const DESCRIPTION = 'Official Latter-day Saint paintings of Jesus Christ, one at a time, to show children during church.'
const ICON_FILES = ['icon-192.png', 'icon-512.png', 'maskable-192.png', 'maskable-512.png']
const ICON_SVG = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 96 96"><rect width="96" height="96" rx="20" fill="#101116"/><path d="M33 74V43a15 15 0 0 1 30 0v31z" fill="none" stroke="#d6b678" stroke-width="3"/></svg>`
const COLOR_ICON = `<svg width="18" height="18" viewBox="0 0 24 24" aria-hidden="true"><path d="M12 3a9 9 0 1 0 0 18c1.1 0 1.6-.8 1.6-1.6 0-.9-.7-1.3-.7-2.2 0-1 .8-1.7 1.8-1.7H17a4 4 0 0 0 4-4C21 6.6 17 3 12 3z" fill="none" stroke="currentColor" stroke-width="1.6"/><circle cx="7.5" cy="11" r="1.3" fill="currentColor"/><circle cx="10.5" cy="7" r="1.3" fill="currentColor"/><circle cx="15" cy="7.5" r="1.3" fill="currentColor"/></svg>`
const INFO_ICON = `<svg width="16" height="16" viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="12" r="9.5" fill="none" stroke="currentColor" stroke-width="1.6"/><path d="M12 11v6" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/><circle cx="12" cy="7.6" r="1.2" fill="currentColor"/></svg>`
const HOST = /^[a-z0-9.-]+(:\d{1,5})?$/i
/**
* Public origin of this request, for the absolute canonical, og:url, and og:image URLs.
* Comes from the forwarded host (custom domain or kody.run), so forks need no changes.
* Every in-app URL is relative to packageContext.appBasePath instead.
*/
function publicOrigin(request: Request, url: URL) {
const raw = (request.headers.get('x-forwarded-host') || request.headers.get('host') || url.host).split(',')[0]!.trim()
if (!HOST.test(raw)) return url.origin
const local = /^(localhost|127\.|\[::1\])/i.test(raw)
return `${local ? 'http' : 'https'}://${raw.toLowerCase()}`
}
const OG_ALT = 'Paintings of Jesus Christ: a phone showing the painting Jesus the Christ by Del Parson in the app, beside the title and the words Official Latter-day Saint art for children at church'
function escapeAttr(value: string) {
return value.replace(/&/g, '&').replace(/"/g, '"').replace(/</g, '<')
}
function paths() {
const appBase = (packageContext?.appBasePath ?? '').replace(/\/+$/, '')
const assetBase = (packageContext?.assetBasePath ?? `${appBase}/_assets`).replace(/\/+$/, '')
return { appBase, assetBase }
}
function documentHtml(input: {
appBase: string
assetBase: string
page: 'home' | 'about' | 'coloring'
siteUrl: string
path: string
title: string
runningSha: string
viewport: string
css: string
body: string
scripts: string[]
}) {
const base = escapeAttr(input.appBase)
const assets = escapeAttr(input.assetBase)
const site = escapeAttr(input.siteUrl)
return `<!doctype html>
<html lang="en" data-app-base="${base}" data-asset-base="${assets}" data-running-sha="${escapeAttr(input.runningSha)}" data-page="${input.page}">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="${input.viewport}" />
<meta name="theme-color" content="#0b0b0e" />
<meta name="mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black" />
<title>${input.title}</title>
<meta name="description" content="${escapeAttr(DESCRIPTION)}" />
<link rel="canonical" href="${site}${input.path}" />
<meta property="og:type" content="website" />
<meta property="og:site_name" content="Paintings of Jesus Christ" />
<meta property="og:title" content="${escapeAttr(input.title)}" />
<meta property="og:description" content="${escapeAttr(DESCRIPTION)}" />
<meta property="og:url" content="${site}${input.path}" />
<meta property="og:image" content="${site}/og.jpg" />
<meta property="og:image:width" content="1200" />
<meta property="og:image:height" content="630" />
<meta property="og:image:alt" content="${escapeAttr(OG_ALT)}" />
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content="${escapeAttr(input.title)}" />
<meta name="twitter:description" content="${escapeAttr(DESCRIPTION)}" />
<meta name="twitter:image" content="${site}/og.jpg" />
<link rel="manifest" href="${base}/manifest.webmanifest" crossorigin="use-credentials" />
<link rel="icon" href="${base}/icon.svg" type="image/svg+xml" />
<link rel="apple-touch-icon" href="${assets}/icons/icon-192.png" />
<style>${input.css}${UPDATE_TOAST_CSS}</style>
</head>
<body>
${input.body}
${input.scripts.map((js) => `<script>${js}</script>`).join('\n')}
</body>
</html>`
}
function viewerPage(appBase: string, assetBase: string, siteUrl: string, version: AppVersion) {
const data = JSON.stringify(
PAINTINGS.map((p) => ({ id: p.id, title: p.title, artist: p.artist, alt: p.alt, width: p.width, height: p.height })),
).replace(/</g, '\\u003c')
const slide = `<figure class="slide"><img alt="" decoding="async" draggable="false" /></figure>`
return documentHtml({
appBase,
assetBase,
page: 'home',
siteUrl,
path: '/',
title: 'Paintings of Jesus Christ',
runningSha: version.sha,
viewport: 'width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover',
css: CSS,
body: `<main id="viewer" tabindex="-1" aria-roledescription="carousel" aria-label="Paintings of Jesus Christ">
${slide}
${slide}
${slide}
</main>
<div id="caption" class="caption" aria-live="polite"><div class="text"><span id="cap-title" class="title"></span><span id="cap-artist" class="artist"></span></div><a id="coloring-link" class="about" href="${escapeAttr(appBase)}/coloring" aria-label="Coloring pages">${COLOR_ICON}</a><a id="about-link" class="about" href="${escapeAttr(appBase)}/about" aria-label="About these paintings">${INFO_ICON}</a></div>
<script id="paintings-data" type="application/json">${data}</script>`,
scripts: [UPDATE_JS, CLIENT_JS],
})
}
function aboutPage(appBase: string, assetBase: string, siteUrl: string, version: AppVersion) {
return documentHtml({
appBase,
assetBase,
page: 'about',
siteUrl,
path: '/about',
title: 'About these paintings',
runningSha: version.sha,
viewport: 'width=device-width, initial-scale=1, viewport-fit=cover',
css: ABOUT_CSS,
body: aboutBodyHtml(escapeAttr(appBase), escapeAttr(assetBase), version),
scripts: [UPDATE_JS, ABOUT_JS],
})
}
function coloringPage(appBase: string, assetBase: string, siteUrl: string, version: AppVersion) {
return documentHtml({
appBase,
assetBase,
page: 'coloring',
siteUrl,
path: '/coloring',
title: 'Coloring pages of Jesus Christ',
runningSha: version.sha,
viewport: 'width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover',
css: COLORING_CSS,
body: coloringBodyHtml(escapeAttr(appBase)),
scripts: [UPDATE_JS, COLORING_JS],
})
}
function manifest(appBase: string, assetBase: string) {
const icon = (file: string, size: string, purpose: string) => ({
src: `${assetBase}/icons/${file}`,
sizes: size,
type: 'image/png',
purpose,
})
return JSON.stringify({
id: `${appBase}/`,
name: 'Paintings of Jesus Christ',
short_name: 'Paintings',
description: DESCRIPTION,
start_url: `${appBase}/`,
scope: `${appBase}/`,
display: 'fullscreen',
display_override: ['fullscreen', 'standalone'],
orientation: 'any',
background_color: '#0b0b0e',
theme_color: '#0b0b0e',
categories: ['education', 'lifestyle'],
icons: [
icon('icon-192.png', '192x192', 'any'),
icon('icon-512.png', '512x512', 'any'),
icon('maskable-192.png', '192x192', 'maskable'),
icon('maskable-512.png', '512x512', 'maskable'),
],
})
}
function serviceWorker(appBase: string, assetBase: string) {
// Follows the package-app-kit starter worker (starter-remix/public/sw.js): HTML pages are
// cache first with a background refresh, the manifest and icons are network first,
// /api/* is never cached, Kody gate pages (403, redirects, or x-kody-handoff) are never
// cached, and SKIP_WAITING / REFRESH_SHELL messages power the update toast's Refresh
// button. Images (paintings and coloring pages) are cache first in their own cache.
// After activation, and whenever a page sends WARM_ALL, the worker downloads every
// image it does not have yet, one at a time, and reports progress to open pages with
// WARM_PROGRESS messages, so the whole app works offline after the first visit.
const pages = ['/', '/about', '/coloring']
const shell = [
...pages.map((page) => `${appBase}${page}`),
`${appBase}/manifest.webmanifest`,
`${appBase}/icon.svg`,
...ICON_FILES.map((file) => `${assetBase}/icons/${file}`),
]
return `'use strict'
const CACHE = 'christ-paintings-v${VERSION}'
const IMAGES = 'christ-paintings-images'
const BASE = ${JSON.stringify(appBase)}
const IMAGE_PATH = ${JSON.stringify(`${appBase}/img/`)}
const ALL_IMAGES = ${JSON.stringify(allImagePaths().map((path) => `${appBase}${path}`))}
const PAGES = ${JSON.stringify(pages)}
const SHELL = ${JSON.stringify(shell)}
const isHandoff = (res) => res && (res.status === 403 || String(res.headers.get('x-kody-handoff') || '').toLowerCase() === 'required')
const isImage = (url) => /\\.(png|svg|ico|webp|jpg|jpeg)$/.test(url.pathname)
const good = (url, res) => {
if (!res || !res.ok || res.redirected || res.type !== 'basic' || isHandoff(res)) return false
if (isImage(url)) return String(res.headers.get('content-type') || '').startsWith('image/')
return true
}
const rel = (url) => {
const path = url.pathname
if (path === BASE || path === BASE + '/') return '/'
return path.startsWith(BASE + '/') ? path.slice(BASE.length).replace(/\\/$/, '') || '/' : path
}
const pageKey = (url) => BASE + rel(url)
const isPage = (url) => PAGES.includes(rel(url))
const isPicture = (url) => url.pathname.startsWith(IMAGE_PATH)
function put(cache, key, url, res) {
if (good(url, res)) return cache.put(key, res.clone())
}
function fetchInto(cache, path) {
const url = new URL(path, location.origin)
return fetch(path, { credentials: 'same-origin', cache: 'no-store' })
.then((res) => put(cache, isPage(url) ? pageKey(url) : path, url, res))
.catch(() => null)
}
async function broadcast(message) {
const list = await self.clients.matchAll({ includeUncontrolled: true, type: 'window' })
for (const client of list) client.postMessage(message)
}
async function countCached(cache) {
let done = 0
for (const path of ALL_IMAGES) if (await cache.match(path)) done++
return done
}
let warming = null
function warm() {
if (warming) return warming
const wanted = new Set(ALL_IMAGES)
warming = caches.open(IMAGES).then(async (cache) => {
const keys = await cache.keys()
await Promise.all(keys.filter((req) => !wanted.has(new URL(req.url).pathname)).map((req) => cache.delete(req)))
let done = await countCached(cache)
let failed = false
await broadcast({ type: 'WARM_PROGRESS', done, total: ALL_IMAGES.length, running: true })
for (const path of ALL_IMAGES) {
if (await cache.match(path)) continue
try {
const res = await fetch(path, { credentials: 'same-origin' })
if (good(new URL(path, location.origin), res)) {
await cache.put(path, res)
done++
await broadcast({ type: 'WARM_PROGRESS', done, total: ALL_IMAGES.length, running: true })
} else if (isHandoff(res)) {
failed = true
break
}
} catch (e) {
failed = true
break
}
}
await broadcast({ type: 'WARM_PROGRESS', done, total: ALL_IMAGES.length, running: false, failed })
}).finally(() => { warming = null })
return warming
}
self.addEventListener('install', (e) => {
e.waitUntil(
caches.open(CACHE)
.then((cache) => Promise.all(SHELL.map((path) => fetchInto(cache, path))))
.then(() => self.skipWaiting()),
)
})
self.addEventListener('activate', (e) => {
e.waitUntil(
caches.keys()
.then((keys) => Promise.all(keys.filter((k) => k.startsWith('christ-paintings-v') && k !== CACHE).map((k) => caches.delete(k))))
.then(() => self.clients.claim())
.then(() => { warm() }),
)
})
self.addEventListener('message', (e) => {
if (e.data === 'SKIP_WAITING') return self.skipWaiting()
if (e.data && (e.data.type === 'WARM_ALL' || e.data.type === 'WARM_PAINTINGS')) {
e.waitUntil(warm())
return
}
if (e.data && e.data.type === 'WARM_STATUS') {
e.waitUntil(caches.open(IMAGES).then(countCached).then((done) => broadcast({ type: 'WARM_PROGRESS', done, total: ALL_IMAGES.length, running: Boolean(warming) })))
return
}
if (e.data && e.data.type === 'REFRESH_SHELL') {
const reply = () => e.ports && e.ports[0] && e.ports[0].postMessage({ type: 'REFRESHED' })
e.waitUntil(
caches.open(CACHE)
.then((cache) => Promise.all(PAGES.map((page) => fetchInto(cache, BASE + page))))
.then(reply, reply),
)
}
})
self.addEventListener('fetch', (e) => {
const req = e.request
if (req.method !== 'GET') return
const url = new URL(req.url)
if (url.origin !== location.origin || !url.pathname.startsWith(BASE)) return
if (url.pathname.startsWith(BASE + '/api/')) return
// Let Kody's session handoff (query string) reach the network untouched.
if (req.mode === 'navigate' && url.search) return
if (isPicture(url)) {
e.respondWith(
caches.open(IMAGES).then((cache) =>
cache.match(url.pathname).then((hit) => {
if (hit) return hit
return fetch(req)
.then((res) => {
if (good(url, res)) cache.put(url.pathname, res.clone())
return res
})
.catch(() => Response.error())
}),
),
)
return
}
if (req.mode === 'navigate' || isPage(url)) {
const key = pageKey(url)
e.respondWith(
caches.open(CACHE).then((cache) =>
cache.match(key).then((hit) => {
const network = fetch(req)
.then((res) => {
put(cache, key, url, res)
return res
})
.catch(() => null)
if (hit) {
e.waitUntil(network)
return hit
}
return network.then((res) => res || Response.error())
}),
),
)
return
}
// Manifest, icons, everything else: network first, cached copy when offline or gated.
e.respondWith(
caches.open(CACHE).then((cache) =>
fetch(req, { credentials: 'same-origin', cache: url.pathname.endsWith('.webmanifest') ? 'no-store' : 'default' })
.then((res) => {
if (good(url, res)) {
cache.put(url.pathname, res.clone())
return res
}
return cache.match(url.pathname).then((hit) => hit || res)
})
.catch(() => cache.match(url.pathname).then((hit) => hit || Response.error())),
),
)
})
`
}
/**
* Paintings of Jesus Christ: a phone-first viewer for official artwork from The Church of
* Jesus Christ of Latter-day Saints, plus a coloring section. Serves the viewer, /coloring,
* the About page, /api/version, the web app manifest, an SVG icon, an offline service
* worker, and the Church's image files at /img/p/{id}.jpg and /img/c/{id}.jpg (see
* images.ts). PNG icons are static assets under packageContext.assetBasePath.
*/
export default {
async fetch(request: Request) {
const url = new URL(request.url)
const { appBase, assetBase } = paths()
const path = url.pathname.replace(/\/+$/, '') || '/'
if (request.method !== 'GET' && request.method !== 'HEAD') {
return new Response('Method not allowed', { status: 405, headers: { allow: 'GET, HEAD' } })
}
if (path === '/') {
return new Response(viewerPage(appBase, assetBase, publicOrigin(request, url) + appBase, await readRecordedVersion()), {
headers: { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-cache' },
})
}
if (path === '/about') {
return new Response(aboutPage(appBase, assetBase, publicOrigin(request, url) + appBase, await readRecordedVersion()), {
headers: { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-cache' },
})
}
if (path === '/coloring') {
return new Response(coloringPage(appBase, assetBase, publicOrigin(request, url) + appBase, await readRecordedVersion()), {
headers: { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-cache' },
})
}
if (path.startsWith('/img/')) {
const source = imageSourceFor(path)
if (!source) return new Response('Not found', { status: 404, headers: { 'content-type': 'text/plain' } })
return serveImage(request, source)
}
if (path === '/api/version') {
return Response.json(await readRecordedVersion(), { headers: { 'cache-control': 'no-store' } })
}
if (path === '/manifest.webmanifest') {
return new Response(manifest(appBase, assetBase), {
headers: { 'content-type': 'application/manifest+json; charset=utf-8', 'cache-control': 'no-store' },
})
}
if (path === '/og.jpg') {
const bytes = Uint8Array.from(atob(OG_IMAGE_JPEG), (c) => c.charCodeAt(0))
return new Response(request.method === 'HEAD' ? null : bytes, {
headers: { 'content-type': 'image/jpeg', 'cache-control': 'public, max-age=86400' },
})
}
if (path === '/icon.svg') {
return new Response(ICON_SVG, {
headers: { 'content-type': 'image/svg+xml', 'cache-control': 'public, max-age=86400' },
})
}
if (path === '/sw.js') {
return new Response(serviceWorker(appBase, assetBase), {
headers: {
'content-type': 'text/javascript; charset=utf-8',
'cache-control': 'no-cache',
'service-worker-allowed': `${appBase}/`,
},
})
}
return new Response('Not found', { status: 404, headers: { 'content-type': 'text/plain' } })
},
}