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
6 changes: 0 additions & 6 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -46,12 +46,6 @@ jobs:
- name: Build
run: pnpm build

# The app shell links to routes Phase 07 has not built yet, so `.lycheeignore` excludes
# exactly those. This fails if one of them has since been built, so the exclusions cannot
# outlive their purpose and start hiding a real 404.
- name: Check for stale link exclusions
run: node tools/checks/stale-link-ignores.mjs

# --root-dir is required for the root-relative hrefs Astro emits (/_astro/...);
# without it lychee cannot resolve them in local files and errors on every page.
- name: Link check
Expand Down
29 changes: 0 additions & 29 deletions .lycheeignore

This file was deleted.

1 change: 1 addition & 0 deletions DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,7 @@ CSS-only (D3, D20). Motion confirms — it never decorates.
- **Stat band**: SCP 600 numeral in `primary` + Inter caption in `body`, on a pocket (feature moments get the grid floor).
- **Forms**: visible `Label` above every field; `card` bg inputs, 1px `border`, focus = `ring` 2px; errors in the destructive text token with icon + `aria-describedby`.
- **Icons**: Tabler, outline, 2px stroke, 20/24/32. Always with text or `aria-label`. No emoji as UI.
- **Long-form prose** (pages carrying an argument rather than a grid — about, news, an event body): one flowing column at prose measure. Lists take `primary` markers, `body` text, and one level of nesting at most. Blockquotes take a 2px `primary` rule on the leading edge and `body-lg` `foreground` text, with the attribution beneath in `muted` `small` — no quote glyphs, no italics; the rule is the signal. Paragraph rhythm 1rem, with 2rem above a heading that follows copy.

## 9. Accessibility

Expand Down
22 changes: 21 additions & 1 deletion astro.config.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,32 @@
import sitemap from "@astrojs/sitemap";
import tailwindcss from "@tailwindcss/vite";
import { defineConfig } from "astro/config";
import { defineConfig, envField } from "astro/config";

// Loaded through jiti, and this module imports nothing from `astro:*`, so the config can read the
// same origin everything else derives canonical and OG URLs from.
import { site } from "./src/data/site";

