Files
OpenNoodl/dev-docs/tasks/phase-3-editor-ux-overhaul/TASK-007-app-config/CONFIG-005-pwa-manifest.md
2025-12-30 11:55:30 +01:00

12 KiB

CONFIG-005: PWA Manifest Generation

Overview

Generate a valid manifest.json file and auto-scale app icons from a single source image for Progressive Web App support.

Estimated effort: 10-14 hours
Dependencies: CONFIG-001, CONFIG-002
Blocks: None (enables Phase 5 Capacitor integration)


Objectives

  1. Generate manifest.json from app config
  2. Auto-scale icons from single 512x512 source
  3. Copy icons to build output
  4. Validate PWA requirements
  5. Generate service worker stub (optional)

Generated manifest.json

{
  "name": "My Amazing App",
  "short_name": "My App",
  "description": "A visual programming app that makes building easy",
  "start_url": "/",
  "display": "standalone",
  "background_color": "#ffffff",
  "theme_color": "#d21f3c",
  "icons": [
    {
      "src": "/icons/icon-72x72.png",
      "sizes": "72x72",
      "type": "image/png"
    },
    {
      "src": "/icons/icon-96x96.png",
      "sizes": "96x96",
      "type": "image/png"
    },
    {
      "src": "/icons/icon-128x128.png",
      "sizes": "128x128",
      "type": "image/png"
    },
    {
      "src": "/icons/icon-144x144.png",
      "sizes": "144x144",
      "type": "image/png"
    },
    {
      "src": "/icons/icon-152x152.png",
      "sizes": "152x152",
      "type": "image/png"
    },
    {
      "src": "/icons/icon-192x192.png",
      "sizes": "192x192",
      "type": "image/png"
    },
    {
      "src": "/icons/icon-384x384.png",
      "sizes": "384x384",
      "type": "image/png"
    },
    {
      "src": "/icons/icon-512x512.png",
      "sizes": "512x512",
      "type": "image/png"
    }
  ]
}

Files to Create

1. Manifest Generator

File: packages/noodl-editor/src/editor/src/utils/compilation/build/processors/manifest-processor.ts

import * as path from 'path';
import * as fs from 'fs-extra';
import { ProjectModel } from '@noodl-models/projectmodel';
import { AppConfig } from '@noodl/runtime/src/config/types';

// Standard PWA icon sizes
const ICON_SIZES = [72, 96, 128, 144, 152, 192, 384, 512];

export interface ManifestProcessorOptions {
  baseUrl?: string;
  outputDir: string;
}

export class ManifestProcessor {
  constructor(public readonly project: ProjectModel) {}
  
  public async process(options: ManifestProcessorOptions): Promise<void> {
    const config = this.project.getAppConfig();
    
    // Only generate if PWA is enabled
    if (!config.pwa?.enabled) {
      return;
    }
    
    const baseUrl = options.baseUrl || '/';
    
    // Generate icons from source
    await this.generateIcons(config, options.outputDir, baseUrl);
    
    // Generate manifest.json
    const manifest = this.generateManifest(config, baseUrl);
    const manifestPath = path.join(options.outputDir, 'manifest.json');
    await fs.writeJson(manifestPath, manifest, { spaces: 2 });
  }
  
  private generateManifest(config: AppConfig, baseUrl: string): object {
    const manifest: Record<string, any> = {
      name: config.identity.appName,
      short_name: config.pwa?.shortName || config.identity.appName,
      description: config.identity.description || '',
      start_url: config.pwa?.startUrl || '/',
      display: config.pwa?.display || 'standalone',
      background_color: config.pwa?.backgroundColor || '#ffffff',
      theme_color: config.seo.themeColor || '#000000',
      icons: this.generateIconsManifest(baseUrl)
    };
    
    // Optional fields
    if (config.identity.description) {
      manifest.description = config.identity.description;
    }
    
    return manifest;
  }
  
  private generateIconsManifest(baseUrl: string): object[] {
    return ICON_SIZES.map(size => ({
      src: `${baseUrl}icons/icon-${size}x${size}.png`,
      sizes: `${size}x${size}`,
      type: 'image/png'
    }));
  }
  
