diff --git a/docs/changelog.md b/docs/changelog.md index 2f4d511..79978cc 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -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. diff --git a/docs/devcon26-homepage.md b/docs/devcon26-homepage.md index eea7a6c..039a057 100644 --- a/docs/devcon26-homepage.md +++ b/docs/devcon26-homepage.md @@ -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. diff --git a/docs/screenshots/devcon26/programme-desktop.webp b/docs/screenshots/devcon26/programme-desktop.webp new file mode 100644 index 0000000..224516c Binary files /dev/null and b/docs/screenshots/devcon26/programme-desktop.webp differ diff --git a/docs/screenshots/devcon26/programme-mobile.webp b/docs/screenshots/devcon26/programme-mobile.webp new file mode 100644 index 0000000..2b14ec7 Binary files /dev/null and b/docs/screenshots/devcon26/programme-mobile.webp differ diff --git a/package.json b/package.json index 99c37de..2709b9b 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/scripts/check-devcon26-programme-relay.mjs b/scripts/check-devcon26-programme-relay.mjs new file mode 100644 index 0000000..cdcc595 --- /dev/null +++ b/scripts/check-devcon26-programme-relay.mjs @@ -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.'); diff --git a/scripts/check-devcon26-programme.mjs b/scripts/check-devcon26-programme.mjs new file mode 100644 index 0000000..7610f09 --- /dev/null +++ b/scripts/check-devcon26-programme.mjs @@ -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: 'Talk', 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, 'Talk'); +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.'); diff --git a/src/lib/devcon26-programme.ts b/src/lib/devcon26-programme.ts new file mode 100644 index 0000000..17ff614 --- /dev/null +++ b/src/lib/devcon26-programme.ts @@ -0,0 +1,177 @@ +import { z } from 'zod'; + +const programmeSchema = z.object({ + year: z.literal(2026), + rows: z.array(z.object({ + id: z.string(), + time: z.string(), + title: z.string(), + description: z.string(), + type: z.string(), + speakers: z.array(z.object({ + id: z.string(), + name: z.string(), + photoUrl: z.string().nullable(), + })), + })), +}); + +function safePhotoUrl(value: string | null): string | null { + if (!value) return null; + + try { + const url = new URL(value); + + return url.protocol === 'https:' || url.protocol === 'http:' ? url.href : null; + } catch { + return null; + } +} + +export async function initializeDevcon26Programme(root: HTMLElement | null, speakerRoot: HTMLElement | null): Promise { + if (!root) return; + + const status = root.querySelector('[data-programme-status]'); + const list = root.querySelector('[data-programme-list]'); + const speakerStatus = speakerRoot?.querySelector('[data-speakers-status]'); + const speakerList = speakerRoot?.querySelector('[data-speakers-list]'); + + if (!status || !list) return; + + root.setAttribute('aria-busy', 'true'); + status.hidden = false; + status.textContent = 'Loading the programme…'; + speakerRoot?.setAttribute('aria-busy', 'true'); + + if (speakerStatus) { + speakerStatus.hidden = false; + speakerStatus.textContent = 'Loading speakers…'; + } + + try { + const configuredOrigin = import.meta.env.PUBLIC_DEVCON26_API_ORIGIN?.trim() || 'https://em.devcongress.org'; + const origin = new URL(configuredOrigin); + + if (origin.protocol !== 'https:' && !(origin.protocol === 'http:' && ['localhost', '127.0.0.1'].includes(origin.hostname))) { + throw new Error('Invalid API origin'); + } + + const isWebsitePreview = /^https:\/\/[a-z0-9-]+-devcongress-website\.admins-a7d\.workers\.dev$/.test(window.location.origin); + const endpoint = isWebsitePreview && origin.origin === 'https://em.devcongress.org' + ? new URL('/__public/devcon26/programme', window.location.origin) + : new URL('/api/public/annual-conference/2026/programme', origin.origin); + const response = await fetch(endpoint, { + credentials: 'omit', + cache: 'no-store', + headers: { Accept: 'application/json' }, + signal: AbortSignal.timeout(12_000), + }); + + if (!response.ok) throw new Error('Programme unavailable'); + + const programme = programmeSchema.parse(await response.json()); + const fragment = document.createDocumentFragment(); + + for (const row of programme.rows) { + const item = document.createElement('li'); + const time = document.createElement('span'); + const content = document.createElement('div'); + const title = document.createElement('h4'); + + time.className = 'programme-time'; + time.textContent = row.time; + title.textContent = row.title; + content.append(title); + + if (row.description) { + const description = document.createElement('p'); + + description.textContent = row.description; + content.append(description); + } + + if (row.speakers.length) { + const speakers = document.createElement('div'); + + speakers.className = 'programme-speakers'; + + for (const speaker of row.speakers) { + const person = document.createElement('span'); + const photoUrl = safePhotoUrl(speaker.photoUrl); + + person.className = 'programme-speaker'; + + if (photoUrl) { + const photo = document.createElement('img'); + + photo.src = photoUrl; + photo.alt = ''; + photo.loading = 'lazy'; + photo.referrerPolicy = 'no-referrer'; + photo.addEventListener('error', () => photo.remove(), { once: true }); + person.append(photo); + } + + const name = document.createElement('span'); + + name.textContent = speaker.name; + person.append(name); + speakers.append(person); + } + + content.append(speakers); + } + + item.append(time, content); + fragment.append(item); + } + + if (speakerList && speakerStatus) { + const speakers = new Map(); + + for (const row of programme.rows) { + for (const speaker of row.speakers) { + if (!speakers.has(speaker.id)) speakers.set(speaker.id, speaker); + } + } + + const speakerFragment = document.createDocumentFragment(); + + for (const speaker of speakers.values()) { + const item = document.createElement('li'); + const photoUrl = safePhotoUrl(speaker.photoUrl); + + if (photoUrl) { + const photo = document.createElement('img'); + + photo.src = photoUrl; + photo.alt = ''; + photo.loading = 'lazy'; + photo.referrerPolicy = 'no-referrer'; + photo.addEventListener('error', () => photo.remove(), { once: true }); + item.append(photo); + } + + const name = document.createElement('span'); + + name.textContent = speaker.name; + item.append(name); + speakerFragment.append(item); + } + + speakerList.replaceChildren(speakerFragment); + speakerStatus.hidden = speakers.size > 0; + speakerStatus.textContent = speakers.size ? '' : 'Speakers will be announced here soon.'; + } + + list.replaceChildren(fragment); + status.hidden = programme.rows.length > 0; + status.textContent = programme.rows.length ? '' : 'The programme will be announced here soon.'; + } catch { + status.textContent = 'The programme could not be loaded. Please try again later.'; + if (speakerStatus) speakerStatus.textContent = 'Speakers could not be loaded. Please try again later.'; + } finally { + root.setAttribute('aria-busy', 'false'); + speakerRoot?.setAttribute('aria-busy', 'false'); + } +} diff --git a/src/pages/devcon26/index.astro b/src/pages/devcon26/index.astro index 78a98a1..82b10f9 100644 --- a/src/pages/devcon26/index.astro +++ b/src/pages/devcon26/index.astro @@ -3,7 +3,6 @@ import { getCollection } from 'astro:content'; import Base from '../../layouts/Base.astro'; import Devcon26HomeLink from '../../components/Devcon26HomeLink.astro'; -const speakerSlots = ['01', '02', '03']; const heroScenes = [ { label: 'The April community gathered together', @@ -121,7 +120,7 @@ heroSlots[0].photos.push({ const faqs = [ ['Who is DevCon26 for?', 'For the people building technology across Africa: developers, designers, founders, product people, researchers, and students.'], - ['When and where is it?', 'Meet us at Ghana Digital Center (formerly Accra Digital Center), Accra, Ghana on 19 December 2026. The programme will be announced here.'], + ['When and where is it?', 'Meet us at Ghana Digital Center (formerly Accra Digital Center), Accra, Ghana on 19 December 2026. See the programme above for the latest schedule.'], ['Can I come with my team?', 'Yes. Buy individual tickets together in one checkout, then choose who each ticket is for. Each ticket admits one person.'], ['How do I get a ticket?', 'Go to ticket checkout, choose your tickets, and complete payment. Your ticket email includes a QR ticket PDF and a link to personalize your conference badge.'], ]; @@ -278,12 +277,12 @@ function packageEnquiryLink(sponsorship: (typeof sponsorshipPackages)[number]) {

The work
behind the work.

The honest question after a talk. The story you needed to hear. The person who’s been figuring out the same thing as you.

We’re shaping DevCon26 around useful stories and enough time to talk to the people in the room.

- +
+

Programme

+ +
    + +
    @@ -295,36 +294,10 @@ function packageEnquiryLink(sponsorship: (typeof sponsorshipPackages)[number]) { Apply to speak -
    - {speakerSlots.map((slot) => ( -
    - -

    Speaker announcement

    -

    To be announced

    -
    - ))} +
    + +
      +
      @@ -534,11 +507,16 @@ function packageEnquiryLink(sponsorship: (typeof sponsorshipPackages)[number]) { @@ -900,37 +878,72 @@ function packageEnquiryLink(sponsorship: (typeof sponsorshipPackages)[number]) { margin-top: 16px; } - .programme-note { - position: relative; - max-width: 380px; - margin: 38px 0 0 34px; - padding: 30px; - border-radius: 6px; - background: var(--dc-yellow); - box-shadow: 0 3px 3px rgb(0 0 0 / 0.03), 0 13px 24px rgb(0 0 0 / 0.06); - transform: rotate(3deg); + .programme { + margin-top: 38px; } - .note-fold { - position: absolute; - top: 12px; - right: 20px; + .programme h3 { font-size: 32px; } - .programme-note .section-caption { - padding-right: 25px; - font-size: var(--dc-text-meta); + .programme-status { + margin-top: 16px; + color: var(--dc-muted); + line-height: 1.6; } - .programme-note h3 { - font-size: 32px; + .programme-list { + margin: 20px 0 0; + padding: 0; + list-style: none; } - .programme-note > p:last-child { - margin-top: 18px; + .programme-list :global(li) { + display: grid; + grid-template-columns: minmax(76px, 110px) minmax(0, 1fr); + gap: 16px; + padding: 20px 0; + border-top: 1px solid currentColor; + } + + .programme-list :global(.programme-time) { font-size: 14px; - line-height: 1.6; + font-weight: 700; + } + + .programme-list :global(h4) { + font-size: 20px; + line-height: 1.25; + } + + .programme-list :global(p) { + margin-top: 8px; + color: var(--dc-muted); + line-height: 1.55; + white-space: pre-line; + overflow-wrap: anywhere; + } + + :global(.programme-speakers) { + display: flex; + flex-wrap: wrap; + gap: 10px 16px; + margin-top: 14px; + } + + :global(.programme-speaker) { + display: inline-flex; + align-items: center; + gap: 8px; + font-size: 14px; + font-weight: 700; + } + + :global(.programme-speaker img) { + width: 32px; + height: 32px; + border-radius: 50%; + object-fit: cover; } .speakers-section { @@ -957,82 +970,38 @@ function packageEnquiryLink(sponsorship: (typeof sponsorshipPackages)[number]) { gap: 20px; } - .speaker-grid { - display: grid; - grid-template-columns: repeat(3, minmax(0, 1fr)); - gap: 36px; - margin: 50px 35px 0; - align-items: start; - } - - .speaker-card:nth-child(2) { - margin-top: 62px; + .speakers-status { + margin-top: 32px; + color: var(--dc-muted); + line-height: 1.6; } - .speaker-card:nth-child(3) { - margin-top: 20px; + .speaker-list { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(min(100%, 220px), 1fr)); + gap: 16px; + margin: 40px 0 0; + padding: 0; + list-style: none; } - .speaker-placeholder { - position: relative; + .speaker-list :global(li) { display: flex; - aspect-ratio: 1.08; - flex-direction: column; - justify-content: space-between; - overflow: hidden; - padding: 23px; - border-radius: 8px; - background: var(--dc-ink); - color: var(--dc-paper-light); - } - - .speaker-number { - font-size: var(--dc-text-meta); - font-weight: 600; - } - - .speaker-art { - position: absolute; - align-self: center; - top: 18%; - width: 58%; - max-width: 160px; - height: auto; - color: var(--dc-yellow); - pointer-events: none; - transform: rotate(-10deg); - } - - .speaker-card:nth-child(2) .speaker-placeholder { - background: var(--dc-yellow); - color: var(--dc-ink); - } - - .speaker-card:nth-child(2) .speaker-art { - color: var(--dc-ink); - transform: rotate(8deg); - } - - .speaker-card:nth-child(3) .speaker-art { - color: oklch(0.76 0.14 354); - transform: rotate(-4deg); - } - - .speaker-placeholder small { - position: relative; - font-size: var(--dc-text-meta); - } - - .speaker-status { - margin-top: 18px; - color: var(--dc-muted); - font-size: var(--dc-text-meta); + align-items: center; + gap: 14px; + padding: 16px 0; + border-top: 1px solid currentColor; + font-size: var(--dc-text-card); + font-weight: 700; + overflow-wrap: anywhere; } - .speaker-card h3 { - margin-top: 7px; - font-size: var(--dc-text-card); - letter-spacing: var(--tracking-heading); + .speaker-list :global(img) { + width: 56px; + height: 56px; + flex: none; + border-radius: 50%; + object-fit: cover; } .location-section { @@ -2017,11 +1986,6 @@ function packageEnquiryLink(sponsorship: (typeof sponsorshipPackages)[number]) { gap: 38px; } - .speaker-grid { - gap: 25px; - margin-inline: 0; - } - .sponsors-section { column-gap: 40px; } @@ -2226,10 +2190,8 @@ function packageEnquiryLink(sponsorship: (typeof sponsorshipPackages)[number]) { margin-top: 20px; } - .programme-note { - max-width: calc(100% - 20px); - margin: 30px 0 0 10px; - padding: 26px; + .programme { + margin-top: 30px; } .speakers-section { @@ -2241,54 +2203,9 @@ function packageEnquiryLink(sponsorship: (typeof sponsorshipPackages)[number]) { gap: 22px; } - .speaker-grid { + .speaker-list { grid-template-columns: minmax(0, 1fr); - gap: 24px; - margin: 30px 0 0; - } - - .speaker-card, - .speaker-card:nth-child(2), - .speaker-card:nth-child(3) { - display: grid; - grid-template-columns: 112px minmax(0, 1fr); - grid-template-rows: 1fr 1fr; - gap: 6px 18px; - margin: 0; - } - - .speaker-placeholder { - grid-row: 1 / 3; - aspect-ratio: 1; - padding: 12px; - } - - .speaker-number { - font-size: 12px; - } - - .speaker-art { - top: 28%; - width: 58%; - max-width: 72px; - } - - .speaker-placeholder small { - display: none; - } - - .speaker-status { - align-self: end; - margin: 0; - font-size: 13px; - line-height: 1.4; - } - - .speaker-card h3 { - align-self: start; - margin: 0; - font-size: 20px; - line-height: 1.2; + margin-top: 30px; } .location-section { diff --git a/src/worker.ts b/src/worker.ts index 8914708..6afc438 100644 --- a/src/worker.ts +++ b/src/worker.ts @@ -21,6 +21,10 @@ const SOCIAL_CARD_ROUTE = /^\/devcon26\/cards\/([A-Za-z0-9_-]{22,80})\/?$/; const SOCIAL_CARD_IMAGE_ROUTE = /^\/devcon26\/cards\/([A-Za-z0-9_-]{22,80})\/image\/?$/; const SOCIAL_CARD_PREVIEW_ROUTE = /^\/devcon26\/cards\/([A-Za-z0-9_-]{22,80})\/preview\.svg\/?$/; const CALENDAR_PATH = '/events/calendar.ics'; +const PROGRAMME_RELAY_PATH = '/__public/devcon26/programme'; +const PROGRAMME_UPSTREAM = 'https://em.devcongress.org/api/public/annual-conference/2026/programme'; +const WEBSITE_PREVIEW_HOST = /^[a-z0-9-]+-devcongress-website\.admins-a7d\.workers\.dev$/; +const PROGRAMME_MAX_BYTES = 1024 * 1024; const EVENT_SHELL_PATH = '/events/'; const SUPPORT_PATHS = new Set(['/support', '/support/']); const SUPPORT_URL = 'https://pages.inttegro.com/buy/sale_eLiPUNAEjfeHZOX4VHQPuy0oJWQT6n0g7Bdof5rr'; @@ -34,6 +38,17 @@ export default { async fetch(request: Request, env: Env, context: ExecutionContext): Promise { const url = new URL(request.url); + if (url.pathname === PROGRAMME_RELAY_PATH) { + if (!WEBSITE_PREVIEW_HOST.test(url.hostname)) return notFound(); + if (request.method !== 'GET') { + return new Response('Method not allowed', { + status: 405, + headers: { Allow: 'GET', 'Cache-Control': 'no-store' }, + }); + } + return servePreviewProgramme(); + } + if (SUPPORT_PATHS.has(url.pathname)) { return Response.redirect(SUPPORT_URL, 307); } @@ -78,6 +93,61 @@ export default { }, }; +async function servePreviewProgramme(): Promise { + try { + const upstream = await fetch(PROGRAMME_UPSTREAM, { + method: 'GET', + headers: { Accept: 'application/json' }, + redirect: 'manual', + signal: AbortSignal.timeout(12_000), + }); + + const contentType = upstream.headers.get('Content-Type')?.split(';')[0].trim().toLowerCase(); + + if (!upstream.ok || !upstream.body || contentType !== 'application/json') { + return serviceUnavailable('Programme temporarily unavailable'); + } + + const reader = upstream.body.getReader(); + const chunks: Uint8Array[] = []; + let total = 0; + + try { + while (true) { + const { done, value } = await reader.read(); + + if (done) break; + total += value.byteLength; + if (total > PROGRAMME_MAX_BYTES) { + await reader.cancel(); + return serviceUnavailable('Programme temporarily unavailable'); + } + chunks.push(value); + } + } finally { + reader.releaseLock(); + } + + const body = new Uint8Array(total); + let offset = 0; + + for (const chunk of chunks) { + body.set(chunk, offset); + offset += chunk.byteLength; + } + + return new Response(body, { + headers: { + 'Content-Type': 'application/json; charset=utf-8', + 'Cache-Control': 'no-store', + 'X-Content-Type-Options': 'nosniff', + }, + }); + } catch { + return serviceUnavailable('Programme temporarily unavailable'); + } +} + async function serveLiveSocialCardPage(request: Request, slug: string): Promise { if (!isDevcon26SocialCardSlug(slug)) return notFound(); diff --git a/wrangler.jsonc b/wrangler.jsonc index 73afe8a..8429b05 100644 --- a/wrangler.jsonc +++ b/wrangler.jsonc @@ -12,6 +12,7 @@ "binding": "ASSETS", "run_worker_first": [ "/events/*", + "/__public/devcon26/programme", "/devcon26/cards/*", "/support", "/support/",