ocre-cli 0.2.0

Command-line tool for Ocre: create, generate, migrate, run and deploy apps.
//! `ocre generate pwa`: Progressive Web App files, like the manifest and
//! service worker of Rails 8 apps. Static files in the assets directory
//! (served by Cloudflare before the Worker runs: free, not counted as
//! Worker requests), linked from `templates/layout.html`:
//!
//! - `manifest.webmanifest`: name, colors, start URL and icon, so browsers
//!   offer to install the app;
//! - `service-worker.js`: offline page and push notification handlers, to
//!   fill in;
//! - `pwa.js`: registers the service worker (a file, so the default
//!   Content-Security-Policy `script-src 'self'` allows it);
//! - `icon.svg`: a placeholder icon.

use super::Edits;
use crate::{CliResult, config, output::CliError, project::Project};

const LAYOUT: &str = "templates/layout.html";

/// Lines added to the layout's `<head>`.
const HEAD: &str = r##"  <link rel="manifest" href="/manifest.webmanifest">
  <meta name="theme-color" content="#cc7722">
  <link rel="icon" href="/icon.svg" type="image/svg+xml">
  <script src="/pwa.js" defer></script>
"##;

const MANIFEST: &str = r##"{
  "name": "__NAME__",
  "short_name": "__NAME__",
  "description": "__NAME__",
  "start_url": "/",
  "scope": "/",
  "display": "standalone",
  "background_color": "#ffffff",
  "theme_color": "#cc7722",
  "icons": [
    { "src": "/icon.svg", "type": "image/svg+xml", "sizes": "any", "purpose": "any maskable" }
  ]
}
"##;

const SERVICE_WORKER: &str = r#"// Service worker of the app, generated by `ocre g pwa` and registered by
// pwa.js. It runs in the browser, not on Cloudflare: requests it answers
// from its cache cost no Worker request.
const CACHE = "offline-v1";
const OFFLINE_URLS = ["/"];

self.addEventListener("install", (event) => {
  event.waitUntil(caches.open(CACHE).then((cache) => cache.addAll(OFFLINE_URLS)));
  self.skipWaiting();
});

self.addEventListener("activate", (event) => {
  event.waitUntil(
    caches.keys().then((keys) => Promise.all(keys.filter((key) => key !== CACHE).map((key) => caches.delete(key)))),
  );
  self.clients.claim();
});

// Pages come from the network; when offline, the cached home page answers.
self.addEventListener("fetch", (event) => {
  if (event.request.mode !== "navigate") return;
  event.respondWith(fetch(event.request).catch(() => caches.match("/")));
});

// Web push: a message `{ "title": "...", "options": { "body": "...", "data": { "path": "/..." } } }`.
self.addEventListener("push", (event) => {
  const { title, options } = event.data ? event.data.json() : { title: "Update", options: {} };
  event.waitUntil(self.registration.showNotification(title, options));
});

self.addEventListener("notificationclick", (event) => {
  event.notification.close();
  const path = (event.notification.data && event.notification.data.path) || "/";
  event.waitUntil(self.clients.openWindow(path));
});
"#;

const REGISTER: &str = r#"// Registers the service worker (generated by `ocre g pwa`).
if ("serviceWorker" in navigator) {
  navigator.serviceWorker.register("/service-worker.js");
}
"#;

const ICON: &str = r##"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><rect width="512" height="512" rx="96" fill="#cc7722"/><text x="256" y="330" font-family="system-ui, sans-serif" font-size="240" font-weight="700" text-anchor="middle" fill="#ffffff">__INITIAL__</text></svg>
"##;

pub fn pwa(project: &Project) -> CliResult {
    if project.api_only {
        return Err(CliError::new("a PWA needs HTML pages; this app is API-only")
            .hint("serve a manifest.webmanifest from the assets directory of the front end that uses this API"));
    }
    let mut edits = Edits::new(project);
    let layout = edits.read(LAYOUT)?.ok_or_else(|| {
        CliError::new(format!("{LAYOUT} not found")).hint("the manifest link goes in the layout's <head>: restore it")
    })?;
    if layout.contains("manifest.webmanifest") {
        return Err(CliError::new(format!("{LAYOUT} already links a web app manifest"))
            .hint("nothing to generate: edit manifest.webmanifest in the assets directory"));
    }
    let head = layout.find("</head>").ok_or_else(|| {
        CliError::new(format!("{LAYOUT} has no </head>")).hint("add the <head> element back to the layout")
    })?;
    let config = project.config()?;
    let name = config.name.as_deref().unwrap_or("app");
    let initial = name.chars().next().unwrap_or('A').to_ascii_uppercase().to_string();
    let assets = config::assets_directory(&project.root);
    let assets = assets.as_deref().unwrap_or("public");
    edits.create(&format!("{assets}/manifest.webmanifest"), MANIFEST.replace("__NAME__", name))?;
    edits.create(&format!("{assets}/service-worker.js"), SERVICE_WORKER.to_owned())?;
    edits.create(&format!("{assets}/pwa.js"), REGISTER.to_owned())?;
    edits.create(&format!("{assets}/icon.svg"), ICON.replace("__INITIAL__", &initial))?;
    edits.update(LAYOUT, format!("{}{HEAD}{}", &layout[..head], &layout[head..]));
    let mut report = edits.apply("generate pwa")?;
    report.next = vec![
        format!("edit {assets}/manifest.webmanifest (name, colors) and replace {assets}/icon.svg"),
        "ocre dev, then open the app: the browser offers to install it".to_owned(),
    ];
    Ok(report)
}