Web app manifest
Installable web apps: the manifest written with sensible defaults and checked against what browsers need for Install, the icon set (maskable, Apple's and favicons) made from one image, and the head tags.
Version 1.0.0 · 4 functions · 2 crates
Functions
What use pwa_manifest.qwer gives a program, with the types Quillo sees. To take only some, or under other names: use pwa_manifest, pwa_check from pwa_manifest (Quillo 0.6).
pwa_manifest(options: map) → anyor an errorA manifest from options: name (needed), and any of short_name, description, start_url, scope, id, display, theme_color, background_color, lang, orientation, categories, icons (as pwa_icons gives them), shortcuts, screenshots. Missing ones get defaults: start_url and scope "/", display "standalone", short_name from the name.
pwa_check(manifest: any) → anyWhat stands between a manifest (a map, or its JSON text) and the browser's "Install" button, and what would make the install look better: { installable, problems: [{ field, severity, message }] }, severity "error" (blocks installing) or "warning".
pwa_icons(source_path: str, out_dir: str, background: str, url_prefix: str) → listor an errorThe icon set made from one image (PNG, JPEG or WebP, square and 512×512 or larger is best) into out_dir: PNGs for the manifest (48 to 512), maskable ones (the image at 80% on background, a #rrggbb color, so Android's shapes do not cut it), apple-touch-icon.png (180, on the background: iOS does not show transparency) and favicons (16, 32). Gives the manifest's icons list, each src as url_prefix + file name.
pwa_head_tags(manifest_url: str, theme_color: str, app_name: str, url_prefix: str) → strThe tags for a page's <head>: the manifest link, theme color, Apple's icon and title, and the favicons, with files where pwa_icons put them under url_prefix. Put it in the page with raw(...).
Get it
$ quillo install pwa_manifest # pwa_manifest.qwer into this folder$ quillo install pwa_manifest --example # and its example, pwa_manifest.qweOr with curl:
$ curl -O https://quillolang.org/plugins/files/pwa_manifest.qwer$ curl -O https://quillolang.org/plugins/files/pwa_manifest.qwe$ curl -O https://quillolang.org/plugins/files/pwa_manifest_sample.pngThen, in your program, all of it or only what you call:
use pwa_manifest.qweruse pwa_manifest, pwa_check from pwa_manifest // Quillo 0.6: only theseIt uses these crates, which cargo fetches the first time the plugin is built:
image = { version = "0.25", default-features = false, features = ["png", "jpeg", "webp"] }serde_json = { version = "1", features = ["preserve_order"] }Example
// A shop installable as an app: its icons made from one image at start, its manifest served,// and the head tags on every page.//// quillo run pwa_manifest.qwe then open http://localhost:8080/ and http://localhost:8080/manifest.webmanifestuse "pwa_manifest.qwer"let ICONS = pwa_icons("pwa_manifest_sample.png", "data/icons", "#2e7d32", "/icons/")?let MANIFEST = pwa_manifest({ name: "Quillo Corner Shop", id: "/?app=shop", description: "Order groceries for pickup in ten minutes.", theme_color: "#2e7d32", background_color: "#ffffff", icons: ICONS, shortcuts: [{ name: "My orders", url: "/orders" }],})?route GET /manifest.webmanifest = respond(200, json.stringify(MANIFEST), { "content-type": "application/manifest+json" })route GET /icons/:name = file(`data/icons/${name}`)route GET / = respond(200, `<!doctype html><html><head><title>Corner Shop</title>${pwa_head_tags("/manifest.webmanifest", "#2e7d32", "Corner Shop", "/icons/")}</head><body><h1>Corner Shop</h1></body></html>`, { "content-type": "text/html; charset=utf-8" })route GET /check = { ours: pwa_check(MANIFEST), bare: pwa_check('{"name": "Old site", "display": "browser", "icons": [{"src": "/i.png", "sizes": "64x64"}]}'), icons: len(ICONS),}$ quillo run pwa_manifest.qwe$ curl 'localhost:8080/manifest.webmanifest'$ curl 'localhost:8080/check'Source
//! Installable web apps: the web app manifest written with sensible defaults, checked against what browsers need to offer "Install", the whole icon set (maskable ones, Apple's and favicons) made from one square image, and the head tags that point to them.//! version: 1.0.0//!//! [dependencies]//! image = { version = "0.25", default-features = false, features = ["png", "jpeg", "webp"] }//! serde_json = { version = "1", features = ["preserve_order"] }use image::{imageops::FilterType, DynamicImage, GenericImageView, Rgba, RgbaImage};use serde_json::{json, Map, Value};const DISPLAYS: &[&str] = &["fullscreen", "standalone", "minimal-ui", "browser"];fn color_ok(c: &str) -> bool { let c = c.trim(); (c.starts_with('#') && [4, 5, 7, 9].contains(&c.len()) && c[1..].chars().all(|x| x.is_ascii_hexdigit())) || c.starts_with("rgb") || c.starts_with("hsl") || c.chars().all(|x| x.is_ascii_alphabetic())}fn hex_color(c: &str) -> Result<Rgba<u8>, String> { let h = c.trim().trim_start_matches('#'); let full: String = match h.len() { 3 | 4 => h.chars().flat_map(|x| [x, x]).collect(), 6 | 8 => h.to_string(), _ => return Err(format!("`{}` is not a #rgb or #rrggbb color", c)), }; let b = |i: usize| u8::from_str_radix(&full[i..i + 2], 16).map_err(|_| format!("`{}` is not a hex color", c)); Ok(Rgba([b(0)?, b(2)?, b(4)?, if full.len() == 8 { b(6)? } else { 255 }]))}/// A manifest from `options`: `name` (needed), and any of `short_name`, `description`, `start_url`, `scope`, `id`, `display`,/// `theme_color`, `background_color`, `lang`, `orientation`, `categories`, `icons` (as `pwa_icons` gives them), `shortcuts`,/// `screenshots`. Missing ones get defaults: `start_url` and `scope` "/", `display` "standalone", `short_name` from the name.pub fn pwa_manifest(options: std::collections::HashMap<String, serde_json::Value>) -> Result<Value, String> { let name = options.get("name").and_then(|v| v.as_str()).ok_or("a manifest needs a name")?.to_string(); let get = |k: &str| options.get(k).filter(|v| !v.is_null()).cloned(); let short = get("short_name").unwrap_or_else(|| { let s: String = name.chars().take(12).collect(); json!(s.trim_end()) }); let mut m = Map::new(); m.insert("name".into(), json!(name)); m.insert("short_name".into(), short); for k in ["description", "id"] { if let Some(v) = get(k) { m.insert(k.into(), v); } } m.insert("start_url".into(), get("start_url").unwrap_or(json!("/"))); m.insert("scope".into(), get("scope").unwrap_or(json!("/"))); m.insert("display".into(), get("display").unwrap_or(json!("standalone"))); for k in ["orientation", "lang", "dir", "theme_color", "background_color", "categories", "icons", "shortcuts", "screenshots", "related_applications"] { if let Some(v) = get(k) { m.insert(k.into(), v); } } let v = Value::Object(m); let errors: Vec<String> = pwa_check(v.clone())["problems"].as_array().into_iter().flatten().filter(|p| p["severity"] == "error" && p["field"] != "icons").filter_map(|p| p["message"].as_str().map(str::to_string)).collect(); if !errors.is_empty() { return Err(errors.join("; ")); } Ok(v)}/// What stands between a manifest (a map, or its JSON text) and the browser's "Install" button, and what would make the install/// look better: `{ installable, problems: [{ field, severity, message }] }`, severity "error" (blocks installing) or "warning".pub fn pwa_check(manifest: serde_json::Value) -> Value { let m = match &manifest { Value::String(s) => serde_json::from_str(s).unwrap_or(Value::Null), other => other.clone(), }; let mut p: Vec<Value> = vec![]; let mut add = |field: &str, severity: &str, message: String| p.push(json!({ "field": field, "severity": severity, "message": message })); if !m.is_object() { add("", "error", "the manifest is not a JSON object".into()); return json!({ "installable": false, "problems": p }); } let s = |k: &str| m[k].as_str().map(str::trim).filter(|x| !x.is_empty()); if s("name").is_none() && s("short_name").is_none() { add("name", "error", "a name or short_name is needed".into()); } if let Some(sn) = s("short_name") { if sn.chars().count() > 12 { add("short_name", "warning", format!("`{}` is {} characters: home screens cut it after about 12", sn, sn.chars().count())); } } else { add("short_name", "warning", "no short_name: the home screen shows the name, cut".into()); } if s("start_url").is_none() { add("start_url", "error", "a start_url is needed".into()); } match s("display") { None => add("display", "error", "display should be standalone, fullscreen or minimal-ui to install as an app".into()), Some(d) if !DISPLAYS.contains(&d) => add("display", "error", format!("display `{}` is not one of {}", d, DISPLAYS.join(", "))), Some("browser") => add("display", "error", "display browser opens in a tab: use standalone".into()), _ => {} } if m["prefer_related_applications"] == json!(true) { add("prefer_related_applications", "error", "true sends people to the store app instead of installing".into()); } let icons: Vec<&Value> = m["icons"].as_array().map(|a| a.iter().collect()).unwrap_or_default(); let has = |size: u32, purpose: &str| { icons.iter().any(|i| { let sizes = i["sizes"].as_str().unwrap_or(""); let fits = sizes.split_whitespace().any(|s| s == "any" || s.split('x').next().and_then(|w| w.parse::<u32>().ok()).is_some_and(|w| w >= size)); let purposes = i["purpose"].as_str().unwrap_or("any"); fits && purposes.split_whitespace().any(|x| x == purpose) }) }; if !has(192, "any") { add("icons", "error", "an icon of at least 192×192 is needed".into()); } if !has(512, "any") { add("icons", "error", "an icon of at least 512×512 is needed for the splash screen".into()); } if !has(192, "maskable") { add("icons", "warning", "no maskable icon: Android puts the icon on a white circle".into()); } for k in ["theme_color", "background_color"] { match s(k) { None => add(k, "warning", format!("no {}: the title bar and splash screen take the browser's defaults", k)), Some(c) if !color_ok(c) => add(k, "error", format!("`{}` is not a CSS color", c)), _ => {} } } if s("id").is_none() { add("id", "warning", "no id: the app is known by its start_url, so changing that later makes it another app".into()); } if !m["screenshots"].as_array().is_some_and(|a| !a.is_empty()) { add("screenshots", "warning", "no screenshots: Chrome shows the small install prompt instead of the richer one".into()); } if s("description").is_none() { add("description", "warning", "no description for the install dialog".into()); } let installable = !p.iter().any(|x| x["severity"] == "error"); json!({ "installable": installable, "problems": p })}fn square(img: &DynamicImage) -> RgbaImage { let (w, h) = img.dimensions(); let side = w.max(h); let mut canvas = RgbaImage::from_pixel(side, side, Rgba([0, 0, 0, 0])); image::imageops::overlay(&mut canvas, &img.to_rgba8(), ((side - w) / 2) as i64, ((side - h) / 2) as i64); canvas}fn on_background(src: &RgbaImage, size: u32, bg: Rgba<u8>, safe: f32) -> RgbaImage { let inner = ((size as f32) * safe).round() as u32; let scaled = image::imageops::resize(src, inner, inner, FilterType::Lanczos3); let mut out = RgbaImage::from_pixel(size, size, bg); let off = ((size - inner) / 2) as i64; image::imageops::overlay(&mut out, &scaled, off, off); out}/// The icon set made from one image (PNG, JPEG or WebP, square and 512×512 or larger is best) into `out_dir`: PNGs for the/// manifest (48 to 512), maskable ones (the image at 80% on `background`, a #rrggbb color, so Android's shapes do not cut it),/// `apple-touch-icon.png` (180, on the background: iOS does not show transparency) and favicons (16, 32). Gives the manifest's/// `icons` list, each `src` as `url_prefix` + file name.pub fn pwa_icons(source_path: &str, out_dir: &str, background: &str, url_prefix: &str) -> Result<Vec<Value>, String> { let img = image::open(source_path).map_err(|e| format!("{}: {}", source_path, e))?; let (w, h) = img.dimensions(); if w.min(h) < 192 { return Err(format!("the image is {}×{}: start from 512×512 or larger", w, h)); } let bg = hex_color(background)?; let src = square(&img); std::fs::create_dir_all(out_dir).map_err(|e| format!("{}: {}", out_dir, e))?; let save = |img: &RgbaImage, name: &str| -> Result<(), String> { img.save(std::path::Path::new(out_dir).join(name)).map_err(|e| format!("{}: {}", name, e)) }; let mut icons = vec![]; for size in [48u32, 72, 96, 128, 144, 192, 256, 384, 512] { let name = format!("icon-{}.png", size); save(&image::imageops::resize(&src, size, size, FilterType::Lanczos3), &name)?; icons.push(json!({ "src": format!("{}{}", url_prefix, name), "sizes": format!("{}x{}", size, size), "type": "image/png", "purpose": "any" })); } for size in [192u32, 512] { let name = format!("maskable-{}.png", size); save(&on_background(&src, size, bg, 0.8), &name)?; icons.push(json!({ "src": format!("{}{}", url_prefix, name), "sizes": format!("{}x{}", size, size), "type": "image/png", "purpose": "maskable" })); } save(&on_background(&src, 180, bg, 0.9), "apple-touch-icon.png")?; for size in [16u32, 32] { save(&image::imageops::resize(&src, size, size, FilterType::Lanczos3), &format!("favicon-{}.png", size))?; } Ok(icons)}/// The tags for a page's `<head>`: the manifest link, theme color, Apple's icon and title, and the favicons, with files where/// `pwa_icons` put them under `url_prefix`. Put it in the page with `raw(...)`.pub fn pwa_head_tags(manifest_url: &str, theme_color: &str, app_name: &str, url_prefix: &str) -> String { let esc = |s: &str| s.replace('&', "&").replace('"', """).replace('<', "<"); [ format!("<link rel=\"manifest\" href=\"{}\">", esc(manifest_url)), format!("<meta name=\"theme-color\" content=\"{}\">", esc(theme_color)), format!("<link rel=\"icon\" type=\"image/png\" sizes=\"32x32\" href=\"{}favicon-32.png\">", esc(url_prefix)), format!("<link rel=\"icon\" type=\"image/png\" sizes=\"16x16\" href=\"{}favicon-16.png\">", esc(url_prefix)), format!("<link rel=\"apple-touch-icon\" href=\"{}apple-touch-icon.png\">", esc(url_prefix)), "<meta name=\"mobile-web-app-capable\" content=\"yes\">".to_string(), format!("<meta name=\"apple-mobile-web-app-title\" content=\"{}\">", esc(app_name)), ] .join("\n")}Related
og_image.qwer · 1.0.0Link-preview pictures (1200 × 630 PNG) with a title and subtitle, wrapped and sized to fit, in your colours, with a font built in.7 views · 9 downloadsrobots.txt robots.qwer · 1.0.0robots.txt read as Google does: whether an agent may fetch a path, its crawl delay and sitemaps; and your own site's robots.txt written.8 views · 7 downloadsSitemaps sitemap_read.qwer · 1.0.0Sitemaps and sitemap indexes read from XML, .xml.gz or text: URLs with lastmod, changefreq, priority and images; and a sitemap written for your pages.7 views · 8 downloadsStructured data structured_data.qwer · 1.0.0JSON-LD, microdata, Open Graph and Twitter tags read out of HTML; schema.org JSON-LD written for products, articles, breadcrumbs and FAQs; and items checked for what Google's rich results need.7 views · 8 downloadsURL tools url_tools.qwer · 1.0.0URLs by the rules browsers use: parts and query as a map, relative links resolved, a normal form for comparing, tracking parameters stripped, query values set.7 views · 6 downloadsThread 0 posts
On Web app manifest: questions, tips, corrections, and code that uses it.
No posts yet. The first one opens the thread.