Skip to content
Open
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: 4 additions & 0 deletions docs/changelog.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
# Changelog

## 2026-11-01 — Live DevCon26 programme

- Show the saved EMS programme and linked speakers on the DevCon26 page, including distinct empty and unavailable states. Route website-owned Cloudflare previews through a bounded public programme relay.

## 2026-10-31 — Mobile DevCon26 hero

- Keep the theme on two centred lines with a compact centred tagline, tighter introduction spacing and a centred content-sized ticket button on mobile. Centre the date and balance the wrapped venue text beneath it. Preserve the exact approved wording and desktop layout.
Expand Down
4 changes: 4 additions & 0 deletions docs/devcon26-homepage.md
Original file line number Diff line number Diff line change
Expand Up @@ -86,3 +86,7 @@ The navbar logo links to `/devcon26/` on all DevCon26 routes, and to `/` elsewhe
The conference landing page places “Back to DevCongress” beside the hero conference label and year, wrapping on narrow screens. The return link appears only on `/devcon26/`, not its sub-routes. Shared header dividers use a light single-pixel rule.

The hero retains its wide top image and two smaller images beneath. Its rotation includes the original sixteen photos plus six selected April/May photos; three initial images load, with subsequent images prepared one at a time. Original source files remain untouched.

## Live programme (2026-11-01)

