Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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
Expand Down
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,5 @@ nul
.wrangler/
.dev.vars*
.cache/school-pages/

public/landing/optimized/
16 changes: 14 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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 `<picture>` 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,
Expand Down
4 changes: 4 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
30 changes: 30 additions & 0 deletions scripts/optimize-landing-images.mjs
Original file line number Diff line number Diff line change
@@ -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).`);
}
29 changes: 29 additions & 0 deletions scripts/optimize-landing-images.test.mjs
Original file line number Diff line number Diff line change
@@ -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 });
}
});
10 changes: 10 additions & 0 deletions src/components/landing/LandingImage.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import React from 'react';

export default function LandingImage({ src, ...props }: React.ImgHTMLAttributes<HTMLImageElement> & { src: string }) {
return (
<picture>
<source type="image/webp" srcSet={src.replace('/landing/', '/landing/optimized/').replace(/\.png$/, '.webp')} />
<img src={src} {...props} />
</picture>
);
}
7 changes: 4 additions & 3 deletions src/components/landing/Landingpage.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -58,7 +59,7 @@ const FeatureRow: React.FC<{
<div className="flex-1 w-full">
<div className="w-full aspect-[4/3] rounded-3xl bg-[#f5f5f2] dark:bg-surface-900 border border-black/[0.03] dark:border-white/[0.07] flex items-center justify-center overflow-hidden">
{imagePath ? (
<div className="w-full h-full select-none"><img src={imagePath} alt={title} className="w-full h-full object-cover" /></div>
<div className="w-full h-full select-none"><LandingImage src={imagePath} loading="lazy" decoding="async" alt={title} className="w-full h-full object-cover" /></div>
) : children}
</div>
</div>
Expand Down Expand Up @@ -350,14 +351,14 @@ const Landingpage: React.FC<LandingpageProps> = ({ 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)]"
>
<div className="absolute inset-0" style={{ clipPath: `inset(0 ${100 - sliderPos}% 0 0)` }}>
<img
<LandingImage
src="/landing/lanis-dashboard.png"
alt="Lanis UI Dashboard"
className="absolute inset-0 w-full h-full object-cover"
/>
</div>
<div className="absolute inset-0" style={{ clipPath: `inset(0 0 0 ${sliderPos}%)` }}>
<img
<LandingImage
src="/landing/sph-dashboard.png"
alt="Schulportal Hessen Dashboard"
className="absolute inset-0 w-full h-full object-cover"
Expand Down
Loading