  private async generateIcons(
    config: AppConfig, 
    outputDir: string,
    baseUrl: string
  ): Promise<void> {
    const sourceIcon = config.pwa?.sourceIcon;
    
    if (!sourceIcon) {
      console.warn('PWA enabled but no source icon provided. Using placeholder.');
      await this.generatePlaceholderIcons(outputDir);
      return;
    }
    
    // Resolve source icon path
    const sourcePath = this.resolveAssetPath(sourceIcon);
    
    if (!await fs.pathExists(sourcePath)) {
      console.warn(`Source icon not found: ${sourcePath}. Using placeholder.`);
      await this.generatePlaceholderIcons(outputDir);
      return;
    }
    
    // Ensure icons directory exists
    const iconsDir = path.join(outputDir, 'icons');
    await fs.ensureDir(iconsDir);
    
    // Generate each size
    for (const size of ICON_SIZES) {
      const outputPath = path.join(iconsDir, `icon-${size}x${size}.png`);
      await this.resizeIcon(sourcePath, outputPath, size);
    }
  }
  
  private async resizeIcon(
    sourcePath: string, 
    outputPath: string, 
    size: number
  ): Promise<void> {
    // Use sharp for image resizing
    const sharp = require('sharp');
    
    try {
      await sharp(sourcePath)
        .resize(size, size, {
          fit: 'contain',
          background: { r: 255, g: 255, b: 255, alpha: 0 }
        })
        .png()
        .toFile(outputPath);
    } catch (error) {
      console.error(`Failed to resize icon to ${size}x${size}:`, error);
      throw error;
    }
  }
  
  private async generatePlaceholderIcons(outputDir: string): Promise<void> {
    const iconsDir = path.join(outputDir, 'icons');
    await fs.ensureDir(iconsDir);
    
    // Generate simple colored placeholder icons
    const sharp = require('sharp');
    
    for (const size of ICON_SIZES) {
      const outputPath = path.join(iconsDir, `icon-${size}x${size}.png`);
      
      // Create a simple colored square as placeholder
      await sharp({
        create: {
          width: size,
          height: size,
          channels: 4,
          background: { r: 100, g: 100, b: 100, alpha: 1 }
        }
      })
      .png()
      .toFile(outputPath);
    }
  }
  
  private resolveAssetPath(assetPath: string): string {
    if (path.isAbsolute(assetPath)) {
      return assetPath;
    }
    return path.join(this.project._retainedProjectDirectory, assetPath);
  }
}

2. Icon Validation

File: packages/noodl-editor/src/editor/src/views/panels/AppSetupPanel/utils/icon-validation.ts

export interface IconValidationResult {
  valid: boolean;
  errors: string[];
  warnings: string[];
  dimensions?: { width: number; height: number };
}

export async function validatePWAIcon(filePath: string): Promise<IconValidationResult> {
  const errors: string[] = [];
  const warnings: string[] = [];
  
  try {
    const sharp = require('sharp');
    const metadata = await sharp(filePath).metadata();
    
    const { width, height, format } = metadata;
    
    // Check format
    if (format !== 'png') {
      errors.push('Icon must be a PNG file');
    }
    
    // Check dimensions
    if (!width || !height) {
      errors.push('Could not read image dimensions');
    } else {
      // Check if square
      if (width !== height) {
        errors.push(`Icon must be square. Current: ${width}x${height}`);
      }
      
      // Check minimum size
      if (width < 512 || height < 512) {
        errors.push(`Icon must be at least 512x512 pixels. Current: ${width}x${height}`);
      }
      
      // Warn if not exactly 512x512
      if (width !== 512 && width > 512) {
        warnings.push(`Icon will be scaled down from ${width}x${height} to required sizes`);
      }
    }
    
    return {
      valid: errors.length === 0,
      errors,
      warnings,
      dimensions: width && height ? { width, height } : undefined
    };
    
  } catch (error) {
    return {
      valid: false,
      errors: [`Failed to read image: ${error.message}`],
      warnings: []
    };
  }
}

