From 04cb9b9e4f0820a6d4097c05552c96982e758ac7 Mon Sep 17 00:00:00 2001 From: Joan Code <172996447+joan-code6@users.noreply.github.com> Date: Sat, 17 Oct 2026 20:50:34 +0200 Subject: [PATCH] Optimize landing screenshots during builds and run frontend regression checks --- .github/workflows/build.yml | 4 ++-- .gitignore | 2 ++ README.md | 16 +++++++++++-- package.json | 4 ++++ scripts/optimize-landing-images.mjs | 30 ++++++++++++++++++++++++ scripts/optimize-landing-images.test.mjs | 29 +++++++++++++++++++++++ src/components/landing/LandingImage.tsx | 10 ++++++++ src/components/landing/Landingpage.tsx | 7 +++--- 8 files changed, 95 insertions(+), 7 deletions(-) create mode 100644 scripts/optimize-landing-images.mjs create mode 100644 scripts/optimize-landing-images.test.mjs create mode 100644 src/components/landing/LandingImage.tsx diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 14f2133d..788e7721 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -35,6 +35,8 @@ jobs: key: npm-frontend-${{ runner.os }}-node24-${{ github.event_name }}-${{ hashFiles('package-lock.json') }} - name: Install locked frontend dependencies run: npm ci --no-audit --no-fund + - name: Run frontend regression checks + run: npm test - name: Cache public school data and rendered markup uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4 with: @@ -50,8 +52,6 @@ jobs: env: NODE_ENV: production VITE_API_URL: https://lanis-backend.joancode.dev - - name: Check deployment security rules - run: node --test scripts/cloudflare/*.test.cjs - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 with: name: frontend-dist diff --git a/.gitignore b/.gitignore index bc010b0c..f8e51031 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,5 @@ nul .wrangler/ .dev.vars* .cache/school-pages/ + +public/landing/optimized/ diff --git a/README.md b/README.md index 000d5d0d..ad12622d 100644 --- a/README.md +++ b/README.md @@ -81,7 +81,10 @@ CHROME_BIN=/path/to/chrome STATUS_UI_URL=http://localhost:3000 node scripts/stat It checks mobile and desktop layouts and simulated service states, and writes screenshots to `/tmp/lanis-status-*.png`. It uses `puppeteer-core` with your installed Chrome; `CHROME_BIN` is required. This manual check is separate from the build and CI, which do not install or launch a browser. -There is no `npm test` script. An `npm run lint` script exists, but ESLint and its configuration are not included in this checkout, so it is not currently a ready-to-run validation step. +`npm test` runs the account-data lifecycle, timetable, lossless image conversion, +and deployment regression checks. CI runs these checks before building. +An `npm run lint` script exists, but ESLint and its configuration are not included +in this checkout, so it is not currently a ready-to-run validation step. ## Build and hosting @@ -92,7 +95,16 @@ npm run build npm run preview ``` -The build runs TypeScript checking and Vite, then npm automatically runs `postbuild`. +Before `npm run dev` and `npm run build`, npm generates lossless WebP versions +of the five landing-page screenshots with the existing Sharp dependency. +`LandingImage` uses these files through `` with the original PNG as a +fallback. Feature screenshots below the hero load lazily. PNG editing sources remain in `public/landing/`; generated files in +`public/landing/optimized/` are ignored by Git. Run `npm run images:optimize` +after changing a source image during development. Conversion errors fail the +command, and the regression check verifies every decoded pixel is preserved. +Icons and Open Graph images keep their existing formats. + +The build then runs TypeScript checking and Vite, then npm automatically runs `postbuild`. That step renders the actual public and school React components directly into `dist/` using React's server renderer. Vite loads the TSX in middleware mode; there is no HTTP listener, Chrome installation, browser automation, fixed delay, diff --git a/package.json b/package.json index 5189fada..60a75ce0 100644 --- a/package.json +++ b/package.json @@ -30,7 +30,11 @@ "wrangler": "4.151.1" }, "scripts": { + "predev": "npm run images:optimize", "dev": "vite", + "images:optimize": "node scripts/optimize-landing-images.mjs", + "prebuild": "npm run images:optimize", + "test": "node scripts/test-account-data.cjs && node scripts/test-timetable-colours.cjs && node scripts/test-timetable-exams.cjs && node scripts/test-timetable-refresh.cjs && node --test scripts/optimize-landing-images.test.mjs scripts/cloudflare/*.test.cjs", "build": "tsc && vite build", "postbuild": "node scripts/prerender.mjs", "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0", diff --git a/scripts/optimize-landing-images.mjs b/scripts/optimize-landing-images.mjs new file mode 100644 index 00000000..332d1db9 --- /dev/null +++ b/scripts/optimize-landing-images.mjs @@ -0,0 +1,30 @@ +import { mkdir, readFile, writeFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +import sharp from 'sharp'; + +// Keep the PNG originals as fallbacks and editing sources. Lossless WebP +// preserves screenshot text and colours without adding a runtime dependency. +export const landingImages = [ + 'lanis-dashboard', 'sph-dashboard', 'loading-screen', 'themes', 'navigation', +]; + +export async function optimizeLandingImages(sourceDir, outputDir) { + await mkdir(outputDir, { recursive: true }); + let originalBytes = 0; + let optimizedBytes = 0; + for (const name of landingImages) { + const input = await readFile(join(sourceDir, `${name}.png`)); + const output = await sharp(input).webp({ lossless: true, effort: 6 }).toBuffer(); + await writeFile(join(outputDir, `${name}.webp`), output); + originalBytes += input.length; + optimizedBytes += output.length; + } + return { originalBytes, optimizedBytes }; +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + const sourceDir = fileURLToPath(new URL('../public/landing/', import.meta.url)); + const { originalBytes, optimizedBytes } = await optimizeLandingImages(sourceDir, join(sourceDir, 'optimized')); + console.log(`Landing screenshots: ${(originalBytes / 1024).toFixed(0)} KiB PNG → ${(optimizedBytes / 1024).toFixed(0)} KiB lossless WebP (${(100 * (1 - optimizedBytes / originalBytes)).toFixed(1)}% smaller).`); +} diff --git a/scripts/optimize-landing-images.test.mjs b/scripts/optimize-landing-images.test.mjs new file mode 100644 index 00000000..f3f43dba --- /dev/null +++ b/scripts/optimize-landing-images.test.mjs @@ -0,0 +1,29 @@ +import assert from 'node:assert/strict'; +import { mkdtemp, readFile, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { test } from 'node:test'; +import sharp from 'sharp'; +import { landingImages, optimizeLandingImages } from './optimize-landing-images.mjs'; + +test('all landing screenshots become smaller WebP images with identical pixels', async () => { + const source = fileURLToPath(new URL('../public/landing/', import.meta.url)); + const output = await mkdtemp(join(tmpdir(), 'lanis-images-')); + try { + const totals = await optimizeLandingImages(source, output); + assert.ok(totals.optimizedBytes < totals.originalBytes); + for (const name of landingImages) { + const original = await readFile(join(source, `${name}.png`)); + const optimized = await readFile(join(output, `${name}.webp`)); + assert.ok(optimized.length < original.length, `${name} must save bytes`); + assert.equal((await sharp(optimized).metadata()).format, 'webp'); + const before = await sharp(original).ensureAlpha().raw().toBuffer({ resolveWithObject: true }); + const after = await sharp(optimized).ensureAlpha().raw().toBuffer({ resolveWithObject: true }); + assert.deepEqual(after.info, before.info, `${name} dimensions`); + assert.deepEqual(after.data, before.data, `${name} pixels`); + } + } finally { + await rm(output, { recursive: true, force: true }); + } +}); diff --git a/src/components/landing/LandingImage.tsx b/src/components/landing/LandingImage.tsx new file mode 100644 index 00000000..87c22404 --- /dev/null +++ b/src/components/landing/LandingImage.tsx @@ -0,0 +1,10 @@ +import React from 'react'; + +export default function LandingImage({ src, ...props }: React.ImgHTMLAttributes & { src: string }) { + return ( + + + + + ); +} diff --git a/src/components/landing/Landingpage.tsx b/src/components/landing/Landingpage.tsx index 1b9df857..bd535d0e 100644 --- a/src/components/landing/Landingpage.tsx +++ b/src/components/landing/Landingpage.tsx @@ -1,4 +1,5 @@ import DemoPreview from './DemoPreview'; +import LandingImage from './LandingImage'; import React, { useState, useRef, useCallback, useEffect } from 'react'; import { useNavigate, Link } from 'react-router-dom'; import SEO from '../seo/SEO'; @@ -58,7 +59,7 @@ const FeatureRow: React.FC<{
{imagePath ? ( -
{title}
+
) : children}
@@ -350,14 +351,14 @@ const Landingpage: React.FC = ({ hero, seo }) => { className="relative w-full aspect-[16/10] rounded-2xl overflow-hidden border border-black/[0.06] dark:border-white/[0.08] bg-[#f5f5f2] dark:bg-surface-900 shadow-[0_8px_40px_-12px_rgba(0,0,0,0.08)] dark:shadow-[0_12px_50px_-16px_rgba(0,0,0,0.65)]" >
- Lanis UI Dashboard
-