diff --git a/scripts/test-pages.mjs b/scripts/test-pages.mjs index ef010507..97b47036 100644 --- a/scripts/test-pages.mjs +++ b/scripts/test-pages.mjs @@ -117,6 +117,20 @@ function parseSitemap(xml) { // File-extension pattern — used to distinguish real assets from nav links. const HAS_EXT = /\.[a-z0-9]{1,6}$/i; +// Some hrefs come from feeds we do not control (TeSS on /training) and get baked +// into static HTML at build time, so a `javascript:` URL would ship and stay +// shipped. Anything outside this set is a defect, not a style preference. +const SAFE_SCHEMES = new Set(['http', 'https', 'mailto', 'tel']); +const SCHEMED_HREF = /(?:href|src)="([a-zA-Z][a-zA-Z0-9+.-]*):/g; + +function extractUnsafeSchemes(html) { + const found = new Set(); + for (const [, scheme] of html.matchAll(SCHEMED_HREF)) { + if (!SAFE_SCHEMES.has(scheme.toLowerCase())) found.add(scheme.toLowerCase()); + } + return [...found]; +} + const ASSET_ATTRS = [ // src attributes (scripts, images — always files) /\bsrc=["']([^"']+)["']/g, @@ -195,6 +209,7 @@ async function main() { const pageErrors = []; // { url, status, error } const ssrPages = []; // SSR pages in sitemap (no static HTML file) const assetErrors = []; // { asset, foundOn, status, error } + const schemeErrors = []; // { url, scheme } const assetCache = new Map(); // url → status // Parse sitemap @@ -242,6 +257,10 @@ async function main() { pageErrors.push({ url: siteUrl, status: null, error: err.message }); } + for (const scheme of pageHtml ? extractUnsafeSchemes(pageHtml) : []) { + schemeErrors.push({ url: siteUrl, scheme }); + } + // Check assets found in page HTML if (pageHtml) { const assets = extractLocalAssets(pageHtml, localUrl); @@ -277,6 +296,7 @@ async function main() { console.log(' ┌─────────────────────────────────────────────┐'); console.log(` │ Pages ${String(pageOk).padStart(4)}/${String(staticTotal).padEnd(4)} ok ${pageErrors.length > 0 ? c.red(`${pageErrors.length} failed`) : c.green('all passed')} │`); console.log(` │ Assets ${String(assetOk).padStart(4)}/${String(totalAssets).padEnd(4)} ok ${assetErrors.length > 0 ? c.red(`${assetErrors.length} failed`) : c.green('all passed')} │`); + console.log(` │ Links ${schemeErrors.length > 0 ? c.red(`${String(schemeErrors.length).padStart(4)} unsafe scheme(s)`) : c.green(' 0 unsafe schemes ')} │`); if (ssrPages.length > 0) console.log(` │ SSR ${String(ssrPages.length).padStart(4)} pages skipped (no static HTML) │`); console.log(' └─────────────────────────────────────────────┘\n'); @@ -290,6 +310,14 @@ async function main() { console.log(); } + if (schemeErrors.length > 0) { + console.log(c.red(c.bold(' Unsafe URL schemes:'))); + for (const { url, scheme } of schemeErrors) { + console.log(` ${c.red(scheme + ':')} ${url}`); + } + console.log(); + } + if (assetErrors.length > 0) { console.log(c.red(c.bold(' Broken assets:'))); for (const { asset, foundOn, status, error } of assetErrors) { @@ -302,7 +330,7 @@ async function main() { server.close(); - const failed = pageErrors.length + assetErrors.length; + const failed = pageErrors.length + assetErrors.length + schemeErrors.length; if (failed > 0) { console.log(c.red(c.bold(` ${failed} issue(s) found. Fix before deploying.\n`))); process.exit(1); diff --git a/src/components/tess-unavailable.astro b/src/components/tess-unavailable.astro new file mode 100644 index 00000000..cfea13ec --- /dev/null +++ b/src/components/tess-unavailable.astro @@ -0,0 +1,21 @@ +--- +import { ExclamationTriangleIcon } from "@heroicons/react/24/outline"; + +interface Props { + /** Where to send people instead, on TeSS. */ + href?: string; +} + +const { href = "https://tess.elixir-europe.org/" } = Astro.props; +--- + +
+
diff --git a/src/lib/tess.ts b/src/lib/tess.ts new file mode 100644 index 00000000..81df9606 --- /dev/null +++ b/src/lib/tess.ts @@ -0,0 +1,142 @@ +const API = "https://tess.elixir-europe.org"; + +const ATTEMPTS: number = 5; +const TIMEOUT_MS = 20_000; + +export interface TessEvent { + id: number; + title: string; + url: string; + slug?: string; + description?: string; + start?: string; + end?: string; + venue?: string; + city?: string; + country?: string; + organizer?: string; +} + +export interface TessMaterial { + id: number; + title: string; + url: string; + description?: string; + doi?: string; +} + +type Params = Record; + +/** + * `reachable` separates "TESS has nothing to list" from "TESS never answered". + * Both leave `items` empty, and rendering them the same way is what let the old + * widget show a plausible-looking empty state over a dead feed for months. + */ +export interface TessResult { + items: T[]; + reachable: boolean; +} + +function query(params: Params): string { + const q = new URLSearchParams(); + for (const [key, value] of Object.entries(params)) { + // TESS expects repeated bracketed keys for arrays: country[]=Norway + if (Array.isArray(value)) value.forEach(v => q.append(`${key}[]`, v)); + else q.append(key, String(value)); + } + return q.toString(); +} + +// Without an explicit JSON Accept header TESS serves an HTML 403 instead of the +// API response, which is what broke the browser widget this replaced: the error +// page carries no CORS headers, so the failure surfaced as a CORS violation. +// TESS also returns 5xx intermittently, hence the retries. +async function fetchList(path: string, params: Params): Promise> { + const url = `${API}/${path}?${query(params)}`; + + for (let attempt = 1; attempt <= ATTEMPTS; attempt++) { + try { + const res = await fetch(url, { + headers: { Accept: "application/json" }, + signal: AbortSignal.timeout(TIMEOUT_MS), + }); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + + const body = await res.json(); + if (!Array.isArray(body)) throw new Error("expected a JSON array"); + return { items: body as T[], reachable: true }; + } catch (err) { + const reason = err instanceof Error ? err.message : String(err); + if (attempt === ATTEMPTS) { + const tries = ATTEMPTS === 1 ? "1 attempt" : `${ATTEMPTS} attempts`; + console.warn(`[tess] ${path} failed after ${tries} (${reason}); the section will say so`); + return { items: [], reachable: false }; + } + await new Promise(r => setTimeout(r, attempt * 1500)); + } + } + return { items: [], reachable: false }; +} + +export const upcomingEvents = (pageSize = 5) => + fetchList("events", { page_size: pageSize, country: ["Norway"] }); + +export const pastEvents = (pageSize = 10) => + fetchList("events", { + page_size: pageSize, + sort: "new", + country: ["Norway"], + include_expired: true, + include_disabled: false, + }); + +export const materials = (pageSize = 10) => + fetchList("materials", { page_size: pageSize, node: ["Norway"] }); + +// These URLs come from a feed we do not control and get baked into static HTML, +// so a bad one would stay on the page until the next build. Anything that is not +// http(s), a `javascript:` URL being the case that matters, is dropped for a +// TESS address we build ourselves. +function safeUrl(raw: string | undefined, fallback: string): string { + if (!raw) return fallback; + try { + const { protocol } = new URL(raw); + return protocol === "http:" || protocol === "https:" ? raw : fallback; + } catch { + return fallback; + } +} + +/** Link to the event's own registration page, falling back to its TESS entry. */ +export function eventLink(event: TessEvent): string { + const onTess = event.slug + ? `${API}/events/${encodeURIComponent(event.slug)}` + : `${API}/events`; + return safeUrl(event.url, onTess); +} + +/** Link to the material on TESS, falling back to the Norwegian listing. */ +export function materialLink(material: TessMaterial): string { + return safeUrl(material.url, `${API}/materials?node[]=Norway`); +} + +/** "12 Mar 2026", or a range when the event spans more than one day. */ +export function eventDates(event: TessEvent): string { + const fmt = (iso: string) => + new Date(iso).toLocaleDateString("en-GB", { day: "numeric", month: "short", year: "numeric" }); + + if (!event.start) return ""; + const start = fmt(event.start); + if (!event.end) return start; + + const end = fmt(event.end); + return start === end ? start : `${start} to ${end}`; +} + +// Venue is left out on purpose. TESS stores a full street address there and +// often a doubled one ("Moltke Moes vei, Moltke Moes vei"), which is noise in a +// one-line summary; the linked event page carries the address. +/** "Oslo, Norway", skipping whichever part TESS left blank. */ +export function eventPlace(event: TessEvent): string { + return [event.city, event.country].filter(Boolean).join(", "); +} diff --git a/src/pages/training/index.astro b/src/pages/training/index.astro index d165df01..10af68c7 100644 --- a/src/pages/training/index.astro +++ b/src/pages/training/index.astro @@ -10,8 +10,17 @@ import { MagnifyingGlassIcon, WrenchScrewdriverIcon, } from "@heroicons/react/24/outline"; +import Unavailable from "../../components/tess-unavailable.astro"; +import { upcomingEvents, pastEvents, materials, eventLink, materialLink, eventDates, eventPlace } from "../../lib/tess"; -const BASE = import.meta.env.BASE_URL.replace(/\/$/, ''); +// Fetched at build time rather than from the browser: TESS rejects a request +// that does not ask for JSON, and the error page it returns carries no CORS +// headers. See src/lib/tess.ts. +const [upcoming, past, trainingMaterials] = await Promise.all([ + upcomingEvents(5), + pastEvents(10), + materials(10), +]); --- - - -

Training

@@ -137,216 +143,137 @@ const BASE = import.meta.env.BASE_URL.replace(/\/$/, '');
- +
-
- - - Live - +

Upcoming training

+ + Browse all on TeSS +
-
-
- +
+ {upcoming.items.length > 0 ? ( + + ) : upcoming.reachable ? ( +

+

+ ) : ( + + )}
- +
-

Training materials

-
-
+
+

Training materials

+ + Browse all on TeSS + +
+
+ {trainingMaterials.items.length > 0 ? ( + + +
+
+ + +
+ ) : trainingMaterials.reachable ? ( +

+

+ ) : ( + + )}
- +

Past training events

-
-
+
+ {past.items.length > 0 ? ( + + ) : past.reachable ? ( +

+

+ ) : ( + + )}
- - - - -