Skip to content

Kody is live

Watch the launch video — what Kody is, and why it exists.

← 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('&', '&amp;')
		.replaceAll('"', '&quot;')
		.replaceAll("'", '&#39;')
		.replaceAll('<', '&lt;')
		.replaceAll('>', '&gt;')
}

function escapeHtml(value: string) {
	return value
		.replaceAll('&', '&amp;')
		.replaceAll('<', '&lt;')
		.replaceAll('>', '&gt;')
		.replaceAll('"', '&quot;')
}

/**
 * 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("'", '&#39;')}'`,
		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