@ultimat3/pwa 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/icons.ts ADDED
@@ -0,0 +1,187 @@
1
+ /**
2
+ * Icons and splash screens from ONE source image. Nobody hand-maintains fourteen PNGs;
3
+ * they maintain three and forget the maskable safe zone, which is why installed Android
4
+ * icons end up with clipped logos.
5
+ */
6
+
7
+ import { transformImageBytes } from '@ultimat3/core';
8
+ import { PwaIconMissingError } from './errors';
9
+ import type { ManifestIcon } from './manifest';
10
+
11
+ export type IconPurpose = 'any' | 'maskable' | 'monochrome' | 'apple-touch';
12
+
13
+ export interface IconSpec {
14
+ readonly size: number;
15
+ readonly purpose: IconPurpose;
16
+ readonly filename: string;
17
+ /** Fraction of the edge reserved as padding so the safe zone survives masking. */
18
+ readonly padding: number;
19
+ }
20
+
21
+ /** Maskable icons are cropped to a circle of ~80% of the edge; 10% padding per side. */
22
+ export const MASKABLE_PADDING = 0.1;
23
+
24
+ export const ICON_MATRIX: readonly IconSpec[] = Object.freeze([
25
+ { size: 48, purpose: 'any', filename: 'icon-48.png', padding: 0 },
26
+ { size: 72, purpose: 'any', filename: 'icon-72.png', padding: 0 },
27
+ { size: 96, purpose: 'any', filename: 'icon-96.png', padding: 0 },
28
+ { size: 128, purpose: 'any', filename: 'icon-128.png', padding: 0 },
29
+ { size: 192, purpose: 'any', filename: 'icon-192.png', padding: 0 },
30
+ { size: 256, purpose: 'any', filename: 'icon-256.png', padding: 0 },
31
+ { size: 384, purpose: 'any', filename: 'icon-384.png', padding: 0 },
32
+ { size: 512, purpose: 'any', filename: 'icon-512.png', padding: 0 },
33
+ { size: 192, purpose: 'maskable', filename: 'icon-maskable-192.png', padding: MASKABLE_PADDING },
34
+ { size: 512, purpose: 'maskable', filename: 'icon-maskable-512.png', padding: MASKABLE_PADDING },
35
+ { size: 512, purpose: 'monochrome', filename: 'icon-mono-512.png', padding: MASKABLE_PADDING },
36
+ { size: 180, purpose: 'apple-touch', filename: 'apple-touch-icon.png', padding: 0 },
37
+ { size: 167, purpose: 'apple-touch', filename: 'apple-touch-icon-167.png', padding: 0 },
38
+ { size: 152, purpose: 'apple-touch', filename: 'apple-touch-icon-152.png', padding: 0 },
39
+ ]);
40
+
41
+ export interface SplashSpec {
42
+ readonly width: number;
43
+ readonly height: number;
44
+ readonly ratio: number;
45
+ readonly orientation: 'portrait' | 'landscape';
46
+ readonly filename: string;
47
+ }
48
+
49
+ /** iOS still needs explicit splash images; every other platform derives them. */
50
+ export const SPLASH_MATRIX: readonly SplashSpec[] = Object.freeze([
51
+ { width: 1290, height: 2796, ratio: 3, orientation: 'portrait', filename: 'splash-1290.png' },
52
+ { width: 1179, height: 2556, ratio: 3, orientation: 'portrait', filename: 'splash-1179.png' },
53
+ { width: 1170, height: 2532, ratio: 3, orientation: 'portrait', filename: 'splash-1170.png' },
54
+ { width: 1125, height: 2436, ratio: 3, orientation: 'portrait', filename: 'splash-1125.png' },
55
+ { width: 828, height: 1792, ratio: 2, orientation: 'portrait', filename: 'splash-828.png' },
56
+ { width: 1536, height: 2048, ratio: 2, orientation: 'portrait', filename: 'splash-1536.png' },
57
+ { width: 2048, height: 2732, ratio: 2, orientation: 'portrait', filename: 'splash-2048.png' },
58
+ ]);
59
+
60
+ export interface SafeZone {
61
+ /** Pixels of padding on each edge. */
62
+ readonly padding: number;
63
+ /** Edge length of the region the artwork may occupy. */
64
+ readonly inner: number;
65
+ }
66
+
67
+ export function maskableSafeZone(size: number, padding = MASKABLE_PADDING): SafeZone {
68
+ const pad = Math.round(size * padding);
69
+ return { padding: pad, inner: size - pad * 2 };
70
+ }
71
+
72
+ export interface ImageTransform {
73
+ readonly size: number;
74
+ readonly padding: number;
75
+ /** `'#rgb' | '#rgba' | '#rrggbb' | '#rrggbbaa' | 'transparent'`. There are no named colours. */
76
+ readonly background?: string;
77
+ }
78
+
79
+ /** The transform driver: source bytes in, one square PNG of `transform.size` out. */
80
+ export interface ImagePipeline {
81
+ resize(source: Uint8Array, transform: ImageTransform): Promise<Uint8Array>;
82
+ }
83
+
84
+ /**
85
+ * The one driver, backed by core's zero-dependency pipeline — no `sharp`, no image CDN.
86
+ * Always square PNG: `toManifestIcon` declares `type: 'image/png'`, so any other format
87
+ * would make the manifest lie about bytes the browser then refuses.
88
+ *
89
+ * `async` on purpose. A `Promise`-typed method that throws synchronously skips every
90
+ * `.catch()` a caller wrote; an undecodable source or a named colour must reject instead.
91
+ */
92
+ export class BuiltinImagePipeline implements ImagePipeline {
93
+ async resize(source: Uint8Array, transform: ImageTransform): Promise<Uint8Array> {
94
+ return transformImageBytes(source, {
95
+ width: transform.size,
96
+ height: transform.size,
97
+ fit: 'contain',
98
+ padding: transform.padding,
99
+ background: transform.background,
100
+ format: 'png',
101
+ });
102
+ }
103
+ }
104
+
105
+ export interface IconSourceConfig {
106
+ /** Path to the ONE source icon, 1024x1024 or larger, square. */
107
+ readonly sourceIcon?: string;
108
+ readonly outDir?: string;
109
+ readonly background?: string;
110
+ }
111
+
112
+ export interface IconPlanEntry {
113
+ readonly spec: IconSpec;
114
+ readonly outputPath: string;
115
+ readonly transform: ImageTransform;
116
+ readonly manifestIcon: ManifestIcon | null;
117
+ }
118
+
119
+ export interface IconPlan {
120
+ readonly source: string;
121
+ readonly entries: readonly IconPlanEntry[];
122
+ readonly splashes: readonly SplashSpec[];
123
+ readonly manifestIcons: readonly ManifestIcon[];
124
+ }
125
+
126
+ /** `x doctor` calls this: a missing source icon must report a fix, not a stack trace. */
127
+ export function requireSourceIcon(config: IconSourceConfig): string {
128
+ const source = config.sourceIcon;
129
+ if (source === undefined || source.trim() === '') {
130
+ throw new PwaIconMissingError(
131
+ 'no source icon: pwa.sourceIcon is unset, so no manifest icon can be generated and ' +
132
+ 'the app is not installable',
133
+ "add a 1024x1024 square PNG at assets/icon.png and set pwa.sourceIcon: 'assets/icon.png'",
134
+ );
135
+ }
136
+ return source;
137
+ }
138
+
139
+ export function planIcons(config: IconSourceConfig): IconPlan {
140
+ const source = requireSourceIcon(config);
141
+ const outDir = (config.outDir ?? '/icons').replace(/\/$/, '');
142
+
143
+ const entries = ICON_MATRIX.map((spec) => {
144
+ const outputPath = `${outDir}/${spec.filename}`;
145
+ const transform: ImageTransform = {
146
+ size: spec.size,
147
+ padding: spec.padding,
148
+ ...(config.background === undefined ? {} : { background: config.background }),
149
+ };
150
+ return {
151
+ spec,
152
+ outputPath,
153
+ transform,
154
+ manifestIcon: toManifestIcon(spec, outputPath),
155
+ };
156
+ });
157
+
158
+ return {
159
+ source,
160
+ entries,
161
+ splashes: SPLASH_MATRIX,
162
+ manifestIcons: entries
163
+ .map((entry) => entry.manifestIcon)
164
+ .filter((icon): icon is ManifestIcon => icon !== null),
165
+ };
166
+ }
167
+
168
+ /** Apple touch icons are `<link rel="apple-touch-icon">`, not manifest members. */
169
+ function toManifestIcon(spec: IconSpec, outputPath: string): ManifestIcon | null {
170
+ if (spec.purpose === 'apple-touch') return null;
171
+ return {
172
+ src: outputPath,
173
+ sizes: `${spec.size}x${spec.size}`,
174
+ type: 'image/png',
175
+ purpose: spec.purpose,
176
+ };
177
+ }
178
+
179
+ export function appleTouchLinks(plan: IconPlan): string {
180
+ return plan.entries
181
+ .filter((entry) => entry.spec.purpose === 'apple-touch')
182
+ .map(
183
+ (entry) =>
184
+ `<link rel="apple-touch-icon" sizes="${entry.spec.size}x${entry.spec.size}" href="${entry.outputPath}">`,
185
+ )
186
+ .join('');
187
+ }
package/src/index.ts ADDED
@@ -0,0 +1,155 @@
1
+ /** Public API of `@ultimat3/pwa`. You never open `sw.js`; you call these. */
2
+
3
+ export type { BackgroundSyncOptions, RetryPolicy } from './background-sync';
4
+ export {
5
+ backgroundSyncSource,
6
+ DEFAULT_FLUSH_ENDPOINT,
7
+ DEFAULT_RETRY,
8
+ PERIODIC_SYNC_TAG,
9
+ registerBackgroundSyncSource,
10
+ retryDelayMs,
11
+ SYNC_TAG,
12
+ shouldRetry,
13
+ } from './background-sync';
14
+ export type { Capability, CapabilityFlags, ResolvedCapabilities } from './capabilities';
15
+ export {
16
+ CAPABILITIES,
17
+ CAPABILITY_MANIFEST_KEYS,
18
+ CAPABILITY_SW_MARKERS,
19
+ enabledCapabilities,
20
+ isEnabled,
21
+ resolveCapabilities,
22
+ } from './capabilities';
23
+ export type { PwaErrorCode } from './errors';
24
+ export {
25
+ BuildIdMissingError,
26
+ NotImplementedError,
27
+ PWA_ERROR_CODES,
28
+ PWA_ERROR_TITLES,
29
+ PwaIconMissingError,
30
+ PwaManifestInvalidError,
31
+ PwaNoOfflineFallbackError,
32
+ SwScopeInvalidError,
33
+ } from './errors';
34
+ export type {
35
+ IconPlan,
36
+ IconPlanEntry,
37
+ IconPurpose,
38
+ IconSourceConfig,
39
+ IconSpec,
40
+ ImagePipeline,
41
+ ImageTransform,
42
+ SafeZone,
43
+ SplashSpec,
44
+ } from './icons';
45
+ export {
46
+ appleTouchLinks,
47
+ BuiltinImagePipeline,
48
+ ICON_MATRIX,
49
+ MASKABLE_PADDING,
50
+ maskableSafeZone,
51
+ planIcons,
52
+ requireSourceIcon,
53
+ SPLASH_MATRIX,
54
+ } from './icons';
55
+ export type {
56
+ BeforeInstallPromptEventLike,
57
+ InstallController,
58
+ InstallHost,
59
+ InstallOptions,
60
+ InstallOutcome,
61
+ IosGuidance,
62
+ ReadSignal,
63
+ } from './install';
64
+ export { createInstallController, iosInstallGuidance, MIN_ENGAGEMENT_MS } from './install';
65
+ export type {
66
+ DisplayMode,
67
+ FileHandler,
68
+ ManifestIcon,
69
+ ManifestScreenshot,
70
+ ManifestShortcut,
71
+ Orientation,
72
+ ProtocolHandler,
73
+ PwaConfig,
74
+ SchemeColors,
75
+ ShareTarget,
76
+ ThemeColorMeta,
77
+ ThemeTokens,
78
+ WebManifest,
79
+ WebManifestResult,
80
+ } from './manifest';
81
+ export { generateWebManifest, renderThemeColorMeta, serializeWebManifest } from './manifest';
82
+ export type { OfflineConfig, OfflineFallback } from './offline-fallback';
83
+ export { offlineFallbackSource, requireOfflineFallback } from './offline-fallback';
84
+ export type { PrecacheAsset, PrecacheEntry, PrecacheInput, PrecacheManifest } from './precache';
85
+ export {
86
+ buildPrecacheManifest,
87
+ DEFAULT_PRECACHE_WARN_BYTES,
88
+ formatBytes,
89
+ serializePrecacheManifest,
90
+ } from './precache';
91
+ export type {
92
+ PushPayload,
93
+ PushSourceOptions,
94
+ PushSubscriptionKeys,
95
+ PushSubscriptionRecord,
96
+ RenderedNotification,
97
+ SubscriptionState,
98
+ Translate,
99
+ VapidConfig,
100
+ } from './push';
101
+ export {
102
+ pushSource,
103
+ renderPushPayload,
104
+ serializePushMessage,
105
+ subscribeSource,
106
+ subscriptionState,
107
+ } from './push';
108
+ export type { RouteRule, ServiceWorkerConfig, ServiceWorkerOutput } from './service-worker';
109
+ export { assertScope, generateServiceWorker, routeRules } from './service-worker';
110
+ export type {
111
+ PwaOfflineStrategy,
112
+ PwaRenderMode,
113
+ PwaRoute,
114
+ StrategyCache,
115
+ StrategyEnv,
116
+ StrategyName,
117
+ StrategyOptions,
118
+ } from './strategies';
119
+ export {
120
+ cacheFirst,
121
+ MODE_STRATEGY,
122
+ networkFirst,
123
+ networkOnly,
124
+ STRATEGY_FN_NAMES,
125
+ STRATEGY_FNS,
126
+ STRATEGY_NAMES,
127
+ STRATEGY_SOURCE,
128
+ staleWhileRevalidate,
129
+ strategyFor,
130
+ } from './strategies';
131
+ export type {
132
+ AppUpdateAvailable,
133
+ BuildIdInput,
134
+ Deploy,
135
+ DeployChannel,
136
+ ForceReason,
137
+ RetentionPlan,
138
+ SkewState,
139
+ UpdatePolicy,
140
+ UpdatePolicyInput,
141
+ UpdateSignalInput,
142
+ } from './version-skew';
143
+ export {
144
+ APP_UPDATE_AVAILABLE,
145
+ assertBuildId,
146
+ BUILD_ID_HEADER,
147
+ BUILD_ID_META,
148
+ buildId,
149
+ cacheNamespace,
150
+ DEFAULT_GRACE_MS,
151
+ detectSkew,
152
+ retentionPlan,
153
+ updatePolicy,
154
+ updateSignal,
155
+ } from './version-skew';
package/src/install.ts ADDED
@@ -0,0 +1,137 @@
1
+ /**
2
+ * Install prompt handling. Two rules: capture `beforeinstallprompt` so the browser's own
3
+ * bar is suppressed and the app owns the moment, and NEVER prompt on first paint — an
4
+ * install prompt before the user knows what the app is converts worse and is dismissed
5
+ * permanently on some platforms.
6
+ */
7
+
8
+ /** A minimal signal so this file needs no framework runtime. */
9
+ export interface ReadSignal<T> {
10
+ (): T;
11
+ subscribe(listener: (value: T) => void): () => void;
12
+ }
13
+
14
+ interface WritableSignal<T> extends ReadSignal<T> {
15
+ set(value: T): void;
16
+ }
17
+
18
+ function createSignal<T>(initial: T): WritableSignal<T> {
19
+ let value = initial;
20
+ const listeners = new Set<(value: T) => void>();
21
+ const read = (() => value) as WritableSignal<T>;
22
+ read.set = (next: T): void => {
23
+ if (Object.is(next, value)) return;
24
+ value = next;
25
+ for (const listener of listeners) listener(next);
26
+ };
27
+ read.subscribe = (listener) => {
28
+ listeners.add(listener);
29
+ return () => listeners.delete(listener);
30
+ };
31
+ return read;
32
+ }
33
+
34
+ export type InstallOutcome = 'accepted' | 'dismissed' | 'unavailable' | 'too-early';
35
+
36
+ export interface BeforeInstallPromptEventLike {
37
+ preventDefault(): void;
38
+ prompt(): Promise<void>;
39
+ readonly userChoice: Promise<{ readonly outcome: 'accepted' | 'dismissed' }>;
40
+ }
41
+
42
+ /** Structural view of the host, so this runs under `bun test` with no DOM. */
43
+ export interface InstallHost {
44
+ addEventListener(type: string, handler: (event: unknown) => void): void;
45
+ removeEventListener(type: string, handler: (event: unknown) => void): void;
46
+ /** `navigator.userAgent`. */
47
+ readonly userAgent: string;
48
+ /** True when already running as an installed app. */
49
+ readonly standalone: boolean;
50
+ readonly now: () => number;
51
+ }
52
+
53
+ export interface InstallOptions {
54
+ readonly host: InstallHost;
55
+ /** How long the user must have been on the page before a prompt is allowed. */
56
+ readonly minEngagementMs?: number;
57
+ }
58
+
59
+ /** Long enough that the prompt follows intent, short enough to still be useful. */
60
+ export const MIN_ENGAGEMENT_MS = 30_000;
61
+
62
+ export interface IosGuidance {
63
+ readonly platform: 'ios';
64
+ /** i18n key — never a literal string. */
65
+ readonly instructionKey: string;
66
+ readonly stepKeys: readonly string[];
67
+ }
68
+
69
+ export interface InstallController {
70
+ readonly canInstall: ReadSignal<boolean>;
71
+ readonly installed: ReadSignal<boolean>;
72
+ /** iOS has no `beforeinstallprompt`; it needs a guided flow instead. */
73
+ readonly iosGuidance: IosGuidance | null;
74
+ prompt(): Promise<InstallOutcome>;
75
+ dispose(): void;
76
+ }
77
+
78
+ export function createInstallController(options: InstallOptions): InstallController {
79
+ const host = options.host;
80
+ const minEngagementMs = options.minEngagementMs ?? MIN_ENGAGEMENT_MS;
81
+ const startedAt = host.now();
82
+
83
+ const canInstall = createSignal(false);
84
+ const installed = createSignal(host.standalone);
85
+ let deferred: BeforeInstallPromptEventLike | null = null;
86
+
87
+ const onBeforeInstallPrompt = (event: unknown): void => {
88
+ const candidate = event as BeforeInstallPromptEventLike;
89
+ if (typeof candidate?.prompt !== 'function') return;
90
+ // Suppress the browser bar; the app decides when to ask.
91
+ candidate.preventDefault();
92
+ deferred = candidate;
93
+ canInstall.set(true);
94
+ };
95
+
96
+ const onInstalled = (): void => {
97
+ deferred = null;
98
+ canInstall.set(false);
99
+ installed.set(true);
100
+ };
101
+
102
+ host.addEventListener('beforeinstallprompt', onBeforeInstallPrompt);
103
+ host.addEventListener('appinstalled', onInstalled);
104
+
105
+ return {
106
+ canInstall,
107
+ installed,
108
+ iosGuidance: iosInstallGuidance(host.userAgent, host.standalone),
109
+
110
+ async prompt(): Promise<InstallOutcome> {
111
+ if (deferred === null) return 'unavailable';
112
+ if (host.now() - startedAt < minEngagementMs) return 'too-early';
113
+ await deferred.prompt();
114
+ const choice = await deferred.userChoice;
115
+ deferred = null;
116
+ canInstall.set(false);
117
+ return choice.outcome;
118
+ },
119
+
120
+ dispose(): void {
121
+ host.removeEventListener('beforeinstallprompt', onBeforeInstallPrompt);
122
+ host.removeEventListener('appinstalled', onInstalled);
123
+ },
124
+ };
125
+ }
126
+
127
+ const IOS_UA = /iphone|ipad|ipod/i;
128
+
129
+ /** Safari never fires `beforeinstallprompt`; the only install path is Share → Add. */
130
+ export function iosInstallGuidance(userAgent: string, standalone: boolean): IosGuidance | null {
131
+ if (standalone || !IOS_UA.test(userAgent)) return null;
132
+ return {
133
+ platform: 'ios',
134
+ instructionKey: 'pwa.install.ios.instruction',
135
+ stepKeys: ['pwa.install.ios.step.share', 'pwa.install.ios.step.addToHome'],
136
+ };
137
+ }
@@ -0,0 +1,216 @@
1
+ /**
2
+ * The web app manifest, generated from `app.config.ts`. Colours come from the design
3
+ * tokens for BOTH schemes: the manifest spec carries one `theme_color`, so the dark value
4
+ * is emitted as a media-scoped `<meta name="theme-color">` — otherwise an installed dark
5
+ * app gets a light status bar on every launch.
6
+ */
7
+
8
+ import type { CapabilityFlags, ResolvedCapabilities } from './capabilities';
9
+ import { isEnabled, resolveCapabilities } from './capabilities';
10
+ import { PwaManifestInvalidError } from './errors';
11
+
12
+ export type DisplayMode = 'standalone' | 'fullscreen' | 'minimal-ui' | 'browser';
13
+ export type Orientation = 'any' | 'natural' | 'portrait' | 'landscape';
14
+
15
+ export interface ManifestIcon {
16
+ readonly src: string;
17
+ readonly sizes: string;
18
+ readonly type: string;
19
+ readonly purpose?: 'any' | 'maskable' | 'monochrome';
20
+ }
21
+
22
+ export interface ManifestShortcut {
23
+ readonly name: string;
24
+ readonly short_name?: string;
25
+ readonly description?: string;
26
+ readonly url: string;
27
+ readonly icons?: readonly ManifestIcon[];
28
+ }
29
+
30
+ export interface ManifestScreenshot {
31
+ readonly src: string;
32
+ readonly sizes: string;
33
+ readonly type: string;
34
+ readonly form_factor?: 'narrow' | 'wide';
35
+ readonly label?: string;
36
+ }
37
+
38
+ export interface ShareTarget {
39
+ readonly action: string;
40
+ readonly method: 'GET' | 'POST';
41
+ readonly enctype?: string;
42
+ readonly params: {
43
+ readonly title?: string;
44
+ readonly text?: string;
45
+ readonly url?: string;
46
+ readonly files?: readonly { readonly name: string; readonly accept: readonly string[] }[];
47
+ };
48
+ }
49
+
50
+ export interface FileHandler {
51
+ readonly action: string;
52
+ readonly accept: Readonly<Record<string, readonly string[]>>;
53
+ }
54
+
55
+ export interface ProtocolHandler {
56
+ readonly protocol: string;
57
+ readonly url: string;
58
+ }
59
+
60
+ export interface WebManifest {
61
+ readonly name: string;
62
+ readonly short_name: string;
63
+ readonly description?: string;
64
+ readonly start_url: string;
65
+ readonly scope: string;
66
+ readonly id?: string;
67
+ readonly display: DisplayMode;
68
+ readonly display_override?: readonly DisplayMode[];
69
+ readonly orientation: Orientation;
70
+ readonly lang: string;
71
+ readonly dir: 'ltr' | 'rtl' | 'auto';
72
+ readonly theme_color: string;
73
+ readonly background_color: string;
74
+ readonly categories?: readonly string[];
75
+ readonly icons: readonly ManifestIcon[];
76
+ readonly shortcuts?: readonly ManifestShortcut[];
77
+ readonly screenshots?: readonly ManifestScreenshot[];
78
+ readonly share_target?: ShareTarget;
79
+ readonly file_handlers?: readonly FileHandler[];
80
+ readonly protocol_handlers?: readonly ProtocolHandler[];
81
+ }
82
+
83
+ /** Resolved token values for one colour scheme. Never a hex literal in framework code. */
84
+ export interface SchemeColors {
85
+ readonly themeColor: string;
86
+ readonly backgroundColor: string;
87
+ }
88
+
89
+ export interface ThemeTokens {
90
+ readonly light: SchemeColors;
91
+ readonly dark: SchemeColors;
92
+ }
93
+
94
+ /** The `pwa` block of `app.config.ts`. */
95
+ export interface PwaConfig {
96
+ readonly name: string;
97
+ readonly shortName?: string;
98
+ readonly description?: string;
99
+ readonly startUrl?: string;
100
+ readonly scope?: string;
101
+ readonly id?: string;
102
+ readonly display?: DisplayMode;
103
+ readonly orientation?: Orientation;
104
+ readonly lang?: string;
105
+ readonly dir?: 'ltr' | 'rtl' | 'auto';
106
+ readonly tokens: ThemeTokens;
107
+ readonly categories?: readonly string[];
108
+ readonly icons?: readonly ManifestIcon[];
109
+ readonly shortcuts?: readonly ManifestShortcut[];
110
+ readonly screenshots?: readonly ManifestScreenshot[];
111
+ readonly shareTarget?: ShareTarget;
112
+ readonly fileHandlers?: readonly FileHandler[];
113
+ readonly protocolHandlers?: readonly ProtocolHandler[];
114
+ readonly capabilities?: CapabilityFlags;
115
+ }
116
+
117
+ export interface ThemeColorMeta {
118
+ readonly content: string;
119
+ readonly media: string;
120
+ }
121
+
122
+ export interface WebManifestResult {
123
+ readonly manifest: WebManifest;
124
+ /** Emit both into `<head>`; the manifest can only carry one. */
125
+ readonly themeColorMeta: readonly ThemeColorMeta[];
126
+ readonly capabilities: ResolvedCapabilities;
127
+ }
128
+
129
+ export function generateWebManifest(config: PwaConfig): WebManifestResult {
130
+ assertValid(config);
131
+ const capabilities = resolveCapabilities(config.capabilities);
132
+ const scope = config.scope ?? '/';
133
+
134
+ const optional: MutableManifest = {};
135
+ if (config.description !== undefined) optional.description = config.description;
136
+ if (config.id !== undefined) optional.id = config.id;
137
+ if (config.categories !== undefined) optional.categories = config.categories;
138
+ if (config.shortcuts !== undefined) optional.shortcuts = config.shortcuts;
139
+ if (config.screenshots !== undefined) optional.screenshots = config.screenshots;
140
+
141
+ // A disabled capability contributes no manifest member at all — not an empty one.
142
+ if (isEnabled(capabilities, 'shareTarget') && config.shareTarget !== undefined) {
143
+ optional.share_target = config.shareTarget;
144
+ }
145
+ if (isEnabled(capabilities, 'fileHandlers') && config.fileHandlers !== undefined) {
146
+ optional.file_handlers = config.fileHandlers;
147
+ }
148
+ if (isEnabled(capabilities, 'protocolHandlers') && config.protocolHandlers !== undefined) {
149
+ optional.protocol_handlers = config.protocolHandlers;
150
+ }
151
+
152
+ const manifest: WebManifest = {
153
+ name: config.name,
154
+ short_name: config.shortName ?? config.name.slice(0, 12),
155
+ start_url: config.startUrl ?? scope,
156
+ scope,
157
+ display: config.display ?? 'standalone',
158
+ display_override: ['standalone', 'minimal-ui'],
159
+ orientation: config.orientation ?? 'any',
160
+ lang: config.lang ?? 'en',
161
+ dir: config.dir ?? 'ltr',
162
+ theme_color: config.tokens.light.themeColor,
163
+ background_color: config.tokens.light.backgroundColor,
164
+ icons: config.icons ?? [],
165
+ ...optional,
166
+ };
167
+
168
+ return {
169
+ manifest,
170
+ themeColorMeta: [
171
+ { content: config.tokens.light.themeColor, media: '(prefers-color-scheme: light)' },
172
+ { content: config.tokens.dark.themeColor, media: '(prefers-color-scheme: dark)' },
173
+ ],
174
+ capabilities,
175
+ };
176
+ }
177
+
178
+ type MutableManifest = { -readonly [K in keyof WebManifest]?: WebManifest[K] };
179
+
180
+ function assertValid(config: PwaConfig): void {
181
+ if (config.name.trim() === '') {
182
+ throw new PwaManifestInvalidError(
183
+ 'pwa.name is empty, so the install prompt would have no title',
184
+ 'set pwa.name in app.config.ts',
185
+ );
186
+ }
187
+ const scope = config.scope ?? '/';
188
+ const startUrl = config.startUrl ?? scope;
189
+ if (!startUrl.startsWith(scope)) {
190
+ throw new PwaManifestInvalidError(
191
+ `pwa.startUrl ${JSON.stringify(startUrl)} is outside pwa.scope ${JSON.stringify(scope)}, ` +
192
+ 'so the installed app would open out of scope',
193
+ `set pwa.startUrl to a path under ${scope} in app.config.ts`,
194
+ );
195
+ }
196
+ for (const scheme of ['light', 'dark'] as const) {
197
+ const colors = config.tokens[scheme];
198
+ if (colors.themeColor.trim() === '' || colors.backgroundColor.trim() === '') {
199
+ throw new PwaManifestInvalidError(
200
+ `pwa.tokens.${scheme} is missing themeColor or backgroundColor`,
201
+ `resolve the ${scheme} theme tokens in app.config.ts — both schemes are required`,
202
+ );
203
+ }
204
+ }
205
+ }
206
+
207
+ /** Deterministic serialization for the emitted `manifest.webmanifest`. */
208
+ export function serializeWebManifest(manifest: WebManifest): string {
209
+ return `${JSON.stringify(manifest, null, 2)}\n`;
210
+ }
211
+
212
+ export function renderThemeColorMeta(metas: readonly ThemeColorMeta[]): string {
213
+ return metas
214
+ .map((meta) => `<meta name="theme-color" content="${meta.content}" media="${meta.media}">`)
215
+ .join('');
216
+ }