Skip to content
← 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.

FollowForkOpen app

src/app.ts

440 lines · 17.7 KB · TypeScript
Raw
import { 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, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;')
}

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' } })
	},
}