← Public packages
@kentcdodds/package-app-kit
Design tokens, PWA install/update, About/version, cache helpers, and optional realtime notes sync for Kody package apps.
src/app-shell.ts
160 lines · 5.3 KB · TypeScript/**
* Minimal HTML document shell for fetch-handler package apps (non-Remix).
* Mirrors the attribute contract used by starter-fetch / swipe / Remix Document.
*/
export type PackageContextLike = {
appBasePath?: string | null
assetBasePath?: string | null
clientModuleUrl?: string | null
hostedUrl?: string | null
}
export type AppShellHtmlInput = {
title: string
packageContext: PackageContextLike
/** Element id for the mount node (default `root`). */
rootId?: string
/** Extra markup inside `<head>` (after kit metas/links). */
head?: string
/** Body class attribute. */
bodyClass?: string
/** Inner HTML for `<body>` before the client module (default: empty `#root`). */
body?: string
/** Theme-color meta (default teal accent). */
themeColor?: string
/** Apple web-app title (defaults to `title`). */
appleTitle?: string
/** When true, link stylesheet at `{assetBasePath}/styles.css` (default true when assetBasePath set). */
stylesheet?: boolean | string
/** Manifest path under appBase (default `/manifest.webmanifest`). */
manifestPath?: string
/**
* JSON config object placed on `html[data-pak-config]` (and as JSON text when
* useful for clients that read `data-pak-config`). Merge with appBase / assetBase /
* clientModuleUrl from packageContext.
*/
pakConfig?: Record<string, unknown>
/** Extra `html` attributes (string of `attr="value"` pairs, already escaped). */
htmlAttrs?: string
lang?: string
}
function escapeAttr(value: string) {
return value
.replaceAll('&', '&')
.replaceAll('"', '"')
.replaceAll("'", ''')
.replaceAll('<', '<')
.replaceAll('>', '>')
}
function escapeHtml(value: string) {
return value
.replaceAll('&', '&')
.replaceAll('<', '<')
.replaceAll('>', '>')
.replaceAll('"', '"')
}
/**
* Full HTML document for a fetch-handler PWA shell.
*
* Sets `data-app-base`, `data-asset-base`, `data-client-module`, and `data-pak-config`
* on `<html>` so kit / app clients can resolve mounts without hard-coding paths.
*
* @example
* import { appShellHtml } from 'kody:@kentcdodds/package-app-kit/app-shell'
* import { packageContext } from 'kody:runtime'
* return new Response(
* appShellHtml({
* title: 'Swipe',
* packageContext,
* pakConfig: { appName: 'Swipe', routes: ['/'] },
* }),
* { headers: { 'content-type': 'text/html; charset=utf-8' } },
* )
*/
export function appShellHtml(input: AppShellHtmlInput): string {
const ctx = input.packageContext || {}
const appBasePath = String(ctx.appBasePath ?? '').replace(/\/$/, '')
const assetBasePath = String(ctx.assetBasePath ?? '').replace(/\/$/, '')
const clientModuleUrl = String(ctx.clientModuleUrl ?? '')
const hostedUrl = String(ctx.hostedUrl ?? '')
const rootId = input.rootId || 'root'
const themeColor = input.themeColor || '#0a0a0a'
const appleTitle = input.appleTitle || input.title
const manifestPath = input.manifestPath || '/manifest.webmanifest'
const manifestHref = `${appBasePath}${manifestPath.startsWith('/') ? manifestPath : `/${manifestPath}`}`
let stylesheetHref: string | null = null
if (input.stylesheet === false) {
stylesheetHref = null
} else if (typeof input.stylesheet === 'string') {
stylesheetHref = input.stylesheet
} else if (assetBasePath) {
stylesheetHref = `${assetBasePath}/styles.css`
}
const pakConfig = {
appBase: appBasePath,
...(assetBasePath ? { assetBase: assetBasePath } : {}),
...(clientModuleUrl ? { clientModuleUrl } : {}),
...(hostedUrl ? { hostedUrl } : {}),
...(input.pakConfig || {}),
}
const pakConfigJson = JSON.stringify(pakConfig)
const htmlAttrParts = [
`lang="${escapeAttr(input.lang || 'en')}"`,
`data-app-base="${escapeAttr(appBasePath)}"`,
assetBasePath ? `data-asset-base="${escapeAttr(assetBasePath)}"` : '',
clientModuleUrl ? `data-client-module="${escapeAttr(clientModuleUrl)}"` : '',
`data-pak-config='${pakConfigJson.replaceAll("'", ''')}'`,
input.htmlAttrs || '',
].filter(Boolean)
const headBits = [
'<meta charset="utf-8" />',
'<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />',
`<meta name="theme-color" content="${escapeAttr(themeColor)}" />`,
'<meta name="color-scheme" content="light dark" />',
'<meta name="apple-mobile-web-app-capable" content="yes" />',
'<meta name="mobile-web-app-capable" content="yes" />',
'<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />',
`<meta name="apple-mobile-web-app-title" content="${escapeAttr(appleTitle)}" />`,
`<title>${escapeHtml(input.title)}</title>`,
`<link rel="manifest" href="${escapeAttr(manifestHref)}" crossorigin="use-credentials" />`,
stylesheetHref
? `<link rel="stylesheet" href="${escapeAttr(stylesheetHref)}" />`
: '',
input.head || '',
].filter(Boolean)
const bodyInner =
input.body != null
? input.body
: `<div id="${escapeAttr(rootId)}"></div>`
const bodyClass = input.bodyClass
? ` class="${escapeAttr(input.bodyClass)}"`
: ''
const moduleScript = clientModuleUrl
? `<script type="module" src="${escapeAttr(clientModuleUrl)}"></script>`
: ''
return [
'<!doctype html>',
`<html ${htmlAttrParts.join(' ')}>`,
'<head>',
...headBits,
'</head>',
`<body${bodyClass}>`,
bodyInner,
moduleScript,
'</body>',
'</html>',
].join('\n')
}
/** Primary callable export for this subpath. */
export default appShellHtml