export default defineConfig({
/**
* Typed environment, so a page reads a variable rather than an untyped `import.meta.env`
* lookup and a missing one fails the build instead of rendering `undefined` into markup.
*/
env: {
schema: {
/**
* The Turnstile widget's public site key — it ships in the page HTML by design, and the
* secret half stays in the Pages Function's environment. The default is Cloudflare's
* documented always-passes test key, so a fresh clone and every preview deploy have a
* working form with no setup; production sets the real key in the Pages dashboard
* (docs/tooling.md).
*/
PUBLIC_TURNSTILE_SITE_KEY: envField.string({
access: "public",
context: "client",
default: "1x00000000000000000000AA",
}),
},
},
// /styleguide is a noindex review artifact; a URL that is both in the sitemap and
// noindex is a "Submitted URL marked 'noindex'" error in Search Console.
integrations: [sitemap({ filter: (page) => !page.includes("/styleguide") })],
Expand Down
22 changes: 15 additions & 7 deletions docs/tooling.md
Original file line number Diff line number Diff line change
Expand Up @@ -178,13 +178,21 @@ vendored rule directory are skipped.

## Environment variables

| Variable | Where | Purpose |
| --------------------------- | --------------------- | ----------------------------------------------- |
| `PUBLIC_TURNSTILE_SITE_KEY` | build (public) | Turnstile widget on the contact form (Phase 07) |
| `TS_SECRET_KEY` | Pages Function secret | Turnstile server-side verification |
| `SLACK_FORM_POST_GENERIC` | Pages Function secret | Slack webhook for contact submissions |

The dev/test Turnstile key that always passes is in `legacy/.env.development`.
| Variable | Where | Purpose |
| --------------------------- | --------------------- | ------------------------------------- |
| `PUBLIC_TURNSTILE_SITE_KEY` | build (public) | Turnstile widget on the contact form |
| `TS_SECRET_KEY` | Pages Function secret | Turnstile server-side verification |
| `SLACK_FORM_POST_GENERIC` | Pages Function secret | Slack webhook for contact submissions |

`PUBLIC_TURNSTILE_SITE_KEY` is declared in `astro.config.ts`'s `env.schema`, so pages import it
from `astro:env/client` rather than reaching into an untyped `import.meta.env`. It **defaults to
Cloudflare's documented always-passes test key** (`1x00000000000000000000AA`), which is why a
fresh clone and every preview deploy have a working form with no setup — and why the production
value has to be set deliberately, in the Cloudflare Pages dashboard, alongside the `TS_SECRET_KEY`
its server half checks against. A site key is public by design: it ships in the page's HTML.

The secret half has a matching always-passes test value baked into `functions/api/form/submit.ts`
for the same reason, so the whole round trip works locally without credentials.

## CI

Expand Down
84 changes: 84 additions & 0 deletions functions/api/calendar/[name].ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import { upcomingEvents } from "@/ics";
import type { CalendarEvent } from "@/types";

/**
* A branded agenda needs the calendar's events as data, not as a Google iframe (D19) — so this
* fetches the public ICS feed server-side and hands the page JSON.
*
* Doing it here rather than in the browser is what makes the feature possible at all: the feed
* sends no CORS headers, so a page cannot read it directly.
*/

/**
* Mirrors `site.calendars` in `src/data/site.ts` — Pages Functions are bundled separately and
* cannot import from `src/`, so the two ids are duplicated verbatim rather than transformed,
* which keeps them diffable against their source. These are the base64 form Google's embed URL
* uses; the ICS endpoint wants the address inside.
*/
const CALENDARS = {
frc: "Y19hYjljNWJlYTEwODgyYzAxYTAxOGNiZDUxYWIyMzcwYmY4NDk5NDZiZTRlMjUzNTAwZmZmMWQxMGZkY2M4NjFhQGdyb3VwLmNhbGVuZGFyLmdvb2dsZS5jb20",
sc2: "Y19wcDlkOXRrbGRrbThmdXZtcjMyZTBwZTgxc0Bncm91cC5jYWxlbmRhci5nb29nbGUuY29t",
};

type CalendarName = keyof typeof CALENDARS;

const isCalendarName = (value: string | undefined): value is CalendarName =>
value !== undefined && Object.hasOwn(CALENDARS, value);

/** How far ahead the agenda looks. */
const WINDOW_DAYS = 90;

/** Fifteen minutes: a schedule change should surface the same day, not the same minute. */
const MAX_AGE = 900;

/** What `/api/calendar/<name>` answers with, either way. */
interface CalendarResponse {
events?: Array<CalendarEvent>;
message?: string;
}

const json = (body: CalendarResponse, status: number, cacheable: boolean): Response =>
new Response(JSON.stringify(body), {
headers: {
"Cache-Control": cacheable ? `public, max-age=${String(MAX_AGE)}` : "no-store",
"Content-Type": "application/json",
},
status,
});

export const onRequestGet: PagesFunction<unknown, "name"> = async ({
params,
request,
waitUntil,
}) => {
const name = Array.isArray(params.name) ? params.name[0] : params.name;
if (!isCalendarName(name)) {
return json({ message: "Unknown calendar" }, 404, false);
}

// Cloudflare's edge cache, keyed on the request, so one fetch of the feed serves every visitor
// for the freshness window instead of each browser holding its own copy.
const cache = caches.default;
const cached = await cache.match(request);
if (cached !== undefined) {
return cached;
}

const address = atob(CALENDARS[name]);
const feed = `https://calendar.google.com/calendar/ical/${encodeURIComponent(address)}/public/basic.ics`;

try {
const upstream = await fetch(feed, { cf: { cacheTtl: MAX_AGE } });
if (!upstream.ok) {
return json({ message: `Calendar feed returned ${String(upstream.status)}` }, 502, false);
}

const events = upcomingEvents(await upstream.text(), Date.now(), WINDOW_DAYS);
const response = json({ events }, 200, true);
waitUntil(cache.put(request, response.clone()));
return response;
} catch (error) {
console.error(error);
return json({ message: "Could not reach the calendar feed" }, 502, false);
}
};
Loading
Loading