3. Service Worker Stub (Optional)

File: packages/noodl-editor/src/editor/src/utils/compilation/build/templates/sw.js

// Basic service worker for PWA install prompt
// This is a minimal stub - users can replace with their own

const CACHE_NAME = 'app-cache-v1';
const ASSETS_TO_CACHE = [
  '/',
  '/index.html'
];

// Install event - cache basic assets
self.addEventListener('install', (event) => {
  event.waitUntil(
    caches.open(CACHE_NAME)
      .then((cache) => cache.addAll(ASSETS_TO_CACHE))
  );
});

// Fetch event - network first, fallback to cache
self.addEventListener('fetch', (event) => {
  event.respondWith(
    fetch(event.request)
      .catch(() => caches.match(event.request))
  );
});

Files to Modify

1. Build Pipeline

File: packages/noodl-editor/src/editor/src/utils/compilation/build/build.ts (or equivalent)

Add manifest processing to build pipeline:

import { ManifestProcessor } from './processors/manifest-processor';

async function buildProject(project: ProjectModel, options: BuildOptions): Promise<void> {
  // ... existing build steps ...
  
  // Generate PWA manifest and icons
  const manifestProcessor = new ManifestProcessor(project);
  await manifestProcessor.process({
    baseUrl: options.baseUrl,
    outputDir: options.outputDir
  });
  
  // ... rest of build ...
}

2. Deploy Build

Ensure the deploy process also runs manifest generation:

File: packages/noodl-editor/src/editor/src/utils/compilation/deploy/

// Include manifest generation in deploy build
await manifestProcessor.process({
  baseUrl: deployConfig.baseUrl || '/',
  outputDir: deployOutputDir
});

Icon Size Reference

Size Usage
72x72 Android home screen (legacy)
96x96 Android home screen
128x128 Chrome Web Store
144x144 IE/Edge pinned sites
152x152 iPad home screen
192x192 Android home screen (modern), Chrome install
384x384 Android splash screen
512x512 Android splash screen, PWA install prompt

Testing Checklist

Manifest Generation

  • manifest.json created when PWA enabled
  • manifest.json not created when PWA disabled
  • name field matches appName
  • short_name matches config or falls back to name
  • description present if configured
  • start_url correct
  • display mode correct
  • background_color correct
  • theme_color correct
  • icons array has all 8 sizes

Icon Generation

  • All 8 icon sizes generated
  • Icons are valid PNG files
  • Icons maintain aspect ratio
  • Icons have correct dimensions
  • Placeholder icons generated when no source
  • Warning shown when source icon missing
  • Error shown for invalid source format

Validation

  • Non-square icons rejected
  • Non-PNG icons rejected
  • Too-small icons rejected
  • Valid icons pass validation
  • Warnings for oversized icons

Integration

  • manifest.json linked in HTML head
  • Icons accessible at correct paths
  • PWA install prompt appears in Chrome
  • App installs correctly
  • Installed app shows correct icon
  • Splash screen displays correctly

Notes for Implementer

Sharp Dependency

The sharp library is required for image processing. It should already be available in the editor dependencies. If not:

npm install sharp --save

Service Worker Considerations

The basic service worker stub is intentionally minimal. Advanced features like:

  • Offline caching strategies
  • Push notifications
  • Background sync

...should be implemented by users through custom code or future Noodl features.

Capacitor Integration (Phase 5)

The icon generation logic will be reused for Capacitor builds:

  • iOS requires specific sizes: 1024, 180, 167, 152, 120, 76, 60, 40, 29, 20
  • Android uses adaptive icons with foreground/background layers

Consider making icon generation configurable for different target platforms.

Testing PWA Installation

Test PWA installation on:

  • Chrome (desktop and mobile)
  • Edge (desktop)
  • Safari (iOS - via Add to Home Screen)
  • Firefox (Android)

Use Chrome DevTools > Application > Manifest to verify manifest is valid.