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
30 changes: 29 additions & 1 deletion scripts/test-pages.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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');
Expand All @@ -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) {
Expand All @@ -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);
Expand Down
21 changes: 21 additions & 0 deletions src/components/tess-unavailable.astro
Original file line number Diff line number Diff line change
@@ -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;
---

<div class="flex flex-col items-center gap-2 py-10 text-center">
<ExclamationTriangleIcon className="h-5 w-5 text-gray-500 dark:text-gray-400" aria-hidden="true" />
<p class="text-sm text-gray-600 dark:text-gray-400">
This listing could not be loaded from TeSS when the site was last built.
</p>
<a href={href} target="_blank" rel="noopener noreferrer"
class="rounded text-sm font-medium text-accent hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent">
Browse it on TeSS instead
</a>
</div>
142 changes: 142 additions & 0 deletions src/lib/tess.ts
Original file line number Diff line number Diff line change
@@ -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<string, string | number | boolean | string[]>;

/**
* `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<T> {
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<T>(path: string, params: Params): Promise<TessResult<T>> {
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<TessEvent>("events", { page_size: pageSize, country: ["Norway"] });

export const pastEvents = (pageSize = 10) =>
fetchList<TessEvent>("events", {
page_size: pageSize,
sort: "new",
country: ["Norway"],
include_expired: true,
include_disabled: false,
});

export const materials = (pageSize = 10) =>
fetchList<TessMaterial>("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(", ");
}
Loading
Loading