[FEAT] PWA installabile Android/iOS conforme AGID (v1.15.0)
- manifest.webmanifest + service worker (offline shell, /api network-only, asset stale-while-revalidate) - set icone (192/512 + maskable + apple-touch + favicon), generate zero-deps (pure Node+zlib) - tag PWA + viewport viewport-fit=cover (zoom mantenuto, WCAG 1.4.4) in 77 pagine HTML - AGID/WCAG 2.1 AA: touch target >=44px, orientamento/zoom liberi, safe-area standalone - generatori riproducibili: scripts/gen-pwa-icons.mjs + scripts/inject-pwa-head.mjs - bump version 1.14.1 -> 1.15.0 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
b80bf2361c
commit
36513fa722
@@ -0,0 +1,487 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* gen-pwa-icons.mjs — NIS2 Agile PWA icon generator (dependency-free)
|
||||
*
|
||||
* Generates the full PWA icon set using ONLY Node v20 built-ins:
|
||||
* - node:zlib (deflate for PNG IDAT)
|
||||
* - node:fs (write files)
|
||||
* - node:path (resolve paths)
|
||||
*
|
||||
* Design: Italy-blue (#0066CC) square tile, centered white shield
|
||||
* (compliance/security silhouette) with a bold "negative" check mark
|
||||
* cut out of the shield (rendered in #0066CC) so it stays readable
|
||||
* down to 16px.
|
||||
*
|
||||
* Rendering: each icon is drawn at 4x (supersampling) into an RGBA
|
||||
* buffer using hand-written primitives (rounded rect, scanline polygon
|
||||
* fill), then box-downsampled to final size for smooth anti-aliased
|
||||
* edges. PNG is encoded manually (8-bit, color type 6 RGBA).
|
||||
*
|
||||
* Run: cd /projects/nis2-agile && node scripts/gen-pwa-icons.mjs
|
||||
*/
|
||||
|
||||
import zlib from 'node:zlib';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Paths
|
||||
// ---------------------------------------------------------------------------
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
const PROJECT_ROOT = path.resolve(__dirname, '..');
|
||||
const ICONS_DIR = path.join(PROJECT_ROOT, 'public', 'assets', 'icons');
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Palette
|
||||
// ---------------------------------------------------------------------------
|
||||
const BLUE = [0, 102, 204]; // #0066CC — Italy blue
|
||||
const WHITE = [255, 255, 255];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// RGBA canvas helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
function makeCanvas(w, h, fill) {
|
||||
// fill = [r,g,b,a] or null => transparent (0,0,0,0)
|
||||
const buf = new Uint8Array(w * h * 4);
|
||||
if (fill) {
|
||||
for (let i = 0; i < w * h; i++) {
|
||||
const o = i * 4;
|
||||
buf[o] = fill[0];
|
||||
buf[o + 1] = fill[1];
|
||||
buf[o + 2] = fill[2];
|
||||
buf[o + 3] = fill[3];
|
||||
}
|
||||
}
|
||||
return { w, h, buf };
|
||||
}
|
||||
|
||||
function setPx(cv, x, y, rgba) {
|
||||
if (x < 0 || y < 0 || x >= cv.w || y >= cv.h) return;
|
||||
const o = (y * cv.w + x) * 4;
|
||||
cv.buf[o] = rgba[0];
|
||||
cv.buf[o + 1] = rgba[1];
|
||||
cv.buf[o + 2] = rgba[2];
|
||||
cv.buf[o + 3] = rgba[3];
|
||||
}
|
||||
|
||||
// Fill the whole canvas with an OPAQUE color.
|
||||
function fillBackground(cv, rgb) {
|
||||
for (let i = 0; i < cv.w * cv.h; i++) {
|
||||
const o = i * 4;
|
||||
cv.buf[o] = rgb[0];
|
||||
cv.buf[o + 1] = rgb[1];
|
||||
cv.buf[o + 2] = rgb[2];
|
||||
cv.buf[o + 3] = 255;
|
||||
}
|
||||
}
|
||||
|
||||
// Filled rounded rectangle (opaque). r = corner radius in px (canvas space).
|
||||
function fillRoundedRect(cv, x0, y0, x1, y1, r, rgb, alpha = 255) {
|
||||
const minX = Math.max(0, Math.floor(x0));
|
||||
const minY = Math.max(0, Math.floor(y0));
|
||||
const maxX = Math.min(cv.w - 1, Math.ceil(x1));
|
||||
const maxY = Math.min(cv.h - 1, Math.ceil(y1));
|
||||
const rr = r * r;
|
||||
for (let y = minY; y <= maxY; y++) {
|
||||
for (let x = minX; x <= maxX; x++) {
|
||||
const px = x + 0.5;
|
||||
const py = y + 0.5;
|
||||
let inside = true;
|
||||
// corner clipping
|
||||
if (px < x0 + r && py < y0 + r) {
|
||||
const dx = px - (x0 + r), dy = py - (y0 + r);
|
||||
inside = dx * dx + dy * dy <= rr;
|
||||
} else if (px > x1 - r && py < y0 + r) {
|
||||
const dx = px - (x1 - r), dy = py - (y0 + r);
|
||||
inside = dx * dx + dy * dy <= rr;
|
||||
} else if (px < x0 + r && py > y1 - r) {
|
||||
const dx = px - (x0 + r), dy = py - (y1 - r);
|
||||
inside = dx * dx + dy * dy <= rr;
|
||||
} else if (px > x1 - r && py > y1 - r) {
|
||||
const dx = px - (x1 - r), dy = py - (y1 - r);
|
||||
inside = dx * dx + dy * dy <= rr;
|
||||
}
|
||||
if (inside) setPx(cv, x, y, [rgb[0], rgb[1], rgb[2], alpha]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Scanline polygon fill. pts = [[x,y],...] in canvas space. Even-odd rule.
|
||||
function fillPolygon(cv, pts, rgb, alpha = 255) {
|
||||
let minY = Infinity, maxY = -Infinity;
|
||||
for (const p of pts) {
|
||||
if (p[1] < minY) minY = p[1];
|
||||
if (p[1] > maxY) maxY = p[1];
|
||||
}
|
||||
minY = Math.max(0, Math.floor(minY));
|
||||
maxY = Math.min(cv.h - 1, Math.ceil(maxY));
|
||||
const n = pts.length;
|
||||
for (let y = minY; y <= maxY; y++) {
|
||||
const yc = y + 0.5;
|
||||
const xs = [];
|
||||
for (let i = 0; i < n; i++) {
|
||||
const a = pts[i];
|
||||
const b = pts[(i + 1) % n];
|
||||
const ay = a[1], by = b[1];
|
||||
if ((ay <= yc && by > yc) || (by <= yc && ay > yc)) {
|
||||
const t = (yc - ay) / (by - ay);
|
||||
xs.push(a[0] + t * (b[0] - a[0]));
|
||||
}
|
||||
}
|
||||
xs.sort((p, q) => p - q);
|
||||
for (let i = 0; i + 1 < xs.length; i += 2) {
|
||||
const xStart = Math.max(0, Math.ceil(xs[i] - 0.5));
|
||||
const xEnd = Math.min(cv.w - 1, Math.floor(xs[i + 1] - 0.5));
|
||||
for (let x = xStart; x <= xEnd; x++) setPx(cv, x, y, [rgb[0], rgb[1], rgb[2], alpha]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shield + check geometry
|
||||
// ---------------------------------------------------------------------------
|
||||
// All geometry is expressed in a normalized 0..1 box, then scaled into the
|
||||
// target region (cx,cy = center, half = half-size of the content square).
|
||||
|
||||
// Build shield outline polygon (heraldic shield: flat top, rounded shoulders
|
||||
// approximated by points, tapering to a point at the bottom).
|
||||
function shieldPolygon(cx, cy, half) {
|
||||
// Normalized shield within a square: x in [-0.5,0.5], y in [-0.6,0.6]
|
||||
// top edge slightly inset, sides curve down, bottom comes to a point.
|
||||
const W = half * 0.92; // half width
|
||||
const Htop = -half * 1.02;
|
||||
const pts = [];
|
||||
// Top edge
|
||||
pts.push([cx - W, cy + Htop]);
|
||||
pts.push([cx + W, cy + Htop]);
|
||||
// Right side going down with a gentle inward curve, then to the point.
|
||||
// Approximate the right & bottom with sampled points along a parametric path.
|
||||
const right = [
|
||||
[cx + W, cy + Htop],
|
||||
[cx + W * 0.99, cy - half * 0.35],
|
||||
[cx + W * 0.92, cy + half * 0.05],
|
||||
[cx + W * 0.74, cy + half * 0.45],
|
||||
[cx + W * 0.42, cy + half * 0.85],
|
||||
[cx + 0, cy + half * 1.12], // bottom point
|
||||
];
|
||||
const left = [
|
||||
[cx - W * 0.42, cy + half * 0.85],
|
||||
[cx - W * 0.74, cy + half * 0.45],
|
||||
[cx - W * 0.92, cy + half * 0.05],
|
||||
[cx - W * 0.99, cy - half * 0.35],
|
||||
[cx - W, cy + Htop],
|
||||
];
|
||||
// Compose: top-left -> top-right -> right path -> bottom -> left path
|
||||
const poly = [];
|
||||
poly.push([cx - W, cy + Htop]);
|
||||
poly.push([cx + W, cy + Htop]);
|
||||
for (let i = 1; i < right.length; i++) poly.push(right[i]);
|
||||
for (let i = 0; i < left.length; i++) poly.push(left[i]);
|
||||
return poly;
|
||||
}
|
||||
|
||||
// Build a bold check-mark polygon (thick stroke as a filled hexagon-ish shape).
|
||||
// Centered roughly in the shield. `s` scales overall size, `t` is stroke thickness.
|
||||
function checkPolygon(cx, cy, s, t) {
|
||||
// Check has a short arm (down-left) and a long arm (up-right).
|
||||
// Define center vertex (bottom of the V), short tip, long tip.
|
||||
const vx = cx - s * 0.10, vy = cy + s * 0.34; // inner corner (bottom)
|
||||
const s1x = cx - s * 0.46, s1y = cy + s * 0.02; // short arm tip
|
||||
const l1x = cx + s * 0.50, l1y = cy - s * 0.42; // long arm tip
|
||||
// Build a thick stroke by offsetting perpendicular to each segment.
|
||||
// Segment A: short tip -> inner corner
|
||||
// Segment B: inner corner -> long tip
|
||||
const half = t / 2;
|
||||
|
||||
function unit(ax, ay, bx, by) {
|
||||
const dx = bx - ax, dy = by - ay;
|
||||
const L = Math.hypot(dx, dy) || 1;
|
||||
return [dx / L, dy / L];
|
||||
}
|
||||
function perp(u) { return [-u[1], u[0]]; }
|
||||
|
||||
const uA = unit(s1x, s1y, vx, vy);
|
||||
const uB = unit(vx, vy, l1x, l1y);
|
||||
const pA = perp(uA);
|
||||
const pB = perp(uB);
|
||||
|
||||
// Outer & inner offsets for each arm; assemble as a single polygon outline.
|
||||
// Order: short tip outer -> corner outer -> long tip outer -> long tip inner
|
||||
// -> corner inner -> short tip inner
|
||||
const poly = [
|
||||
[s1x + pA[0] * half, s1y + pA[1] * half], // short tip outer
|
||||
[vx + pA[0] * half, vy + pA[1] * half], // corner outer (A side)
|
||||
[vx + pB[0] * half, vy + pB[1] * half], // corner outer (B side)
|
||||
[l1x + pB[0] * half, l1y + pB[1] * half], // long tip outer
|
||||
[l1x - pB[0] * half, l1y - pB[1] * half], // long tip inner
|
||||
[vx - pB[0] * half, vy - pB[1] * half], // corner inner (B side)
|
||||
[vx - pA[0] * half, vy - pA[1] * half], // corner inner (A side)
|
||||
[s1x - pA[0] * half, s1y - pA[1] * half], // short tip inner
|
||||
];
|
||||
return poly;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Draw the NIS2 emblem (shield + check) into a canvas region.
|
||||
// cx,cy: center; half: half content size; simplified: bolder geometry for tiny sizes
|
||||
// ---------------------------------------------------------------------------
|
||||
function drawEmblem(cv, cx, cy, half, simplified = false) {
|
||||
// White shield
|
||||
const shield = shieldPolygon(cx, cy, half);
|
||||
fillPolygon(cv, shield, WHITE, 255);
|
||||
|
||||
// Negative blue check inside the shield.
|
||||
const checkScale = half * (simplified ? 1.18 : 1.05);
|
||||
const stroke = half * (simplified ? 0.32 : 0.24);
|
||||
const check = checkPolygon(cx, cy - half * 0.02, checkScale, stroke);
|
||||
fillPolygon(cv, check, BLUE, 255);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Box downsample (supersampled -> final). factor must divide dimensions.
|
||||
// ---------------------------------------------------------------------------
|
||||
function downsample(cv, factor) {
|
||||
const W = cv.w / factor;
|
||||
const H = cv.h / factor;
|
||||
const out = makeCanvas(W, H, null);
|
||||
const f2 = factor * factor;
|
||||
for (let y = 0; y < H; y++) {
|
||||
for (let x = 0; x < W; x++) {
|
||||
let r = 0, g = 0, b = 0, a = 0;
|
||||
for (let sy = 0; sy < factor; sy++) {
|
||||
for (let sx = 0; sx < factor; sx++) {
|
||||
const o = ((y * factor + sy) * cv.w + (x * factor + sx)) * 4;
|
||||
const sa = cv.buf[o + 3];
|
||||
// premultiply for correct edge blending
|
||||
r += cv.buf[o] * sa;
|
||||
g += cv.buf[o + 1] * sa;
|
||||
b += cv.buf[o + 2] * sa;
|
||||
a += sa;
|
||||
}
|
||||
}
|
||||
const oo = (y * W + x) * 4;
|
||||
if (a === 0) {
|
||||
out.buf[oo] = 0; out.buf[oo + 1] = 0; out.buf[oo + 2] = 0; out.buf[oo + 3] = 0;
|
||||
} else {
|
||||
out.buf[oo] = Math.round(r / a);
|
||||
out.buf[oo + 1] = Math.round(g / a);
|
||||
out.buf[oo + 2] = Math.round(b / a);
|
||||
out.buf[oo + 3] = Math.round(a / f2);
|
||||
}
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// PNG encoding (manual, 8-bit RGBA, color type 6)
|
||||
// ---------------------------------------------------------------------------
|
||||
const PNG_SIG = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
||||
|
||||
// CRC32 (standard polynomial 0xEDB88320)
|
||||
const CRC_TABLE = (() => {
|
||||
const t = new Uint32Array(256);
|
||||
for (let n = 0; n < 256; n++) {
|
||||
let c = n;
|
||||
for (let k = 0; k < 8; k++) {
|
||||
c = (c & 1) ? (0xedb88320 ^ (c >>> 1)) : (c >>> 1);
|
||||
}
|
||||
t[n] = c >>> 0;
|
||||
}
|
||||
return t;
|
||||
})();
|
||||
|
||||
function crc32(buf) {
|
||||
let c = 0xffffffff;
|
||||
for (let i = 0; i < buf.length; i++) {
|
||||
c = CRC_TABLE[(c ^ buf[i]) & 0xff] ^ (c >>> 8);
|
||||
}
|
||||
return (c ^ 0xffffffff) >>> 0;
|
||||
}
|
||||
|
||||
function chunk(type, data) {
|
||||
const len = Buffer.alloc(4);
|
||||
len.writeUInt32BE(data.length, 0);
|
||||
const typeBuf = Buffer.from(type, 'ascii');
|
||||
const body = Buffer.concat([typeBuf, data]);
|
||||
const crc = Buffer.alloc(4);
|
||||
crc.writeUInt32BE(crc32(body), 0);
|
||||
return Buffer.concat([len, body, crc]);
|
||||
}
|
||||
|
||||
function encodePNG(cv) {
|
||||
const { w, h, buf } = cv;
|
||||
// IHDR
|
||||
const ihdr = Buffer.alloc(13);
|
||||
ihdr.writeUInt32BE(w, 0);
|
||||
ihdr.writeUInt32BE(h, 4);
|
||||
ihdr[8] = 8; // bit depth
|
||||
ihdr[9] = 6; // color type RGBA
|
||||
ihdr[10] = 0; // compression
|
||||
ihdr[11] = 0; // filter
|
||||
ihdr[12] = 0; // interlace
|
||||
|
||||
// Filtered raw data: each scanline prefixed with filter byte 0
|
||||
const stride = w * 4;
|
||||
const raw = Buffer.alloc((stride + 1) * h);
|
||||
for (let y = 0; y < h; y++) {
|
||||
raw[y * (stride + 1)] = 0; // filter: none
|
||||
buf.subarray(y * stride, y * stride + stride)
|
||||
.forEach ? null : null; // (Uint8Array subarray copy below)
|
||||
const srcStart = y * stride;
|
||||
raw.set(buf.subarray(srcStart, srcStart + stride), y * (stride + 1) + 1);
|
||||
}
|
||||
const idat = zlib.deflateSync(raw, { level: 9 });
|
||||
|
||||
return Buffer.concat([
|
||||
PNG_SIG,
|
||||
chunk('IHDR', ihdr),
|
||||
chunk('IDAT', idat),
|
||||
chunk('IEND', Buffer.alloc(0)),
|
||||
]);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Icon builders
|
||||
// ---------------------------------------------------------------------------
|
||||
const SS = 4; // supersampling factor
|
||||
|
||||
// Standard (rounded tile) icon: transparent corners, rounded blue tile,
|
||||
// margin ~8%, centered emblem.
|
||||
function buildStandard(size, simplified = false) {
|
||||
const W = size * SS;
|
||||
const cv = makeCanvas(W, W, null); // transparent background
|
||||
const margin = W * 0.08;
|
||||
const radius = W * 0.18;
|
||||
fillRoundedRect(cv, margin, margin, W - margin, W - margin, radius, BLUE, 255);
|
||||
const cx = W / 2, cy = W / 2;
|
||||
// emblem content sized to ~ inner tile
|
||||
const half = (W - 2 * margin) * 0.30;
|
||||
drawEmblem(cv, cx, cy, half, simplified);
|
||||
return downsample(cv, SS);
|
||||
}
|
||||
|
||||
// Maskable icon: FULL-BLEED blue, square corners, emblem inside the central
|
||||
// 80% safe-zone.
|
||||
function buildMaskable(size) {
|
||||
const W = size * SS;
|
||||
const cv = makeCanvas(W, W, null);
|
||||
fillBackground(cv, BLUE); // full bleed, opaque
|
||||
const cx = W / 2, cy = W / 2;
|
||||
// safe zone is central 80% => content sized so it stays inside 0.8*W box
|
||||
const half = W * 0.5 * 0.80 * 0.62; // ~31% of half-canvas
|
||||
drawEmblem(cv, cx, cy, half, false);
|
||||
return downsample(cv, SS);
|
||||
}
|
||||
|
||||
// Apple touch icon: fully OPAQUE blue background, square corners, emblem.
|
||||
function buildAppleTouch(size) {
|
||||
const W = size * SS;
|
||||
const cv = makeCanvas(W, W, null);
|
||||
fillBackground(cv, BLUE); // opaque, no transparency
|
||||
const cx = W / 2, cy = W / 2;
|
||||
const half = W * 0.30;
|
||||
drawEmblem(cv, cx, cy, half, false);
|
||||
return downsample(cv, SS);
|
||||
}
|
||||
|
||||
// Favicon: rounded blue tile + bolder simplified emblem for legibility.
|
||||
function buildFavicon(size) {
|
||||
const W = size * SS;
|
||||
const cv = makeCanvas(W, W, null);
|
||||
const margin = W * 0.04;
|
||||
const radius = W * 0.18;
|
||||
fillRoundedRect(cv, margin, margin, W - margin, W - margin, radius, BLUE, 255);
|
||||
const cx = W / 2, cy = W / 2;
|
||||
const half = W * 0.34;
|
||||
drawEmblem(cv, cx, cy, half, true); // simplified/bolder
|
||||
return downsample(cv, SS);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SVG master (same design, vector)
|
||||
// ---------------------------------------------------------------------------
|
||||
function buildSVG() {
|
||||
// Express the same geometry in a 512 viewBox.
|
||||
const cx = 256, cy = 256, half = 512 * 0.30; // 153.6
|
||||
const shield = shieldPolygon(cx, cy, half)
|
||||
.map(p => `${p[0].toFixed(1)},${p[1].toFixed(1)}`).join(' ');
|
||||
const check = checkPolygon(cx, cy - half * 0.02, half * 1.05, half * 0.24)
|
||||
.map(p => `${p[0].toFixed(1)},${p[1].toFixed(1)}`).join(' ');
|
||||
const margin = 512 * 0.08;
|
||||
const r = 512 * 0.18;
|
||||
return `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512" role="img" aria-label="NIS2 Agile">
|
||||
<title>NIS2 Agile</title>
|
||||
<!-- Italy-blue rounded tile -->
|
||||
<rect x="${margin}" y="${margin}" width="${512 - 2 * margin}" height="${512 - 2 * margin}" rx="${r}" ry="${r}" fill="#0066CC"/>
|
||||
<!-- White shield -->
|
||||
<polygon points="${shield}" fill="#FFFFFF"/>
|
||||
<!-- Negative blue check -->
|
||||
<polygon points="${check}" fill="#0066CC"/>
|
||||
</svg>
|
||||
`;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main
|
||||
// ---------------------------------------------------------------------------
|
||||
function main() {
|
||||
fs.mkdirSync(ICONS_DIR, { recursive: true });
|
||||
|
||||
const outputs = [];
|
||||
|
||||
function write(name, cv) {
|
||||
const png = encodePNG(cv);
|
||||
const fp = path.join(ICONS_DIR, name);
|
||||
fs.writeFileSync(fp, png);
|
||||
outputs.push({ name, bytes: png.length, w: cv.w, h: cv.h });
|
||||
}
|
||||
|
||||
write('icon-192.png', buildStandard(192));
|
||||
write('icon-512.png', buildStandard(512));
|
||||
write('icon-192-maskable.png', buildMaskable(192));
|
||||
write('icon-512-maskable.png', buildMaskable(512));
|
||||
write('apple-touch-icon.png', buildAppleTouch(180));
|
||||
write('favicon-32.png', buildFavicon(32));
|
||||
write('favicon-16.png', buildFavicon(16));
|
||||
|
||||
// SVG master
|
||||
const svg = buildSVG();
|
||||
fs.writeFileSync(path.join(ICONS_DIR, 'icon.svg'), svg);
|
||||
|
||||
// ---- Validation: re-read each PNG, verify signature + IHDR dims ----
|
||||
console.log('\n=== NIS2 PWA Icon Generation ===');
|
||||
console.log('Output dir:', ICONS_DIR);
|
||||
console.log('\nFile | Bytes | Declared | Read(IHDR) | SIG');
|
||||
console.log('---------------------------|--------|----------|------------|----');
|
||||
|
||||
let allOk = true;
|
||||
for (const o of outputs) {
|
||||
const data = fs.readFileSync(path.join(ICONS_DIR, o.name));
|
||||
const sigOk =
|
||||
data.length >= 8 &&
|
||||
data[0] === 0x89 && data[1] === 0x50 && data[2] === 0x4e && data[3] === 0x47 &&
|
||||
data[4] === 0x0d && data[5] === 0x0a && data[6] === 0x1a && data[7] === 0x0a;
|
||||
// IHDR starts at byte 16 (8 sig + 4 len + 4 'IHDR'); width@16, height@20
|
||||
const rw = data.readUInt32BE(16);
|
||||
const rh = data.readUInt32BE(20);
|
||||
const nonEmpty = data.length > 0;
|
||||
const ok = sigOk && nonEmpty && rw === o.w && rh === o.h;
|
||||
if (!ok) allOk = false;
|
||||
console.log(
|
||||
`${o.name.padEnd(26)} | ${String(o.bytes).padStart(6)} | ${(o.w + 'x' + o.h).padStart(8)} | ${(rw + 'x' + rh).padStart(10)} | ${ok ? 'OK' : 'KO'}`
|
||||
);
|
||||
}
|
||||
|
||||
// SVG report
|
||||
const svgStat = fs.statSync(path.join(ICONS_DIR, 'icon.svg'));
|
||||
console.log(`${'icon.svg'.padEnd(26)} | ${String(svgStat.size).padStart(6)} | ${'vector'.padStart(8)} | ${'-'.padStart(10)} | ${svgStat.size > 0 ? 'OK' : 'KO'}`);
|
||||
|
||||
console.log('\nValidation:', allOk ? 'ALL PNG OK' : 'FAILURES PRESENT');
|
||||
if (!allOk) process.exitCode = 1;
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -0,0 +1,86 @@
|
||||
#!/usr/bin/env node
|
||||
/*
|
||||
* Inietta i tag PWA negli <head> di tutte le pagine HTML sotto public/.
|
||||
* Idempotente: salta i file che contengono gia' il marker <!-- PWA:start -->.
|
||||
* Normalizza il viewport mantenendo lo zoom abilitato (WCAG 1.4.4 / AGID).
|
||||
* Esegue solo modifiche additive e mirate; tutto e' git-tracked => reversibile.
|
||||
*
|
||||
* Uso: node scripts/inject-pwa-head.mjs
|
||||
*/
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
const ROOT = path.resolve(process.cwd(), 'public');
|
||||
const EXCLUDE_BASENAMES = new Set(['offline.html']);
|
||||
const MARKER = '<!-- PWA:start -->';
|
||||
const VIEWPORT = '<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">';
|
||||
|
||||
const BLOCK = [
|
||||
' <!-- PWA:start -->',
|
||||
' <link rel="manifest" href="/manifest.webmanifest">',
|
||||
' <meta name="theme-color" content="#0066CC">',
|
||||
' <link rel="icon" type="image/png" sizes="32x32" href="/assets/icons/favicon-32.png">',
|
||||
' <link rel="icon" type="image/png" sizes="16x16" href="/assets/icons/favicon-16.png">',
|
||||
' <link rel="apple-touch-icon" sizes="180x180" href="/assets/icons/apple-touch-icon.png">',
|
||||
' <meta name="apple-mobile-web-app-capable" content="yes">',
|
||||
' <meta name="mobile-web-app-capable" content="yes">',
|
||||
' <meta name="apple-mobile-web-app-status-bar-style" content="default">',
|
||||
' <meta name="apple-mobile-web-app-title" content="NIS2 Agile">',
|
||||
' <meta name="application-name" content="NIS2 Agile">',
|
||||
' <script src="/js/pwa.js?v=20260614" defer></script>',
|
||||
' <!-- PWA:end -->'
|
||||
].join('\n');
|
||||
|
||||
function walk(dir) {
|
||||
let out = [];
|
||||
for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||
const full = path.join(dir, e.name);
|
||||
if (e.isDirectory()) out = out.concat(walk(full));
|
||||
else if (e.isFile() && e.name.toLowerCase().endsWith('.html')) out.push(full);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const files = walk(ROOT).filter((f) => {
|
||||
const b = path.basename(f);
|
||||
if (EXCLUDE_BASENAMES.has(b)) return false;
|
||||
if (/copy/i.test(b)) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
let modified = 0, already = 0, nohead = 0, viewportFixed = 0;
|
||||
const anomalies = [];
|
||||
|
||||
for (const f of files) {
|
||||
let html = fs.readFileSync(f, 'utf8');
|
||||
if (html.includes(MARKER)) { already++; continue; }
|
||||
|
||||
if (!/<\/head>/i.test(html)) {
|
||||
nohead++;
|
||||
anomalies.push('NO </head>: ' + path.relative(process.cwd(), f));
|
||||
continue;
|
||||
}
|
||||
|
||||
// Normalizza il viewport (zoom SEMPRE consentito: no user-scalable=no / maximum-scale)
|
||||
const vp = html.match(/<meta\s+name=["']viewport["'][^>]*>/i);
|
||||
if (vp) {
|
||||
if (!/viewport-fit=cover/i.test(vp[0])) {
|
||||
html = html.replace(vp[0], VIEWPORT);
|
||||
viewportFixed++;
|
||||
}
|
||||
}
|
||||
|
||||
const inject = (vp ? '' : (' ' + VIEWPORT + '\n')) + BLOCK + '\n';
|
||||
// replace con funzione per evitare interpretazione di pattern '$' nel testo iniettato
|
||||
html = html.replace(/<\/head>/i, () => inject + '</head>');
|
||||
fs.writeFileSync(f, html);
|
||||
modified++;
|
||||
}
|
||||
|
||||
console.log('HTML trovati:', files.length);
|
||||
console.log('Modificati:', modified, '| gia col marker:', already,
|
||||
'| senza </head>:', nohead, '| viewport normalizzati:', viewportFixed);
|
||||
if (anomalies.length) {
|
||||
console.log('Anomalie:');
|
||||
anomalies.forEach((a) => console.log(' -', a));
|
||||
}
|
||||
Reference in New Issue
Block a user