The Inside the day section loads the saved public programme from EMS in the browser using `GET /api/public/annual-conference/2026/programme`, so saves appear on the next page load without rebuilding the static site. `PUBLIC_DEVCON26_API_ORIGIN` selects the EMS origin; the production default is `https://em.devcongress.org`. A different origin must also be allowed by the site CSP and EMS CORS policy. Requests omit credentials and bypass the browser cache. Website-owned Cloudflare preview hosts use a same-origin, GET-only Worker relay to the fixed public EMS programme endpoint; production and other hosts request EMS directly. The relay forwards no client headers or query, rejects redirects and limits the response to 1 MiB. Rows show their time, title, overview and any speaker names and photos. The speakers section uses the same response, listing each linked speaker once by public speaker ID, with separate empty and unavailable messages. Broken photos are removed while names remain visible. Empty programmes and unavailable or invalid responses have distinct messages; without JavaScript the page explains how to view the programme. The public API controls which fields and rows are exposed.
Binary file added docs/screenshots/devcon26/programme-desktop.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/screenshots/devcon26/programme-mobile.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
"deploy:dry-run": "pnpm build && wrangler deploy --dry-run",
"astro": "astro",
"check:donation": "node scripts/check-donation-checkout.mjs",
"check:tickets": "node scripts/check-devcon26-managed-checkout.mjs && node scripts/check-community-tickets.mjs && node scripts/check-devcon26-payment-confirmation.mjs && node scripts/check-payment-receipts.mjs && node scripts/check-devcon26-badge.mjs && node scripts/check-devcon26-checkout.mjs"
"check:tickets": "node scripts/check-devcon26-managed-checkout.mjs && node scripts/check-devcon26-programme.mjs && node scripts/check-devcon26-programme-relay.mjs && node scripts/check-community-tickets.mjs && node scripts/check-devcon26-payment-confirmation.mjs && node scripts/check-payment-receipts.mjs && node scripts/check-devcon26-badge.mjs && node scripts/check-devcon26-checkout.mjs"
},
"dependencies": {
"@inttegro/js": "0.7.2",
Expand Down
66 changes: 66 additions & 0 deletions scripts/check-devcon26-programme-relay.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import assert from 'node:assert/strict';
import { createRequire } from 'node:module';
import vm from 'node:vm';

const require = createRequire(import.meta.url);
const astroRequire = createRequire(require.resolve('astro/package.json'));
const { build } = astroRequire('esbuild');
const bundle = await build({
entryPoints: [new URL('../src/worker.ts', import.meta.url).pathname],
bundle: true,
format: 'cjs',
platform: 'browser',
write: false,
});
const worker = { exports: {} };
let upstreamRequest;
let upstream = Response.json({ year: 2026, rows: [] });

vm.runInNewContext(bundle.outputFiles[0].text, {
module: worker,
exports: worker.exports,
URL,
Request,
Response,
Headers,
AbortSignal,
Uint8Array,
console,
fetch: async (url, options) => {
upstreamRequest = { url, options };

return upstream;
},
});

const preview = 'https://feature-devcon26-live-programme-devcongress-website.admins-a7d.workers.dev';
const path = '/__public/devcon26/programme';
const env = { ASSETS: { fetch: async () => new Response('asset') } };
const run = (url, options) => worker.exports.default.fetch(new Request(url, options), env, {});
const response = await run(preview + path + '?url=https://evil.example', {
headers: { Cookie: 'private=secret', Authorization: 'Bearer secret' },
});

assert.equal(response.status, 200);
assert.deepEqual(await response.json(), { year: 2026, rows: [] });
assert.equal(response.headers.get('Cache-Control'), 'no-store');
assert.equal(upstreamRequest.url, 'https://em.devcongress.org/api/public/annual-conference/2026/programme');
assert.equal(upstreamRequest.options.method, 'GET');
assert.equal(upstreamRequest.options.redirect, 'manual');
assert.deepEqual(Object.keys(upstreamRequest.options.headers), ['Accept']);
assert.equal((await run(preview + path, { method: 'POST' })).status, 405);
assert.equal((await run('https://devcongress.org' + path)).status, 404);
assert.equal((await run('https://evil-devcongress-website.admins-a7d.workers.dev.evil.example' + path)).status, 404);
assert.equal(await (await run(preview + '/other')).text(), 'asset');

for (const failed of [
new Response(null, { status: 302, headers: { Location: 'https://evil.example' } }),
new Response('bad', { status: 500 }),
new Response('not json', { headers: { 'Content-Type': 'text/html' } }),
new Response(new Uint8Array(1024 * 1024 + 1), { headers: { 'Content-Type': 'application/json' } }),
]) {
upstream = failed;
assert.equal((await run(preview + path)).status, 503);
}

console.log('PASS programme relay: exact preview routing, fixed upstream, request isolation, failure and size bounds.');
162 changes: 162 additions & 0 deletions scripts/check-devcon26-programme.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
import assert from 'node:assert/strict';
import { createRequire } from 'node:module';
import vm from 'node:vm';

const require = createRequire(import.meta.url);
const astroRequire = createRequire(require.resolve('astro/package.json'));
const { build } = astroRequire('esbuild');
const bundle = await build({
entryPoints: [new URL('../src/lib/devcon26-programme.ts', import.meta.url).pathname],
bundle: true,
format: 'cjs',
platform: 'browser',
write: false,
define: { 'import.meta.env.PUBLIC_DEVCON26_API_ORIGIN': '"https://em.devcongress.org"' },
});

class Element {
constructor(tag) {
this.tag = tag;
this.children = [];
this.attributes = {};
this.textContent = '';
}

append(...children) {
this.children.push(...children);
}

replaceChildren(...children) {
this.children = children;
}

setAttribute(key, value) {
this.attributes[key] = value;
}

querySelector(selector) {
return selector.includes('status') ? this.status : this.list;
}

addEventListener(_name, callback) {
this.onError = callback;
}

remove() {
this.removed = true;
}

}

function createRoot() {
const root = new Element('root');

root.status = new Element('status');
root.list = new Element('list');

return root;
}

const data = {
year: 2026,
rows: [
{
id: 'panel', time: '9:00', title: '<b>Talk</b>', description: 'Overview', type: 'panel',
speakers: [
{ id: 'ada', name: 'Ada', photoUrl: 'https://example.org/ada.png' },
{ id: 'ben-1', name: 'Ben', photoUrl: null },
{ id: 'ben-2', name: 'Ben', photoUrl: null },
{ id: 'ada', name: 'Ada Variant', photoUrl: null },
],
},
{ id: 'break', time: '10:00', title: 'Break', description: '', type: 'break', speakers: [] },
],
};

let response = { ok: true, json: async () => data };
let request;
const module = { exports: {} };

vm.runInNewContext(bundle.outputFiles[0].text, {
module,
exports: module.exports,
URL,
AbortSignal,
window: { location: { origin: 'https://devcongress.org' } },
document: {
createElement: tag => new Element(tag),
createDocumentFragment: () => new Element('fragment'),
},
fetch: async (url, options) => {
request = { url, options };

return response;
},
});

const { initializeDevcon26Programme } = module.exports;
const programme = createRoot();
const speakers = createRoot();

await initializeDevcon26Programme(programme, speakers);

const rows = programme.list.children[0].children;
const directory = speakers.list.children[0].children;

assert.equal(rows.length, 2);
assert.equal(rows[0].children[1].children[0].textContent, '<b>Talk</b>');
assert.equal(rows[0].children[1].children[2].children.length, 4);
assert.equal(rows[1].children[1].children.length, 1);
assert.deepEqual(directory.map(item => item.children.at(-1).textContent), ['Ada', 'Ben', 'Ben']);
assert.equal(request.url.href, 'https://em.devcongress.org/api/public/annual-conference/2026/programme');
assert.equal(request.options.credentials, 'omit');
assert.equal(request.options.cache, 'no-store');
assert.equal(programme.status.hidden, true);
assert.equal(programme.attributes['aria-busy'], 'false');

directory[0].children[0].onError();
assert.equal(directory[0].children[0].removed, true);
assert.equal(directory[0].children[1].textContent, 'Ada');

response = { ok: true, json: async () => ({ year: 2026, rows: [] }) };
const emptyProgramme = createRoot();
const emptySpeakers = createRoot();

await initializeDevcon26Programme(emptyProgramme, emptySpeakers);
assert.match(emptyProgramme.status.textContent, /announced/);
assert.match(emptySpeakers.status.textContent, /announced/);

for (const failedResponse of [
{ ok: false },
{ ok: true, json: async () => ({ year: 2026, rows: 'bad' }) },
]) {
response = failedResponse;
const failedProgramme = createRoot();
const failedSpeakers = createRoot();

await initializeDevcon26Programme(failedProgramme, failedSpeakers);
assert.match(failedProgramme.status.textContent, /could not be loaded/);
assert.match(failedSpeakers.status.textContent, /could not be loaded/);
}

const previewModule = { exports: {} };
let previewRequest;

vm.runInNewContext(bundle.outputFiles[0].text, {
module: previewModule,
exports: previewModule.exports,
URL,
AbortSignal,
window: { location: { origin: 'https://feature-devcon26-live-programme-devcongress-website.admins-a7d.workers.dev' } },
document: { createElement: tag => new Element(tag), createDocumentFragment: () => new Element('fragment') },
fetch: async url => {
previewRequest = url;

return { ok: true, json: async () => ({ year: 2026, rows: [] }) };
},
});

await previewModule.exports.initializeDevcon26Programme(createRoot(), createRoot());
assert.equal(previewRequest.href, 'https://feature-devcon26-live-programme-devcongress-website.admins-a7d.workers.dev/__public/devcon26/programme');

console.log('PASS programme: speaker identity, equal names, panel and break rows, safe text, broken photos, empty and failure states.');
Loading
Loading