@djangocfg/nextjs 2.1.606 → 2.1.608

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.
@@ -1,9 +1,11 @@
1
1
  // src/pwa/manifest.ts
2
2
  function createViewport(config) {
3
3
  return {
4
+ ...config.viewportFit ? { viewportFit: config.viewportFit } : {},
5
+ ...config.appLike ? { userScalable: false } : {},
4
6
  width: "device-width",
5
7
  initialScale: 1,
6
- maximumScale: 1,
8
+ ...config.appLike ? { maximumScale: 1 } : {},
7
9
  themeColor: config.themeColor || "#000000"
8
10
  };
9
11
  }
@@ -12,7 +14,7 @@ function createManifestMetadata(config) {
12
14
  manifest: "/manifest.json",
13
15
  appleWebApp: {
14
16
  capable: true,
15
- statusBarStyle: "default",
17
+ statusBarStyle: config.statusBarStyle ?? "default",
16
18
  title: config.shortName || config.name
17
19
  },
18
20
  applicationName: config.name,
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/pwa/manifest.ts","../../src/pwa/plugin.ts"],"sourcesContent":["/**\n * PWA Manifest Metadata Utilities\n *\n * Helper functions for creating Next.js metadata for PWA manifest\n */\n\nimport type { Metadata, MetadataRoute, Viewport } from 'next';\n\nexport interface ManifestConfig {\n name: string;\n shortName?: string;\n description?: string;\n themeColor?: string;\n backgroundColor?: string;\n display?: 'standalone' | 'fullscreen' | 'minimal-ui' | 'browser';\n orientation?: 'portrait' | 'landscape' | 'any';\n startUrl?: string;\n scope?: string;\n lang?: string;\n dir?: 'ltr' | 'rtl' | 'auto';\n icons?: {\n src: string;\n sizes: string;\n type?: string;\n purpose?: string;\n }[];\n}\n\n/**\n * Icon paths configuration\n */\nexport interface IconPaths {\n logo192?: string;\n logo384?: string;\n logo512?: string;\n}\n\n/**\n * Protocol handler configuration\n * Allows your PWA to register as a handler for custom protocols\n *\n * @example\n * ```typescript\n * {\n * protocol: \"web+music\",\n * url: \"/play?track=%s\"\n * }\n * ```\n */\nexport interface ProtocolHandler {\n /** Protocol scheme (e.g., \"web+music\", \"mailto\", \"magnet\") */\n protocol: string;\n /** URL template with %s placeholder for the protocol parameter */\n url: string;\n}\n\n/**\n * Create viewport configuration for Next.js app\n *\n * @example\n * ```typescript\n * export const viewport: Viewport = createViewport({\n * themeColor: '#ffffff',\n * });\n * ```\n */\nexport function createViewport(config: { themeColor?: string }): Viewport {\n return {\n width: 'device-width',\n initialScale: 1,\n maximumScale: 1,\n themeColor: config.themeColor || '#000000',\n };\n}\n\n/**\n * Create manifest metadata for Next.js app\n *\n * Note: themeColor and viewport should be exported separately using createViewport()\n *\n * @example\n * ```typescript\n * export const metadata: Metadata = {\n * ...createManifestMetadata({\n * name: 'My App',\n * shortName: 'App',\n * description: 'My awesome app',\n * }),\n * };\n *\n * export const viewport: Viewport = createViewport({\n * themeColor: '#ffffff',\n * });\n * ```\n */\nexport function createManifestMetadata(config: ManifestConfig): Metadata {\n return {\n manifest: '/manifest.json',\n appleWebApp: {\n capable: true,\n statusBarStyle: 'default',\n title: config.shortName || config.name,\n },\n applicationName: config.name,\n formatDetection: {\n telephone: false,\n },\n };\n}\n\n/**\n * Create Next.js manifest function\n *\n * Use this in your app/manifest.ts file\n *\n * @example\n * ```typescript\n * // app/manifest.ts\n * import { createManifest } from '@djangocfg/nextjs/config';\n * import { settings } from '@core/settings';\n *\n * export default createManifest({\n * name: settings.app.name,\n * description: settings.app.description,\n * icons: {\n * logo192: settings.app.icons.logo192,\n * logo384: settings.app.icons.logo384,\n * logo512: settings.app.icons.logo512,\n * },\n * });\n * ```\n */\nexport interface ScreenshotConfig {\n src: string;\n sizes: string;\n type?: string;\n form_factor?: 'narrow' | 'wide';\n label?: string;\n}\n\n/**\n * Smart screenshot configuration\n * Automatically detects everything from path or uses defaults\n */\nexport interface SmartScreenshotInput {\n src: string;\n /** Form factor (auto-detected from filename if contains 'desktop'/'mobile', or use default) */\n form_factor?: 'narrow' | 'wide';\n /** Optional label (auto-generated from form_factor) */\n label?: string;\n /** Optional width (defaults based on form_factor) */\n width?: number;\n /** Optional height (defaults based on form_factor) */\n height?: number;\n}\n\n/**\n * Create screenshot config with smart defaults\n * Automatically detects type, sizes, form_factor from path or uses sensible defaults\n *\n * @example\n * ```typescript\n * // Minimal - everything auto-detected\n * createScreenshot({ src: '/screenshots/desktop-view.png' })\n * // → form_factor: 'wide', sizes: '1920x1080', type: 'image/png', label: 'Desktop screenshot'\n *\n * createScreenshot({ src: '/screenshots/mobile.png' })\n * // → form_factor: 'narrow', sizes: '390x844', type: 'image/png', label: 'Mobile screenshot'\n *\n * // With custom dimensions\n * createScreenshot({ src: '/screenshots/tablet.png', width: 1024, height: 768 })\n * ```\n */\nexport function createScreenshot(input: SmartScreenshotInput | string): ScreenshotConfig {\n // Allow string shorthand\n const config = typeof input === 'string' ? { src: input } : input;\n const { src, label, form_factor } = config;\n let { width, height } = config;\n\n // Auto-detect image type from extension\n const ext = src.split('.').pop()?.toLowerCase();\n const typeMap: Record<string, string> = {\n png: 'image/png',\n jpg: 'image/jpeg',\n jpeg: 'image/jpeg',\n webp: 'image/webp',\n svg: 'image/svg+xml',\n };\n const type = ext ? typeMap[ext] || 'image/png' : 'image/png';\n\n // Try to parse dimensions from filename (e.g., \"1920x1080.png\" or \"screenshot-390x844.png\")\n const filename = src.toLowerCase();\n // Destructured: `\\d{3,4}` cannot match empty, so both groups exist whenever\n // the match does — but only binding them says so without an assertion.\n const [, matchedWidth, matchedHeight] = filename.match(/(\\d{3,4})x(\\d{3,4})/) ?? [];\n if (matchedWidth && matchedHeight && !width && !height) {\n width = parseInt(matchedWidth, 10);\n height = parseInt(matchedHeight, 10);\n }\n\n // Auto-detect form_factor from filename if not provided\n let autoFormFactor: 'narrow' | 'wide' = 'wide'; // Default to wide\n if (filename.includes('mobile') || filename.includes('phone') || filename.includes('narrow')) {\n autoFormFactor = 'narrow';\n } else if (filename.includes('desktop') || filename.includes('laptop') || filename.includes('wide')) {\n autoFormFactor = 'wide';\n } else if (width && height) {\n // Calculate from dimensions if provided or parsed\n const aspectRatio = width / height;\n autoFormFactor = aspectRatio > 1.2 ? 'wide' : 'narrow';\n }\n\n const finalFormFactor = form_factor || autoFormFactor;\n\n // Default dimensions based on form_factor (only if not parsed from filename)\n const defaultDimensions = finalFormFactor === 'wide'\n ? { width: 1920, height: 1080 } // Desktop default\n : { width: 390, height: 844 }; // Mobile default (iPhone 14)\n\n const finalWidth = width || defaultDimensions.width;\n const finalHeight = height || defaultDimensions.height;\n\n // Auto-generate label\n const autoLabel = finalFormFactor === 'wide'\n ? 'Desktop screenshot'\n : 'Mobile screenshot';\n\n return {\n src,\n sizes: `${finalWidth}x${finalHeight}`,\n type,\n form_factor: finalFormFactor,\n label: label || autoLabel,\n };\n}\n\n/**\n * Create multiple screenshots from array\n * Supports string shorthand or full config objects\n *\n * @example\n * ```typescript\n * // Minimal - just paths\n * createScreenshots([\n * '/screenshots/desktop.png', // Auto: wide, 1920x1080\n * '/screenshots/mobile.png', // Auto: narrow, 390x844\n * ])\n *\n * // Mixed\n * createScreenshots([\n * '/screenshots/desktop.png',\n * { src: '/screenshots/tablet.png', width: 1024, height: 768 },\n * ])\n * ```\n */\nexport function createScreenshots(inputs: Array<SmartScreenshotInput | string>): ScreenshotConfig[] {\n return inputs.map(createScreenshot);\n}\n\nexport function createManifest(config: {\n name: string;\n shortName?: string;\n description?: string;\n themeColor?: string;\n backgroundColor?: string;\n display?: 'standalone' | 'fullscreen' | 'minimal-ui' | 'browser';\n orientation?: 'portrait' | 'landscape' | 'any';\n id?: string;\n startUrl?: string;\n scope?: string;\n lang?: string;\n dir?: 'ltr' | 'rtl' | 'auto';\n icons?: IconPaths | ManifestConfig['icons'];\n screenshots?: ScreenshotConfig[];\n protocol_handlers?: ProtocolHandler[];\n}): () => MetadataRoute.Manifest {\n return () => {\n // Convert IconPaths to manifest icons format\n let manifestIcons: MetadataRoute.Manifest['icons'];\n\n if (Array.isArray(config.icons)) {\n // Already in manifest format\n manifestIcons = config.icons as MetadataRoute.Manifest['icons'];\n } else if (config.icons) {\n // Convert IconPaths to manifest icons\n const { logo192, logo384, logo512 } = config.icons as IconPaths;\n manifestIcons = [\n ...(logo192\n ? [\n {\n src: logo192,\n sizes: '192x192',\n type: 'image/png',\n purpose: 'maskable' as const,\n },\n ]\n : []),\n ...(logo384\n ? [\n {\n src: logo384,\n sizes: '384x384',\n type: 'image/png',\n },\n ]\n : []),\n ...(logo512\n ? [\n {\n src: logo512,\n sizes: '512x512',\n type: 'image/png',\n },\n ]\n : []),\n ];\n }\n\n const manifest: MetadataRoute.Manifest = {\n name: config.name,\n short_name: config.shortName || config.name,\n description: config.description || config.name,\n id: config.id || config.startUrl || '/',\n start_url: config.startUrl || '/',\n scope: config.scope || '/',\n display: config.display || 'standalone',\n orientation: config.orientation || 'portrait',\n background_color: config.backgroundColor || '#000000',\n theme_color: config.themeColor || '#ffffff',\n lang: config.lang || 'en',\n dir: config.dir || 'ltr',\n icons: manifestIcons,\n // Removed forced gcm_sender_id to avoid potential conflicts with VAPID\n // gcm_sender_id: '103953800507',\n };\n\n // Add screenshots if provided (for Richer PWA Install UI)\n if (config.screenshots && config.screenshots.length > 0) {\n manifest.screenshots = config.screenshots;\n }\n\n // Add protocol handlers if provided\n if (config.protocol_handlers && config.protocol_handlers.length > 0) {\n manifest.protocol_handlers = config.protocol_handlers;\n }\n\n return manifest;\n };\n}\n\n/**\n * Generate manifest.json content (legacy)\n *\n * @deprecated Use createManifest() instead\n */\nexport function generateManifest(config: ManifestConfig): MetadataRoute.Manifest {\n return createManifest(config)();\n}\n","/**\n * PWA (Progressive Web App) Plugin — dependency-free.\n *\n * History: this used to wrap `@serwist/next`. Serwist is a **webpack** plugin,\n * and Next.js 16 defaults to Turbopack, which does not run webpack loaders and\n * chokes on serwist's `fileURLToPath(new URL(\".\", import.meta.url))` during\n * static tracing — failing the whole build even when PWA was never enabled\n * (the mere `require('@serwist/next')` reference was enough). Since no app in\n * this monorepo actually turned PWA on (`withPWA` is opt-in and unused), we\n * dropped serwist entirely.\n *\n * What remains:\n * - `createManifest` (in `./manifest`) — the web app manifest, which Next.js\n * supports natively via `app/manifest.ts`; needs no library at all.\n * - `withPWA` — a thin, Turbopack-safe pass-through kept for API stability.\n * - `getServiceWorkerTemplate` — a vanilla service worker you can drop into\n * `app/sw.ts`, no framework runtime required.\n *\n * If you want a full precache/offline story back, wire a real service worker\n * (the template below, or Workbox) yourself — but do it in an app, not here, so\n * one app's PWA choice can't break every consumer's build.\n */\n\nimport type { NextConfig } from 'next';\n\nexport interface PWAPluginOptions {\n /**\n * Path to service worker source file (relative to project root).\n * @default 'app/sw.ts'\n */\n swSrc?: string;\n\n /**\n * Destination for the compiled service worker.\n * @default 'public/sw.js'\n */\n swDest?: string;\n\n /**\n * Disable PWA completely.\n * @default true in development / static export, false otherwise\n */\n disable?: boolean;\n\n /** Cache pages on navigation. Consumed by a custom SW, if any. @default true */\n cacheOnNavigation?: boolean;\n\n /** Reload the app when the device comes back online. @default true */\n reloadOnOnline?: boolean;\n}\n\n/**\n * Turbopack-safe PWA pass-through.\n *\n * This no longer injects a build plugin — Next.js's native manifest support\n * plus a hand-written `app/sw.ts` (see {@link getServiceWorkerTemplate}) cover\n * the PWA surface without a webpack-only dependency. Kept as a named export so\n * existing `withPWA(config)` / `createBaseNextConfig({ pwa })` call sites keep\n * type-checking and behave predictably (they return the config unchanged).\n *\n * @example\n * ```ts\n * import { createBaseNextConfig } from '@djangocfg/nextjs/config';\n * export default createBaseNextConfig({ pwa: { swSrc: 'app/sw.ts' } });\n * ```\n */\nexport function withPWA(\n nextConfig: NextConfig,\n _options: PWAPluginOptions = {}\n): NextConfig {\n return nextConfig;\n}\n\n/**\n * Vanilla service-worker template — no library.\n *\n * Returns ready-to-use, framework-free service worker source. Copy it into\n * `app/sw.ts` (or generate it) if you want offline support. It uses only the\n * standard Service Worker + Cache APIs, so it builds under Turbopack with no\n * extra dependency.\n *\n * @example\n * ```ts\n * import { getServiceWorkerTemplate } from '@djangocfg/nextjs/config';\n * console.log(getServiceWorkerTemplate()); // → paste into app/sw.ts\n * ```\n */\nexport function getServiceWorkerTemplate(): string {\n return `/**\n * Service Worker (vanilla) — standard Cache + Fetch APIs, no library.\n */\n\n/// <reference lib=\"webworker\" />\n// eslint-disable-next-line @typescript-eslint/no-explicit-any\ndeclare const self: any;\n\nconst CACHE = 'app-cache-v1';\nconst OFFLINE_URL = '/_offline';\n\n// Precache the offline fallback on install.\nself.addEventListener('install', (event: any) => {\n event.waitUntil(\n caches.open(CACHE).then((cache: Cache) => cache.addAll([OFFLINE_URL]))\n );\n self.skipWaiting();\n});\n\n// Drop stale caches on activate and take control immediately.\nself.addEventListener('activate', (event: any) => {\n event.waitUntil(\n caches.keys().then((keys: string[]) =>\n Promise.all(keys.filter((k) => k !== CACHE).map((k) => caches.delete(k)))\n )\n );\n self.clients.claim();\n});\n\n// Network-first for navigations, with an offline fallback.\nself.addEventListener('fetch', (event: any) => {\n const { request } = event;\n if (request.mode !== 'navigate') return;\n event.respondWith(\n fetch(request).catch(() =>\n caches.match(OFFLINE_URL).then((r: Response | undefined) => r ?? Response.error())\n )\n );\n});\n`;\n}\n"],"mappings":"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","names":[]}
1
+ {"version":3,"sources":["../../src/pwa/manifest.ts","../../src/pwa/plugin.ts"],"sourcesContent":["/**\n * PWA Manifest Metadata Utilities\n *\n * Helper functions for creating Next.js metadata for PWA manifest\n */\n\nimport type { Metadata, MetadataRoute, Viewport } from 'next';\n\nexport interface ManifestConfig {\n name: string;\n shortName?: string;\n description?: string;\n themeColor?: string;\n backgroundColor?: string;\n /** iOS status bar in the installed app. @default 'default' */\n statusBarStyle?: 'default' | 'black' | 'black-translucent';\n display?: 'standalone' | 'fullscreen' | 'minimal-ui' | 'browser';\n orientation?: 'portrait' | 'landscape' | 'any';\n startUrl?: string;\n scope?: string;\n lang?: string;\n dir?: 'ltr' | 'rtl' | 'auto';\n icons?: {\n src: string;\n sizes: string;\n type?: string;\n purpose?: string;\n }[];\n}\n\n/**\n * Icon paths configuration\n */\nexport interface IconPaths {\n logo192?: string;\n logo384?: string;\n logo512?: string;\n}\n\n/**\n * Protocol handler configuration\n * Allows your PWA to register as a handler for custom protocols\n *\n * @example\n * ```typescript\n * {\n * protocol: \"web+music\",\n * url: \"/play?track=%s\"\n * }\n * ```\n */\nexport interface ProtocolHandler {\n /** Protocol scheme (e.g., \"web+music\", \"mailto\", \"magnet\") */\n protocol: string;\n /** URL template with %s placeholder for the protocol parameter */\n url: string;\n}\n\n/**\n * Create viewport configuration for Next.js app\n *\n * @example\n * ```typescript\n * export const viewport: Viewport = createViewport({\n * themeColor: '#ffffff',\n * });\n * ```\n */\nexport function createViewport(config: {\n themeColor?: string;\n /** `'cover'` lets the page extend under the notch / status bar and makes `env(safe-area-inset-*)` non-zero (needed for PWAs). */\n viewportFit?: 'auto' | 'contain' | 'cover';\n /**\n * Also adds `maximumScale: 1` and `userScalable: false`. OFF by default on purpose: it blocks pinch-zoom for\n * people who need it (WCAG 1.4.4) and iOS Safari ignores `user-scalable=no` anyway. Double-tap zoom is already\n * off through `touch-action: manipulation` in the ui-core base styles; prefer that.\n */\n appLike?: boolean;\n}): Viewport {\n return {\n ...(config.viewportFit ? { viewportFit: config.viewportFit } : {}),\n ...(config.appLike ? { userScalable: false } : {}),\n width: 'device-width',\n initialScale: 1,\n ...(config.appLike ? { maximumScale: 1 } : {}),\n themeColor: config.themeColor || '#000000',\n };\n}\n\n/**\n * Create manifest metadata for Next.js app\n *\n * Note: themeColor and viewport should be exported separately using createViewport()\n *\n * @example\n * ```typescript\n * export const metadata: Metadata = {\n * ...createManifestMetadata({\n * name: 'My App',\n * shortName: 'App',\n * description: 'My awesome app',\n * }),\n * };\n *\n * export const viewport: Viewport = createViewport({\n * themeColor: '#ffffff',\n * });\n * ```\n */\nexport function createManifestMetadata(config: ManifestConfig): Metadata {\n return {\n manifest: '/manifest.json',\n appleWebApp: {\n capable: true,\n statusBarStyle: config.statusBarStyle ?? 'default',\n title: config.shortName || config.name,\n },\n applicationName: config.name,\n formatDetection: {\n telephone: false,\n },\n };\n}\n\n/**\n * Create Next.js manifest function\n *\n * Use this in your app/manifest.ts file\n *\n * @example\n * ```typescript\n * // app/manifest.ts\n * import { createManifest } from '@djangocfg/nextjs/config';\n * import { settings } from '@core/settings';\n *\n * export default createManifest({\n * name: settings.app.name,\n * description: settings.app.description,\n * icons: {\n * logo192: settings.app.icons.logo192,\n * logo384: settings.app.icons.logo384,\n * logo512: settings.app.icons.logo512,\n * },\n * });\n * ```\n */\nexport interface ScreenshotConfig {\n src: string;\n sizes: string;\n type?: string;\n form_factor?: 'narrow' | 'wide';\n label?: string;\n}\n\n/**\n * Smart screenshot configuration\n * Automatically detects everything from path or uses defaults\n */\nexport interface SmartScreenshotInput {\n src: string;\n /** Form factor (auto-detected from filename if contains 'desktop'/'mobile', or use default) */\n form_factor?: 'narrow' | 'wide';\n /** Optional label (auto-generated from form_factor) */\n label?: string;\n /** Optional width (defaults based on form_factor) */\n width?: number;\n /** Optional height (defaults based on form_factor) */\n height?: number;\n}\n\n/**\n * Create screenshot config with smart defaults\n * Automatically detects type, sizes, form_factor from path or uses sensible defaults\n *\n * @example\n * ```typescript\n * // Minimal - everything auto-detected\n * createScreenshot({ src: '/screenshots/desktop-view.png' })\n * // → form_factor: 'wide', sizes: '1920x1080', type: 'image/png', label: 'Desktop screenshot'\n *\n * createScreenshot({ src: '/screenshots/mobile.png' })\n * // → form_factor: 'narrow', sizes: '390x844', type: 'image/png', label: 'Mobile screenshot'\n *\n * // With custom dimensions\n * createScreenshot({ src: '/screenshots/tablet.png', width: 1024, height: 768 })\n * ```\n */\nexport function createScreenshot(input: SmartScreenshotInput | string): ScreenshotConfig {\n // Allow string shorthand\n const config = typeof input === 'string' ? { src: input } : input;\n const { src, label, form_factor } = config;\n let { width, height } = config;\n\n // Auto-detect image type from extension\n const ext = src.split('.').pop()?.toLowerCase();\n const typeMap: Record<string, string> = {\n png: 'image/png',\n jpg: 'image/jpeg',\n jpeg: 'image/jpeg',\n webp: 'image/webp',\n svg: 'image/svg+xml',\n };\n const type = ext ? typeMap[ext] || 'image/png' : 'image/png';\n\n // Try to parse dimensions from filename (e.g., \"1920x1080.png\" or \"screenshot-390x844.png\")\n const filename = src.toLowerCase();\n // Destructured: `\\d{3,4}` cannot match empty, so both groups exist whenever\n // the match does — but only binding them says so without an assertion.\n const [, matchedWidth, matchedHeight] = filename.match(/(\\d{3,4})x(\\d{3,4})/) ?? [];\n if (matchedWidth && matchedHeight && !width && !height) {\n width = parseInt(matchedWidth, 10);\n height = parseInt(matchedHeight, 10);\n }\n\n // Auto-detect form_factor from filename if not provided\n let autoFormFactor: 'narrow' | 'wide' = 'wide'; // Default to wide\n if (filename.includes('mobile') || filename.includes('phone') || filename.includes('narrow')) {\n autoFormFactor = 'narrow';\n } else if (filename.includes('desktop') || filename.includes('laptop') || filename.includes('wide')) {\n autoFormFactor = 'wide';\n } else if (width && height) {\n // Calculate from dimensions if provided or parsed\n const aspectRatio = width / height;\n autoFormFactor = aspectRatio > 1.2 ? 'wide' : 'narrow';\n }\n\n const finalFormFactor = form_factor || autoFormFactor;\n\n // Default dimensions based on form_factor (only if not parsed from filename)\n const defaultDimensions = finalFormFactor === 'wide'\n ? { width: 1920, height: 1080 } // Desktop default\n : { width: 390, height: 844 }; // Mobile default (iPhone 14)\n\n const finalWidth = width || defaultDimensions.width;\n const finalHeight = height || defaultDimensions.height;\n\n // Auto-generate label\n const autoLabel = finalFormFactor === 'wide'\n ? 'Desktop screenshot'\n : 'Mobile screenshot';\n\n return {\n src,\n sizes: `${finalWidth}x${finalHeight}`,\n type,\n form_factor: finalFormFactor,\n label: label || autoLabel,\n };\n}\n\n/**\n * Create multiple screenshots from array\n * Supports string shorthand or full config objects\n *\n * @example\n * ```typescript\n * // Minimal - just paths\n * createScreenshots([\n * '/screenshots/desktop.png', // Auto: wide, 1920x1080\n * '/screenshots/mobile.png', // Auto: narrow, 390x844\n * ])\n *\n * // Mixed\n * createScreenshots([\n * '/screenshots/desktop.png',\n * { src: '/screenshots/tablet.png', width: 1024, height: 768 },\n * ])\n * ```\n */\nexport function createScreenshots(inputs: Array<SmartScreenshotInput | string>): ScreenshotConfig[] {\n return inputs.map(createScreenshot);\n}\n\nexport function createManifest(config: {\n name: string;\n shortName?: string;\n description?: string;\n themeColor?: string;\n backgroundColor?: string;\n display?: 'standalone' | 'fullscreen' | 'minimal-ui' | 'browser';\n orientation?: 'portrait' | 'landscape' | 'any';\n id?: string;\n startUrl?: string;\n scope?: string;\n lang?: string;\n dir?: 'ltr' | 'rtl' | 'auto';\n icons?: IconPaths | ManifestConfig['icons'];\n screenshots?: ScreenshotConfig[];\n protocol_handlers?: ProtocolHandler[];\n}): () => MetadataRoute.Manifest {\n return () => {\n // Convert IconPaths to manifest icons format\n let manifestIcons: MetadataRoute.Manifest['icons'];\n\n if (Array.isArray(config.icons)) {\n // Already in manifest format\n manifestIcons = config.icons as MetadataRoute.Manifest['icons'];\n } else if (config.icons) {\n // Convert IconPaths to manifest icons\n const { logo192, logo384, logo512 } = config.icons as IconPaths;\n manifestIcons = [\n ...(logo192\n ? [\n {\n src: logo192,\n sizes: '192x192',\n type: 'image/png',\n purpose: 'maskable' as const,\n },\n ]\n : []),\n ...(logo384\n ? [\n {\n src: logo384,\n sizes: '384x384',\n type: 'image/png',\n },\n ]\n : []),\n ...(logo512\n ? [\n {\n src: logo512,\n sizes: '512x512',\n type: 'image/png',\n },\n ]\n : []),\n ];\n }\n\n const manifest: MetadataRoute.Manifest = {\n name: config.name,\n short_name: config.shortName || config.name,\n description: config.description || config.name,\n id: config.id || config.startUrl || '/',\n start_url: config.startUrl || '/',\n scope: config.scope || '/',\n display: config.display || 'standalone',\n orientation: config.orientation || 'portrait',\n background_color: config.backgroundColor || '#000000',\n theme_color: config.themeColor || '#ffffff',\n lang: config.lang || 'en',\n dir: config.dir || 'ltr',\n icons: manifestIcons,\n // Removed forced gcm_sender_id to avoid potential conflicts with VAPID\n // gcm_sender_id: '103953800507',\n };\n\n // Add screenshots if provided (for Richer PWA Install UI)\n if (config.screenshots && config.screenshots.length > 0) {\n manifest.screenshots = config.screenshots;\n }\n\n // Add protocol handlers if provided\n if (config.protocol_handlers && config.protocol_handlers.length > 0) {\n manifest.protocol_handlers = config.protocol_handlers;\n }\n\n return manifest;\n };\n}\n\n/**\n * Generate manifest.json content (legacy)\n *\n * @deprecated Use createManifest() instead\n */\nexport function generateManifest(config: ManifestConfig): MetadataRoute.Manifest {\n return createManifest(config)();\n}\n","/**\n * PWA (Progressive Web App) Plugin — dependency-free.\n *\n * History: this used to wrap `@serwist/next`. Serwist is a **webpack** plugin,\n * and Next.js 16 defaults to Turbopack, which does not run webpack loaders and\n * chokes on serwist's `fileURLToPath(new URL(\".\", import.meta.url))` during\n * static tracing — failing the whole build even when PWA was never enabled\n * (the mere `require('@serwist/next')` reference was enough). Since no app in\n * this monorepo actually turned PWA on (`withPWA` is opt-in and unused), we\n * dropped serwist entirely.\n *\n * What remains:\n * - `createManifest` (in `./manifest`) — the web app manifest, which Next.js\n * supports natively via `app/manifest.ts`; needs no library at all.\n * - `withPWA` — a thin, Turbopack-safe pass-through kept for API stability.\n * - `getServiceWorkerTemplate` — a vanilla service worker you can drop into\n * `app/sw.ts`, no framework runtime required.\n *\n * If you want a full precache/offline story back, wire a real service worker\n * (the template below, or Workbox) yourself — but do it in an app, not here, so\n * one app's PWA choice can't break every consumer's build.\n */\n\nimport type { NextConfig } from 'next';\n\nexport interface PWAPluginOptions {\n /**\n * Path to service worker source file (relative to project root).\n * @default 'app/sw.ts'\n */\n swSrc?: string;\n\n /**\n * Destination for the compiled service worker.\n * @default 'public/sw.js'\n */\n swDest?: string;\n\n /**\n * Disable PWA completely.\n * @default true in development / static export, false otherwise\n */\n disable?: boolean;\n\n /** Cache pages on navigation. Consumed by a custom SW, if any. @default true */\n cacheOnNavigation?: boolean;\n\n /** Reload the app when the device comes back online. @default true */\n reloadOnOnline?: boolean;\n}\n\n/**\n * Turbopack-safe PWA pass-through.\n *\n * This no longer injects a build plugin — Next.js's native manifest support\n * plus a hand-written `app/sw.ts` (see {@link getServiceWorkerTemplate}) cover\n * the PWA surface without a webpack-only dependency. Kept as a named export so\n * existing `withPWA(config)` / `createBaseNextConfig({ pwa })` call sites keep\n * type-checking and behave predictably (they return the config unchanged).\n *\n * @example\n * ```ts\n * import { createBaseNextConfig } from '@djangocfg/nextjs/config';\n * export default createBaseNextConfig({ pwa: { swSrc: 'app/sw.ts' } });\n * ```\n */\nexport function withPWA(\n nextConfig: NextConfig,\n _options: PWAPluginOptions = {}\n): NextConfig {\n return nextConfig;\n}\n\n/**\n * Vanilla service-worker template — no library.\n *\n * Returns ready-to-use, framework-free service worker source. Copy it into\n * `app/sw.ts` (or generate it) if you want offline support. It uses only the\n * standard Service Worker + Cache APIs, so it builds under Turbopack with no\n * extra dependency.\n *\n * @example\n * ```ts\n * import { getServiceWorkerTemplate } from '@djangocfg/nextjs/config';\n * console.log(getServiceWorkerTemplate()); // → paste into app/sw.ts\n * ```\n */\nexport function getServiceWorkerTemplate(): string {\n return `/**\n * Service Worker (vanilla) — standard Cache + Fetch APIs, no library.\n */\n\n/// <reference lib=\"webworker\" />\n// eslint-disable-next-line @typescript-eslint/no-explicit-any\ndeclare const self: any;\n\nconst CACHE = 'app-cache-v1';\nconst OFFLINE_URL = '/_offline';\n\n// Precache the offline fallback on install.\nself.addEventListener('install', (event: any) => {\n event.waitUntil(\n caches.open(CACHE).then((cache: Cache) => cache.addAll([OFFLINE_URL]))\n );\n self.skipWaiting();\n});\n\n// Drop stale caches on activate and take control immediately.\nself.addEventListener('activate', (event: any) => {\n event.waitUntil(\n caches.keys().then((keys: string[]) =>\n Promise.all(keys.filter((k) => k !== CACHE).map((k) => caches.delete(k)))\n )\n );\n self.clients.claim();\n});\n\n// Network-first for navigations, with an offline fallback.\nself.addEventListener('fetch', (event: any) => {\n const { request } = event;\n if (request.mode !== 'navigate') return;\n event.respondWith(\n fetch(request).catch(() =>\n caches.match(OFFLINE_URL).then((r: Response | undefined) => r ?? Response.error())\n )\n );\n});\n`;\n}\n"],"mappings":"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","names":[]}
@@ -12,6 +12,8 @@ interface ManifestConfig {
12
12
  description?: string;
13
13
  themeColor?: string;
14
14
  backgroundColor?: string;
15
+ /** iOS status bar in the installed app. @default 'default' */
16
+ statusBarStyle?: 'default' | 'black' | 'black-translucent';
15
17
  display?: 'standalone' | 'fullscreen' | 'minimal-ui' | 'browser';
16
18
  orientation?: 'portrait' | 'landscape' | 'any';
17
19
  startUrl?: string;
@@ -63,6 +65,14 @@ interface ProtocolHandler {
63
65
  */
64
66
  declare function createViewport(config: {
65
67
  themeColor?: string;
68
+ /** `'cover'` lets the page extend under the notch / status bar and makes `env(safe-area-inset-*)` non-zero (needed for PWAs). */
69
+ viewportFit?: 'auto' | 'contain' | 'cover';
70
+ /**
71
+ * Also adds `maximumScale: 1` and `userScalable: false`. OFF by default on purpose: it blocks pinch-zoom for
72
+ * people who need it (WCAG 1.4.4) and iOS Safari ignores `user-scalable=no` anyway. Double-tap zoom is already
73
+ * off through `touch-action: manipulation` in the ui-core base styles; prefer that.
74
+ */
75
+ appLike?: boolean;
66
76
  }): Viewport;
67
77
  /**
68
78
  * Create manifest metadata for Next.js app
@@ -1,9 +1,11 @@
1
1
  // src/pwa/manifest.ts
2
2
  function createViewport(config) {
3
3
  return {
4
+ ...config.viewportFit ? { viewportFit: config.viewportFit } : {},
5
+ ...config.appLike ? { userScalable: false } : {},
4
6
  width: "device-width",
5
7
  initialScale: 1,
6
- maximumScale: 1,
8
+ ...config.appLike ? { maximumScale: 1 } : {},
7
9
  themeColor: config.themeColor || "#000000"
8
10
  };
9
11
  }
@@ -12,7 +14,7 @@ function createManifestMetadata(config) {
12
14
  manifest: "/manifest.json",
13
15
  appleWebApp: {
14
16
  capable: true,
15
- statusBarStyle: "default",
17
+ statusBarStyle: config.statusBarStyle ?? "default",
16
18
  title: config.shortName || config.name
17
19
  },
18
20
  applicationName: config.name,
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/pwa/manifest.ts"],"sourcesContent":["/**\n * PWA Manifest Metadata Utilities\n *\n * Helper functions for creating Next.js metadata for PWA manifest\n */\n\nimport type { Metadata, MetadataRoute, Viewport } from 'next';\n\nexport interface ManifestConfig {\n name: string;\n shortName?: string;\n description?: string;\n themeColor?: string;\n backgroundColor?: string;\n display?: 'standalone' | 'fullscreen' | 'minimal-ui' | 'browser';\n orientation?: 'portrait' | 'landscape' | 'any';\n startUrl?: string;\n scope?: string;\n lang?: string;\n dir?: 'ltr' | 'rtl' | 'auto';\n icons?: {\n src: string;\n sizes: string;\n type?: string;\n purpose?: string;\n }[];\n}\n\n/**\n * Icon paths configuration\n */\nexport interface IconPaths {\n logo192?: string;\n logo384?: string;\n logo512?: string;\n}\n\n/**\n * Protocol handler configuration\n * Allows your PWA to register as a handler for custom protocols\n *\n * @example\n * ```typescript\n * {\n * protocol: \"web+music\",\n * url: \"/play?track=%s\"\n * }\n * ```\n */\nexport interface ProtocolHandler {\n /** Protocol scheme (e.g., \"web+music\", \"mailto\", \"magnet\") */\n protocol: string;\n /** URL template with %s placeholder for the protocol parameter */\n url: string;\n}\n\n/**\n * Create viewport configuration for Next.js app\n *\n * @example\n * ```typescript\n * export const viewport: Viewport = createViewport({\n * themeColor: '#ffffff',\n * });\n * ```\n */\nexport function createViewport(config: { themeColor?: string }): Viewport {\n return {\n width: 'device-width',\n initialScale: 1,\n maximumScale: 1,\n themeColor: config.themeColor || '#000000',\n };\n}\n\n/**\n * Create manifest metadata for Next.js app\n *\n * Note: themeColor and viewport should be exported separately using createViewport()\n *\n * @example\n * ```typescript\n * export const metadata: Metadata = {\n * ...createManifestMetadata({\n * name: 'My App',\n * shortName: 'App',\n * description: 'My awesome app',\n * }),\n * };\n *\n * export const viewport: Viewport = createViewport({\n * themeColor: '#ffffff',\n * });\n * ```\n */\nexport function createManifestMetadata(config: ManifestConfig): Metadata {\n return {\n manifest: '/manifest.json',\n appleWebApp: {\n capable: true,\n statusBarStyle: 'default',\n title: config.shortName || config.name,\n },\n applicationName: config.name,\n formatDetection: {\n telephone: false,\n },\n };\n}\n\n/**\n * Create Next.js manifest function\n *\n * Use this in your app/manifest.ts file\n *\n * @example\n * ```typescript\n * // app/manifest.ts\n * import { createManifest } from '@djangocfg/nextjs/config';\n * import { settings } from '@core/settings';\n *\n * export default createManifest({\n * name: settings.app.name,\n * description: settings.app.description,\n * icons: {\n * logo192: settings.app.icons.logo192,\n * logo384: settings.app.icons.logo384,\n * logo512: settings.app.icons.logo512,\n * },\n * });\n * ```\n */\nexport interface ScreenshotConfig {\n src: string;\n sizes: string;\n type?: string;\n form_factor?: 'narrow' | 'wide';\n label?: string;\n}\n\n/**\n * Smart screenshot configuration\n * Automatically detects everything from path or uses defaults\n */\nexport interface SmartScreenshotInput {\n src: string;\n /** Form factor (auto-detected from filename if contains 'desktop'/'mobile', or use default) */\n form_factor?: 'narrow' | 'wide';\n /** Optional label (auto-generated from form_factor) */\n label?: string;\n /** Optional width (defaults based on form_factor) */\n width?: number;\n /** Optional height (defaults based on form_factor) */\n height?: number;\n}\n\n/**\n * Create screenshot config with smart defaults\n * Automatically detects type, sizes, form_factor from path or uses sensible defaults\n *\n * @example\n * ```typescript\n * // Minimal - everything auto-detected\n * createScreenshot({ src: '/screenshots/desktop-view.png' })\n * // → form_factor: 'wide', sizes: '1920x1080', type: 'image/png', label: 'Desktop screenshot'\n *\n * createScreenshot({ src: '/screenshots/mobile.png' })\n * // → form_factor: 'narrow', sizes: '390x844', type: 'image/png', label: 'Mobile screenshot'\n *\n * // With custom dimensions\n * createScreenshot({ src: '/screenshots/tablet.png', width: 1024, height: 768 })\n * ```\n */\nexport function createScreenshot(input: SmartScreenshotInput | string): ScreenshotConfig {\n // Allow string shorthand\n const config = typeof input === 'string' ? { src: input } : input;\n const { src, label, form_factor } = config;\n let { width, height } = config;\n\n // Auto-detect image type from extension\n const ext = src.split('.').pop()?.toLowerCase();\n const typeMap: Record<string, string> = {\n png: 'image/png',\n jpg: 'image/jpeg',\n jpeg: 'image/jpeg',\n webp: 'image/webp',\n svg: 'image/svg+xml',\n };\n const type = ext ? typeMap[ext] || 'image/png' : 'image/png';\n\n // Try to parse dimensions from filename (e.g., \"1920x1080.png\" or \"screenshot-390x844.png\")\n const filename = src.toLowerCase();\n // Destructured: `\\d{3,4}` cannot match empty, so both groups exist whenever\n // the match does — but only binding them says so without an assertion.\n const [, matchedWidth, matchedHeight] = filename.match(/(\\d{3,4})x(\\d{3,4})/) ?? [];\n if (matchedWidth && matchedHeight && !width && !height) {\n width = parseInt(matchedWidth, 10);\n height = parseInt(matchedHeight, 10);\n }\n\n // Auto-detect form_factor from filename if not provided\n let autoFormFactor: 'narrow' | 'wide' = 'wide'; // Default to wide\n if (filename.includes('mobile') || filename.includes('phone') || filename.includes('narrow')) {\n autoFormFactor = 'narrow';\n } else if (filename.includes('desktop') || filename.includes('laptop') || filename.includes('wide')) {\n autoFormFactor = 'wide';\n } else if (width && height) {\n // Calculate from dimensions if provided or parsed\n const aspectRatio = width / height;\n autoFormFactor = aspectRatio > 1.2 ? 'wide' : 'narrow';\n }\n\n const finalFormFactor = form_factor || autoFormFactor;\n\n // Default dimensions based on form_factor (only if not parsed from filename)\n const defaultDimensions = finalFormFactor === 'wide'\n ? { width: 1920, height: 1080 } // Desktop default\n : { width: 390, height: 844 }; // Mobile default (iPhone 14)\n\n const finalWidth = width || defaultDimensions.width;\n const finalHeight = height || defaultDimensions.height;\n\n // Auto-generate label\n const autoLabel = finalFormFactor === 'wide'\n ? 'Desktop screenshot'\n : 'Mobile screenshot';\n\n return {\n src,\n sizes: `${finalWidth}x${finalHeight}`,\n type,\n form_factor: finalFormFactor,\n label: label || autoLabel,\n };\n}\n\n/**\n * Create multiple screenshots from array\n * Supports string shorthand or full config objects\n *\n * @example\n * ```typescript\n * // Minimal - just paths\n * createScreenshots([\n * '/screenshots/desktop.png', // Auto: wide, 1920x1080\n * '/screenshots/mobile.png', // Auto: narrow, 390x844\n * ])\n *\n * // Mixed\n * createScreenshots([\n * '/screenshots/desktop.png',\n * { src: '/screenshots/tablet.png', width: 1024, height: 768 },\n * ])\n * ```\n */\nexport function createScreenshots(inputs: Array<SmartScreenshotInput | string>): ScreenshotConfig[] {\n return inputs.map(createScreenshot);\n}\n\nexport function createManifest(config: {\n name: string;\n shortName?: string;\n description?: string;\n themeColor?: string;\n backgroundColor?: string;\n display?: 'standalone' | 'fullscreen' | 'minimal-ui' | 'browser';\n orientation?: 'portrait' | 'landscape' | 'any';\n id?: string;\n startUrl?: string;\n scope?: string;\n lang?: string;\n dir?: 'ltr' | 'rtl' | 'auto';\n icons?: IconPaths | ManifestConfig['icons'];\n screenshots?: ScreenshotConfig[];\n protocol_handlers?: ProtocolHandler[];\n}): () => MetadataRoute.Manifest {\n return () => {\n // Convert IconPaths to manifest icons format\n let manifestIcons: MetadataRoute.Manifest['icons'];\n\n if (Array.isArray(config.icons)) {\n // Already in manifest format\n manifestIcons = config.icons as MetadataRoute.Manifest['icons'];\n } else if (config.icons) {\n // Convert IconPaths to manifest icons\n const { logo192, logo384, logo512 } = config.icons as IconPaths;\n manifestIcons = [\n ...(logo192\n ? [\n {\n src: logo192,\n sizes: '192x192',\n type: 'image/png',\n purpose: 'maskable' as const,\n },\n ]\n : []),\n ...(logo384\n ? [\n {\n src: logo384,\n sizes: '384x384',\n type: 'image/png',\n },\n ]\n : []),\n ...(logo512\n ? [\n {\n src: logo512,\n sizes: '512x512',\n type: 'image/png',\n },\n ]\n : []),\n ];\n }\n\n const manifest: MetadataRoute.Manifest = {\n name: config.name,\n short_name: config.shortName || config.name,\n description: config.description || config.name,\n id: config.id || config.startUrl || '/',\n start_url: config.startUrl || '/',\n scope: config.scope || '/',\n display: config.display || 'standalone',\n orientation: config.orientation || 'portrait',\n background_color: config.backgroundColor || '#000000',\n theme_color: config.themeColor || '#ffffff',\n lang: config.lang || 'en',\n dir: config.dir || 'ltr',\n icons: manifestIcons,\n // Removed forced gcm_sender_id to avoid potential conflicts with VAPID\n // gcm_sender_id: '103953800507',\n };\n\n // Add screenshots if provided (for Richer PWA Install UI)\n if (config.screenshots && config.screenshots.length > 0) {\n manifest.screenshots = config.screenshots;\n }\n\n // Add protocol handlers if provided\n if (config.protocol_handlers && config.protocol_handlers.length > 0) {\n manifest.protocol_handlers = config.protocol_handlers;\n }\n\n return manifest;\n };\n}\n\n/**\n * Generate manifest.json content (legacy)\n *\n * @deprecated Use createManifest() instead\n */\nexport function generateManifest(config: ManifestConfig): MetadataRoute.Manifest {\n return createManifest(config)();\n}\n"],"mappings":"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","names":[]}
1
+ {"version":3,"sources":["../../src/pwa/manifest.ts"],"sourcesContent":["/**\n * PWA Manifest Metadata Utilities\n *\n * Helper functions for creating Next.js metadata for PWA manifest\n */\n\nimport type { Metadata, MetadataRoute, Viewport } from 'next';\n\nexport interface ManifestConfig {\n name: string;\n shortName?: string;\n description?: string;\n themeColor?: string;\n backgroundColor?: string;\n /** iOS status bar in the installed app. @default 'default' */\n statusBarStyle?: 'default' | 'black' | 'black-translucent';\n display?: 'standalone' | 'fullscreen' | 'minimal-ui' | 'browser';\n orientation?: 'portrait' | 'landscape' | 'any';\n startUrl?: string;\n scope?: string;\n lang?: string;\n dir?: 'ltr' | 'rtl' | 'auto';\n icons?: {\n src: string;\n sizes: string;\n type?: string;\n purpose?: string;\n }[];\n}\n\n/**\n * Icon paths configuration\n */\nexport interface IconPaths {\n logo192?: string;\n logo384?: string;\n logo512?: string;\n}\n\n/**\n * Protocol handler configuration\n * Allows your PWA to register as a handler for custom protocols\n *\n * @example\n * ```typescript\n * {\n * protocol: \"web+music\",\n * url: \"/play?track=%s\"\n * }\n * ```\n */\nexport interface ProtocolHandler {\n /** Protocol scheme (e.g., \"web+music\", \"mailto\", \"magnet\") */\n protocol: string;\n /** URL template with %s placeholder for the protocol parameter */\n url: string;\n}\n\n/**\n * Create viewport configuration for Next.js app\n *\n * @example\n * ```typescript\n * export const viewport: Viewport = createViewport({\n * themeColor: '#ffffff',\n * });\n * ```\n */\nexport function createViewport(config: {\n themeColor?: string;\n /** `'cover'` lets the page extend under the notch / status bar and makes `env(safe-area-inset-*)` non-zero (needed for PWAs). */\n viewportFit?: 'auto' | 'contain' | 'cover';\n /**\n * Also adds `maximumScale: 1` and `userScalable: false`. OFF by default on purpose: it blocks pinch-zoom for\n * people who need it (WCAG 1.4.4) and iOS Safari ignores `user-scalable=no` anyway. Double-tap zoom is already\n * off through `touch-action: manipulation` in the ui-core base styles; prefer that.\n */\n appLike?: boolean;\n}): Viewport {\n return {\n ...(config.viewportFit ? { viewportFit: config.viewportFit } : {}),\n ...(config.appLike ? { userScalable: false } : {}),\n width: 'device-width',\n initialScale: 1,\n ...(config.appLike ? { maximumScale: 1 } : {}),\n themeColor: config.themeColor || '#000000',\n };\n}\n\n/**\n * Create manifest metadata for Next.js app\n *\n * Note: themeColor and viewport should be exported separately using createViewport()\n *\n * @example\n * ```typescript\n * export const metadata: Metadata = {\n * ...createManifestMetadata({\n * name: 'My App',\n * shortName: 'App',\n * description: 'My awesome app',\n * }),\n * };\n *\n * export const viewport: Viewport = createViewport({\n * themeColor: '#ffffff',\n * });\n * ```\n */\nexport function createManifestMetadata(config: ManifestConfig): Metadata {\n return {\n manifest: '/manifest.json',\n appleWebApp: {\n capable: true,\n statusBarStyle: config.statusBarStyle ?? 'default',\n title: config.shortName || config.name,\n },\n applicationName: config.name,\n formatDetection: {\n telephone: false,\n },\n };\n}\n\n/**\n * Create Next.js manifest function\n *\n * Use this in your app/manifest.ts file\n *\n * @example\n * ```typescript\n * // app/manifest.ts\n * import { createManifest } from '@djangocfg/nextjs/config';\n * import { settings } from '@core/settings';\n *\n * export default createManifest({\n * name: settings.app.name,\n * description: settings.app.description,\n * icons: {\n * logo192: settings.app.icons.logo192,\n * logo384: settings.app.icons.logo384,\n * logo512: settings.app.icons.logo512,\n * },\n * });\n * ```\n */\nexport interface ScreenshotConfig {\n src: string;\n sizes: string;\n type?: string;\n form_factor?: 'narrow' | 'wide';\n label?: string;\n}\n\n/**\n * Smart screenshot configuration\n * Automatically detects everything from path or uses defaults\n */\nexport interface SmartScreenshotInput {\n src: string;\n /** Form factor (auto-detected from filename if contains 'desktop'/'mobile', or use default) */\n form_factor?: 'narrow' | 'wide';\n /** Optional label (auto-generated from form_factor) */\n label?: string;\n /** Optional width (defaults based on form_factor) */\n width?: number;\n /** Optional height (defaults based on form_factor) */\n height?: number;\n}\n\n/**\n * Create screenshot config with smart defaults\n * Automatically detects type, sizes, form_factor from path or uses sensible defaults\n *\n * @example\n * ```typescript\n * // Minimal - everything auto-detected\n * createScreenshot({ src: '/screenshots/desktop-view.png' })\n * // → form_factor: 'wide', sizes: '1920x1080', type: 'image/png', label: 'Desktop screenshot'\n *\n * createScreenshot({ src: '/screenshots/mobile.png' })\n * // → form_factor: 'narrow', sizes: '390x844', type: 'image/png', label: 'Mobile screenshot'\n *\n * // With custom dimensions\n * createScreenshot({ src: '/screenshots/tablet.png', width: 1024, height: 768 })\n * ```\n */\nexport function createScreenshot(input: SmartScreenshotInput | string): ScreenshotConfig {\n // Allow string shorthand\n const config = typeof input === 'string' ? { src: input } : input;\n const { src, label, form_factor } = config;\n let { width, height } = config;\n\n // Auto-detect image type from extension\n const ext = src.split('.').pop()?.toLowerCase();\n const typeMap: Record<string, string> = {\n png: 'image/png',\n jpg: 'image/jpeg',\n jpeg: 'image/jpeg',\n webp: 'image/webp',\n svg: 'image/svg+xml',\n };\n const type = ext ? typeMap[ext] || 'image/png' : 'image/png';\n\n // Try to parse dimensions from filename (e.g., \"1920x1080.png\" or \"screenshot-390x844.png\")\n const filename = src.toLowerCase();\n // Destructured: `\\d{3,4}` cannot match empty, so both groups exist whenever\n // the match does — but only binding them says so without an assertion.\n const [, matchedWidth, matchedHeight] = filename.match(/(\\d{3,4})x(\\d{3,4})/) ?? [];\n if (matchedWidth && matchedHeight && !width && !height) {\n width = parseInt(matchedWidth, 10);\n height = parseInt(matchedHeight, 10);\n }\n\n // Auto-detect form_factor from filename if not provided\n let autoFormFactor: 'narrow' | 'wide' = 'wide'; // Default to wide\n if (filename.includes('mobile') || filename.includes('phone') || filename.includes('narrow')) {\n autoFormFactor = 'narrow';\n } else if (filename.includes('desktop') || filename.includes('laptop') || filename.includes('wide')) {\n autoFormFactor = 'wide';\n } else if (width && height) {\n // Calculate from dimensions if provided or parsed\n const aspectRatio = width / height;\n autoFormFactor = aspectRatio > 1.2 ? 'wide' : 'narrow';\n }\n\n const finalFormFactor = form_factor || autoFormFactor;\n\n // Default dimensions based on form_factor (only if not parsed from filename)\n const defaultDimensions = finalFormFactor === 'wide'\n ? { width: 1920, height: 1080 } // Desktop default\n : { width: 390, height: 844 }; // Mobile default (iPhone 14)\n\n const finalWidth = width || defaultDimensions.width;\n const finalHeight = height || defaultDimensions.height;\n\n // Auto-generate label\n const autoLabel = finalFormFactor === 'wide'\n ? 'Desktop screenshot'\n : 'Mobile screenshot';\n\n return {\n src,\n sizes: `${finalWidth}x${finalHeight}`,\n type,\n form_factor: finalFormFactor,\n label: label || autoLabel,\n };\n}\n\n/**\n * Create multiple screenshots from array\n * Supports string shorthand or full config objects\n *\n * @example\n * ```typescript\n * // Minimal - just paths\n * createScreenshots([\n * '/screenshots/desktop.png', // Auto: wide, 1920x1080\n * '/screenshots/mobile.png', // Auto: narrow, 390x844\n * ])\n *\n * // Mixed\n * createScreenshots([\n * '/screenshots/desktop.png',\n * { src: '/screenshots/tablet.png', width: 1024, height: 768 },\n * ])\n * ```\n */\nexport function createScreenshots(inputs: Array<SmartScreenshotInput | string>): ScreenshotConfig[] {\n return inputs.map(createScreenshot);\n}\n\nexport function createManifest(config: {\n name: string;\n shortName?: string;\n description?: string;\n themeColor?: string;\n backgroundColor?: string;\n display?: 'standalone' | 'fullscreen' | 'minimal-ui' | 'browser';\n orientation?: 'portrait' | 'landscape' | 'any';\n id?: string;\n startUrl?: string;\n scope?: string;\n lang?: string;\n dir?: 'ltr' | 'rtl' | 'auto';\n icons?: IconPaths | ManifestConfig['icons'];\n screenshots?: ScreenshotConfig[];\n protocol_handlers?: ProtocolHandler[];\n}): () => MetadataRoute.Manifest {\n return () => {\n // Convert IconPaths to manifest icons format\n let manifestIcons: MetadataRoute.Manifest['icons'];\n\n if (Array.isArray(config.icons)) {\n // Already in manifest format\n manifestIcons = config.icons as MetadataRoute.Manifest['icons'];\n } else if (config.icons) {\n // Convert IconPaths to manifest icons\n const { logo192, logo384, logo512 } = config.icons as IconPaths;\n manifestIcons = [\n ...(logo192\n ? [\n {\n src: logo192,\n sizes: '192x192',\n type: 'image/png',\n purpose: 'maskable' as const,\n },\n ]\n : []),\n ...(logo384\n ? [\n {\n src: logo384,\n sizes: '384x384',\n type: 'image/png',\n },\n ]\n : []),\n ...(logo512\n ? [\n {\n src: logo512,\n sizes: '512x512',\n type: 'image/png',\n },\n ]\n : []),\n ];\n }\n\n const manifest: MetadataRoute.Manifest = {\n name: config.name,\n short_name: config.shortName || config.name,\n description: config.description || config.name,\n id: config.id || config.startUrl || '/',\n start_url: config.startUrl || '/',\n scope: config.scope || '/',\n display: config.display || 'standalone',\n orientation: config.orientation || 'portrait',\n background_color: config.backgroundColor || '#000000',\n theme_color: config.themeColor || '#ffffff',\n lang: config.lang || 'en',\n dir: config.dir || 'ltr',\n icons: manifestIcons,\n // Removed forced gcm_sender_id to avoid potential conflicts with VAPID\n // gcm_sender_id: '103953800507',\n };\n\n // Add screenshots if provided (for Richer PWA Install UI)\n if (config.screenshots && config.screenshots.length > 0) {\n manifest.screenshots = config.screenshots;\n }\n\n // Add protocol handlers if provided\n if (config.protocol_handlers && config.protocol_handlers.length > 0) {\n manifest.protocol_handlers = config.protocol_handlers;\n }\n\n return manifest;\n };\n}\n\n/**\n * Generate manifest.json content (legacy)\n *\n * @deprecated Use createManifest() instead\n */\nexport function generateManifest(config: ManifestConfig): MetadataRoute.Manifest {\n return createManifest(config)();\n}\n"],"mappings":"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","names":[]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@djangocfg/nextjs",
3
- "version": "2.1.606",
3
+ "version": "2.1.608",
4
4
  "description": "Next.js server utilities: sitemap, health, OG images, contact forms, navigation, config",
5
5
  "keywords": [
6
6
  "nextjs",
@@ -159,10 +159,10 @@
159
159
  "ai-docs": "tsx src/ai/cli.ts"
160
160
  },
161
161
  "peerDependencies": {
162
- "@djangocfg/api": "^2.1.606",
163
- "@djangocfg/devtools": "^2.1.606",
164
- "@djangocfg/i18n": "^2.1.606",
165
- "@djangocfg/ui-core": "^2.1.606",
162
+ "@djangocfg/api": "^2.1.608",
163
+ "@djangocfg/devtools": "^2.1.608",
164
+ "@djangocfg/i18n": "^2.1.608",
165
+ "@djangocfg/ui-core": "^2.1.608",
166
166
  "next": "^16.2.2"
167
167
  },
168
168
  "peerDependenciesMeta": {
@@ -184,12 +184,12 @@
184
184
  "semver": "^7.8.5"
185
185
  },
186
186
  "devDependencies": {
187
- "@djangocfg/api": "^2.1.606",
188
- "@djangocfg/devtools": "^2.1.606",
189
- "@djangocfg/i18n": "^2.1.606",
190
- "@djangocfg/layouts": "^2.1.606",
191
- "@djangocfg/typescript-config": "^2.1.606",
192
- "@djangocfg/ui-core": "^2.1.606",
187
+ "@djangocfg/api": "^2.1.608",
188
+ "@djangocfg/devtools": "^2.1.608",
189
+ "@djangocfg/i18n": "^2.1.608",
190
+ "@djangocfg/layouts": "^2.1.608",
191
+ "@djangocfg/typescript-config": "^2.1.608",
192
+ "@djangocfg/ui-core": "^2.1.608",
193
193
  "@types/node": "^25.9.5",
194
194
  "@types/react": "19.2.15",
195
195
  "@types/react-dom": "19.2.3",
@@ -0,0 +1,33 @@
1
+ import assert from 'node:assert/strict';
2
+ import { describe, it } from 'node:test';
3
+
4
+ import { createManifestMetadata, createViewport } from './manifest';
5
+
6
+ describe('createViewport', () => {
7
+ it('keeps pinch-zoom by default (no maximumScale, no userScalable)', () => {
8
+ const v = createViewport({ themeColor: '#fff' }) as Record<string, unknown>;
9
+ assert.equal(v.width, 'device-width');
10
+ assert.equal(v.initialScale, 1);
11
+ assert.equal('maximumScale' in v, false);
12
+ assert.equal('userScalable' in v, false);
13
+ });
14
+
15
+ it('appLike locks zoom explicitly', () => {
16
+ const v = createViewport({ appLike: true }) as Record<string, unknown>;
17
+ assert.equal(v.maximumScale, 1);
18
+ assert.equal(v.userScalable, false);
19
+ });
20
+
21
+ it('viewportFit is opt-in', () => {
22
+ assert.equal((createViewport({}) as Record<string, unknown>).viewportFit, undefined);
23
+ assert.equal((createViewport({ viewportFit: 'cover' }) as Record<string, unknown>).viewportFit, 'cover');
24
+ });
25
+ });
26
+
27
+ describe('createManifestMetadata', () => {
28
+ it('is installable on iOS with the chosen status bar style', () => {
29
+ const m = createManifestMetadata({ name: 'App', statusBarStyle: 'black-translucent' });
30
+ assert.equal((m.appleWebApp as { capable: boolean }).capable, true);
31
+ assert.equal((m.appleWebApp as { statusBarStyle: string }).statusBarStyle, 'black-translucent');
32
+ });
33
+ });
@@ -12,6 +12,8 @@ export interface ManifestConfig {
12
12
  description?: string;
13
13
  themeColor?: string;
14
14
  backgroundColor?: string;
15
+ /** iOS status bar in the installed app. @default 'default' */
16
+ statusBarStyle?: 'default' | 'black' | 'black-translucent';
15
17
  display?: 'standalone' | 'fullscreen' | 'minimal-ui' | 'browser';
16
18
  orientation?: 'portrait' | 'landscape' | 'any';
17
19
  startUrl?: string;
@@ -64,11 +66,23 @@ export interface ProtocolHandler {
64
66
  * });
65
67
  * ```
66
68
  */
67
- export function createViewport(config: { themeColor?: string }): Viewport {
69
+ export function createViewport(config: {
70
+ themeColor?: string;
71
+ /** `'cover'` lets the page extend under the notch / status bar and makes `env(safe-area-inset-*)` non-zero (needed for PWAs). */
72
+ viewportFit?: 'auto' | 'contain' | 'cover';
73
+ /**
74
+ * Also adds `maximumScale: 1` and `userScalable: false`. OFF by default on purpose: it blocks pinch-zoom for
75
+ * people who need it (WCAG 1.4.4) and iOS Safari ignores `user-scalable=no` anyway. Double-tap zoom is already
76
+ * off through `touch-action: manipulation` in the ui-core base styles; prefer that.
77
+ */
78
+ appLike?: boolean;
79
+ }): Viewport {
68
80
  return {
81
+ ...(config.viewportFit ? { viewportFit: config.viewportFit } : {}),
82
+ ...(config.appLike ? { userScalable: false } : {}),
69
83
  width: 'device-width',
70
84
  initialScale: 1,
71
- maximumScale: 1,
85
+ ...(config.appLike ? { maximumScale: 1 } : {}),
72
86
  themeColor: config.themeColor || '#000000',
73
87
  };
74
88
  }
@@ -98,7 +112,7 @@ export function createManifestMetadata(config: ManifestConfig): Metadata {
98
112
  manifest: '/manifest.json',
99
113
  appleWebApp: {
100
114
  capable: true,
101
- statusBarStyle: 'default',
115
+ statusBarStyle: config.statusBarStyle ?? 'default',
102
116
  title: config.shortName || config.name,
103
117
  },
104
118
  applicationName: config.name,