From 12732c802ae59752b82dd3bdb3589f146c1c59c9 Mon Sep 17 00:00:00 2001 From: Erik Thorelli Date: Sun, 4 Oct 2026 06:21:55 -0700 Subject: [PATCH 1/2] Lint the whole repo with oxlint One root .oxlintrc.json for every package: the correctness and suspicious categories as errors, type-aware rules on oxlint-tsgolint, warnings denied and unused disable directives reported. app/ adds the React, Next.js and jsx-a11y plugins and web/ adds jsx-a11y, each in a nested config that extends the root one. `bun run lint` runs it, and a Lint workflow runs it on every pull request and push to main. Every finding is fixed without changing behavior: unused code removed, shadowed names renamed, sort() replaced with toSorted(), caught errors attached as causes, floating promises awaited or voided, needless assertions dropped. app/'s tsconfig names its ambient types, which TypeScript 7 no longer includes by default. The few intentional cases carry a disable comment with the reason. --- .github/workflows/lint.yaml | 59 ++++++++++++ .oxlintrc.json | 51 ++++++++++ app/.oxlintrc.json | 31 +++++++ app/scripts/add-host.ts | 2 +- app/scripts/add-images.ts | 2 +- app/scripts/add-talk.ts | 2 +- app/scripts/add-user-to-admins.ts | 2 +- app/scripts/create-event.ts | 2 +- app/scripts/create-host.ts | 2 +- app/scripts/create-profile.ts | 2 +- app/scripts/create-talk.ts | 2 +- app/scripts/delete-image.ts | 2 +- app/scripts/delete-profile.ts | 4 +- app/scripts/find-profile.ts | 2 +- app/scripts/find-talks.ts | 5 +- app/scripts/functions.ts | 9 +- app/scripts/get-event.ts | 2 +- app/scripts/get-image-ids.ts | 2 +- app/scripts/get-luma-event.ts | 2 +- app/scripts/list-admins.ts | 2 +- app/scripts/mcp-server.ts | 6 +- app/scripts/remove-talk.ts | 2 +- app/scripts/remove-user-from-admins.ts | 2 +- app/scripts/update-event.ts | 2 +- .../app/2025-06-02-nextdevfm-live/page.tsx | 8 +- app/src/app/about/page.tsx | 20 ++-- .../assign-profile/assign-profile-form.tsx | 2 +- .../app/admin/images/admin-images-client.tsx | 3 +- app/src/app/admin/images/page.tsx | 8 +- .../app/admin/raw/talks/create-talk-form.tsx | 10 +- .../upload-images/upload-images-form.tsx | 6 +- app/src/app/api/sentry-example-api/route.ts | 3 - app/src/app/api/v1/preview.png/route.tsx | 3 +- app/src/app/api/v1/speakers.png/route.tsx | 3 +- app/src/app/global-error.tsx | 2 +- app/src/app/page.tsx | 4 +- app/src/app/r/[id]/route.ts | 2 +- app/src/app/sentry-example-page/page.tsx | 2 +- app/src/components/admin/copy-button.tsx | 4 +- app/src/components/admin/event-image-card.tsx | 1 + app/src/components/event-carousel.tsx | 2 +- app/src/components/event-details.tsx | 12 +-- app/src/components/image-upload-crop.tsx | 27 +++--- app/src/components/past-events-grid.tsx | 2 +- app/src/components/profile-button.tsx | 2 +- app/src/components/profile-card.tsx | 4 +- app/src/components/profile-form.tsx | 5 +- app/src/components/top-nav.tsx | 1 - app/src/components/ui/breadcrumb.tsx | 1 + app/src/components/ui/calendar.tsx | 93 +++++++++++-------- app/src/components/ui/carousel.tsx | 12 ++- app/src/components/ui/chart.tsx | 16 ++-- app/src/components/ui/form.tsx | 6 +- app/src/components/ui/input-otp.tsx | 1 + app/src/components/ui/pagination.tsx | 5 +- app/src/components/ui/sidebar.tsx | 4 +- app/src/components/ui/typing-animation.tsx | 2 +- app/src/hooks/use-carousel.ts | 6 +- app/src/lib/event-covers/image-signature.ts | 3 +- app/src/lib/event-covers/luma-cover.ts | 1 + app/src/lib/event-covers/runtime.ts | 4 +- app/src/lib/event-feeds.ts | 1 + app/src/lib/image-gen/templates.tsx | 3 +- app/src/lib/image-processor.ts | 3 +- app/src/lib/mcp/tools.ts | 2 +- app/src/lib/public-api/mappers.ts | 2 +- app/src/lib/published-events.ts | 2 +- app/src/lib/speaker-directory.ts | 2 +- app/tests/cover-source.test.ts | 2 +- app/tests/event-covers.test.ts | 12 +-- app/tests/image-signature.test.ts | 4 +- app/tests/luma-cover.test.ts | 2 +- app/tests/mcp.test.ts | 10 +- app/tests/media-store.test.ts | 5 +- app/tests/profile-photos.test.ts | 10 +- app/tests/speaker-directory.test.ts | 6 +- app/tsconfig.json | 1 + atw-cli/src/index.ts | 1 + atw-cli/tests/client.test.ts | 5 +- brand/src/contrast.ts | 3 +- brand/src/tokens.ts | 6 +- bun.lock | 58 ++++++++++++ core/src/mappers.ts | 2 +- core/src/speakers.ts | 2 +- core/tests/parity.test.ts | 20 ++-- core/tests/repositories.test.ts | 10 +- core/tests/support/database.ts | 2 +- infra/src/media-upload.ts | 8 +- infra/src/upload-worker.ts | 6 +- infra/src/vercel-env.ts | 2 +- package.json | 9 +- tools/pr-header/src/main.ts | 4 +- tools/pr-header/src/render.ts | 2 +- tools/pr-header/src/vercel.ts | 33 +++---- tools/pr-header/tests/vercel.test.ts | 10 +- web/.oxlintrc.json | 13 +++ web/src/pages/brand.tsx | 1 + web/src/pages/response.ts | 6 +- web/tests/support/app.ts | 6 +- web/tests/support/order.ts | 2 +- 100 files changed, 507 insertions(+), 260 deletions(-) create mode 100644 .github/workflows/lint.yaml create mode 100644 .oxlintrc.json create mode 100644 app/.oxlintrc.json create mode 100644 web/.oxlintrc.json diff --git a/.github/workflows/lint.yaml b/.github/workflows/lint.yaml new file mode 100644 index 00000000..301a0205 --- /dev/null +++ b/.github/workflows/lint.yaml @@ -0,0 +1,59 @@ +name: Lint + +on: + push: + branches: ["main"] + pull_request: + types: [opened, synchronize, reopened] + +permissions: + contents: read + +concurrency: + group: lint-${{ github.ref }} + cancel-in-progress: true + +jobs: + lint: + name: ๐Ÿงน Lint + runs-on: ubuntu-latest + steps: + - name: โฌ‡๏ธ Checkout repo + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + with: + persist-credentials: false + + - name: ๐Ÿž Setup Bun + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 + with: + bun-version: 1.3.14 + + # oxlint and the root workspace (brand, core, web, tools/xss-scan). + - name: ๐Ÿ“ฅ Download root deps + run: bun install --frozen-lockfile + + # Type-aware rules and import checks read every package's types, so + # each package with its own lockfile gets its dependencies too. + - name: ๐Ÿ“ฅ Download app deps + working-directory: app + run: bun install --frozen-lockfile + + - name: ๐Ÿ“ฅ Download CLI deps + working-directory: atw-cli + run: bun install --frozen-lockfile + + - name: ๐Ÿ“ฅ Download infra deps + working-directory: infra + run: bun install --frozen-lockfile + + - name: ๐Ÿ“ฅ Download pr-header deps + working-directory: tools/pr-header + run: bun install --frozen-lockfile + + # web/src imports the manifest its build writes to web/dist. + - name: ๐Ÿ—๏ธ Build web + working-directory: web + run: bun run build + + - name: ๐Ÿงน Lint + run: bun run lint diff --git a/.oxlintrc.json b/.oxlintrc.json new file mode 100644 index 00000000..da7a1e52 --- /dev/null +++ b/.oxlintrc.json @@ -0,0 +1,51 @@ +{ + "$schema": "./node_modules/oxlint/configuration_schema.json", + // One rule set for every package. app/ and web/ extend it with their JSX + // plugins in their own .oxlintrc.json. + "plugins": ["typescript", "unicorn", "oxc", "import", "promise"], + "categories": { + "correctness": "error", + "suspicious": "error" + }, + "options": { + // Type-aware rules run on oxlint-tsgolint, which follows TypeScript 7. + "typeAware": true, + "denyWarnings": true, + "reportUnusedDisableDirectives": "error" + }, + "rules": { + // A micro-optimization that is noisy in tests and Effect code. + "unicorn/consistent-function-scoping": "off", + // Effect's `_tag` and friends are idiomatic. + "eslint/no-underscore-dangle": "off", + // Omitting keys with a rest sibling (`const { a, ...rest } = x`) is how we + // drop fields, so those siblings are used. + "eslint/no-unused-vars": ["error", { "ignoreRestSiblings": true }], + // TypeScript checks namespace members already, and this rule resolves + // conditional exports wrongly (it misses @sentry/nextjs's browser API). + "import/namespace": "off", + "import/no-unassigned-import": [ + "error", + { "allow": ["**/*.css", "server-only"] } + ], + // Each of today's casts (most in app/) needs a runtime check to replace + // it. That is its own change; this one keeps behavior as it is. + "typescript/no-unsafe-type-assertion": "off", + // JSX event handlers may be async: React ignores what they return. + "typescript/no-misused-promises": [ + "error", + { "checksVoidReturn": { "attributes": false } } + ], + "typescript/only-throw-error": "error", + "typescript/prefer-promise-reject-errors": "error", + "typescript/switch-exhaustiveness-check": "error" + }, + "overrides": [ + { + // bun-types declares `expect(...).rejects` matchers as returning void, + // but they return promises that must be awaited. + "files": ["**/tests/**"], + "rules": { "typescript/await-thenable": "off" } + } + ] +} diff --git a/app/.oxlintrc.json b/app/.oxlintrc.json new file mode 100644 index 00000000..f0a7216d --- /dev/null +++ b/app/.oxlintrc.json @@ -0,0 +1,31 @@ +{ + "$schema": "../node_modules/oxlint/configuration_schema.json", + // The root rule set, plus React, Next.js and accessibility checks for this + // Next.js app. A nested config because `overrides` cannot enable categories. + "extends": ["../.oxlintrc.json"], + "plugins": [ + "typescript", + "unicorn", + "oxc", + "import", + "promise", + "react", + "nextjs", + "jsx-a11y" + ], + "rules": { + // The automatic JSX runtime needs no React in scope. + "react/react-in-jsx-scope": "off", + // These check what the React Compiler can optimize, and this app doesn't + // use it. What they flag here are working effects (syncing form fields on + // selection, subscribing to media queries and carousels); rewriting them + // would change behavior, not fix it. + "react/set-state-in-effect": "off", + "react/immutability": "off", + "react/exhaustive-effect-dependencies": "off", + "react/purity": "off", + // Option text sits three elements deep inside its label. + "jsx-a11y/label-has-associated-control": ["error", { "depth": 3 }], + "jsx-a11y/control-has-associated-label": ["error", { "depth": 3 }] + } +} diff --git a/app/scripts/add-host.ts b/app/scripts/add-host.ts index 3d9161e8..0beb8c40 100644 --- a/app/scripts/add-host.ts +++ b/app/scripts/add-host.ts @@ -8,4 +8,4 @@ async function main() { console.log("Done"); } -main(); +await main(); diff --git a/app/scripts/add-images.ts b/app/scripts/add-images.ts index f6c58f5b..1a107fc2 100644 --- a/app/scripts/add-images.ts +++ b/app/scripts/add-images.ts @@ -13,4 +13,4 @@ async function main() { } } -main(); +await main(); diff --git a/app/scripts/add-talk.ts b/app/scripts/add-talk.ts index 8fe41dd8..237331bc 100644 --- a/app/scripts/add-talk.ts +++ b/app/scripts/add-talk.ts @@ -8,4 +8,4 @@ async function main() { console.log("Done"); } -main(); +await main(); diff --git a/app/scripts/add-user-to-admins.ts b/app/scripts/add-user-to-admins.ts index 755afc6a..a0523960 100644 --- a/app/scripts/add-user-to-admins.ts +++ b/app/scripts/add-user-to-admins.ts @@ -21,4 +21,4 @@ async function main() { } } -main(); +await main(); diff --git a/app/scripts/create-event.ts b/app/scripts/create-event.ts index df637aa5..5a6f2a69 100644 --- a/app/scripts/create-event.ts +++ b/app/scripts/create-event.ts @@ -23,4 +23,4 @@ async function main() { console.log(res); } -main(); +await main(); diff --git a/app/scripts/create-host.ts b/app/scripts/create-host.ts index ed23f30e..2e84fbb0 100644 --- a/app/scripts/create-host.ts +++ b/app/scripts/create-host.ts @@ -23,4 +23,4 @@ async function main() { console.log(createdHost.id); } -main(); +await main(); diff --git a/app/scripts/create-profile.ts b/app/scripts/create-profile.ts index de8398f8..fff788b4 100644 --- a/app/scripts/create-profile.ts +++ b/app/scripts/create-profile.ts @@ -21,4 +21,4 @@ async function main() { console.log(createdProfile.id); } -main(); +await main(); diff --git a/app/scripts/create-talk.ts b/app/scripts/create-talk.ts index 2ec7c393..91b019c7 100644 --- a/app/scripts/create-talk.ts +++ b/app/scripts/create-talk.ts @@ -15,4 +15,4 @@ async function main() { console.log(speakers); } -main(); +await main(); diff --git a/app/scripts/delete-image.ts b/app/scripts/delete-image.ts index 0b6fe7c7..3b374efc 100644 --- a/app/scripts/delete-image.ts +++ b/app/scripts/delete-image.ts @@ -17,4 +17,4 @@ async function main() { } } -main(); +await main(); diff --git a/app/scripts/delete-profile.ts b/app/scripts/delete-profile.ts index b2ef9a4e..e687170c 100644 --- a/app/scripts/delete-profile.ts +++ b/app/scripts/delete-profile.ts @@ -50,7 +50,7 @@ async function main() { console.log(`Name: ${profile.name}`); console.log(`Type: ${profile.profileType}`); console.log(`Title: ${profile.title}`); - console.log(`Created: ${profile.createdAt}`); + console.log(`Created: ${String(profile.createdAt)}`); if (profile.image) { console.log(`Image ID: ${profile.image}`); } @@ -99,5 +99,5 @@ async function main() { // Only run if this script is executed directly if (import.meta.main) { - main(); + await main(); } diff --git a/app/scripts/find-profile.ts b/app/scripts/find-profile.ts index 8affac20..c831b687 100644 --- a/app/scripts/find-profile.ts +++ b/app/scripts/find-profile.ts @@ -10,4 +10,4 @@ async function main() { console.log(profile); } -main(); +await main(); diff --git a/app/scripts/find-talks.ts b/app/scripts/find-talks.ts index b76136fb..b8d41975 100644 --- a/app/scripts/find-talks.ts +++ b/app/scripts/find-talks.ts @@ -3,12 +3,11 @@ import { findTalksBySpeakerName } from "./functions"; const speakerName = "Ted Nyman"; async function main() { - const { speaker, talks, talkEvents } = - await findTalksBySpeakerName(speakerName); + const { talks, talkEvents } = await findTalksBySpeakerName(speakerName); if (talkEvents) { console.log(talkEvents); } console.log(talks); } -main(); +await main(); diff --git a/app/scripts/functions.ts b/app/scripts/functions.ts index f94d91f9..97f535ab 100644 --- a/app/scripts/functions.ts +++ b/app/scripts/functions.ts @@ -5,7 +5,6 @@ import { removeStoredObject, } from "../src/lib/media-store"; import { profilePhotoKey } from "../src/lib/profile-photos/ingest"; -import { mainConfig } from "../src/lib/config"; import { eventImagesTable, eventHostsTable, @@ -471,7 +470,7 @@ export async function deleteEventImages(imageUrls: string[]) { const ids = await getImgIdsForUrls(imageUrls); const results = []; - for await (const id of ids) { + for (const id of ids) { const image = await db .select() .from(imagesTable) @@ -525,6 +524,7 @@ export async function deleteOrphanedImage(imageUrl: string) { // PostgreSQL foreign key constraint violation throw new Error( `Image ${imageUrl} is not orphaned - it is still referenced by other records (foreign key constraint)`, + { cause: error }, ); } // Re-throw other errors @@ -545,6 +545,7 @@ export async function deleteOrphanedImage(imageUrl: string) { } catch (error) { throw new Error( `Failed to delete orphaned image from storage: ${error instanceof Error ? error.message : "Unknown error"}`, + { cause: error }, ); } } @@ -562,7 +563,7 @@ export async function addImagesToEvent( const filePaths = fileNames.map((fn) => join(imagesDir, fn)); const results = []; - for await (const entry of filePaths) { + for (const entry of filePaths) { const uuid = randomUUID(); // Process image using our new utility @@ -690,6 +691,7 @@ export async function getLumaEvent(eventId: string) { console.error(`Failed to fetch Luma event ${eventId}:`, error); throw new Error( `Failed to fetch Luma event: ${error instanceof Error ? error.message : String(error)}`, + { cause: error }, ); } } @@ -803,6 +805,7 @@ export async function deleteProfile(profileId: string) { console.error("โŒ Error during deletion process:", error); throw new Error( `Deletion failed: ${error instanceof Error ? error.message : String(error)}`, + { cause: error }, ); } } diff --git a/app/scripts/get-event.ts b/app/scripts/get-event.ts index 8f595bf9..bcf29592 100644 --- a/app/scripts/get-event.ts +++ b/app/scripts/get-event.ts @@ -11,4 +11,4 @@ async function main() { console.log(event); } -main(); +await main(); diff --git a/app/scripts/get-image-ids.ts b/app/scripts/get-image-ids.ts index 8af26c3e..82afbfe9 100644 --- a/app/scripts/get-image-ids.ts +++ b/app/scripts/get-image-ids.ts @@ -9,4 +9,4 @@ async function main() { console.log(ids); } -main(); +await main(); diff --git a/app/scripts/get-luma-event.ts b/app/scripts/get-luma-event.ts index 0294dcdc..cdf7f6b3 100644 --- a/app/scripts/get-luma-event.ts +++ b/app/scripts/get-luma-event.ts @@ -12,4 +12,4 @@ async function main() { } } -main(); +await main(); diff --git a/app/scripts/list-admins.ts b/app/scripts/list-admins.ts index f7d70e27..60f63b7e 100644 --- a/app/scripts/list-admins.ts +++ b/app/scripts/list-admins.ts @@ -24,4 +24,4 @@ async function main() { } } -main(); +await main(); diff --git a/app/scripts/mcp-server.ts b/app/scripts/mcp-server.ts index 77d5d6f0..2d2497ea 100644 --- a/app/scripts/mcp-server.ts +++ b/app/scripts/mcp-server.ts @@ -527,10 +527,10 @@ server.setRequestHandler(ListToolsRequestSchema, async () => { // Tool execution handler server.setRequestHandler(CallToolRequestSchema, async (request) => { - const { name, arguments: args } = request.params; + const { name: toolName, arguments: args } = request.params; try { - switch (name) { + switch (toolName) { // Event tools case "create_event": { const { event } = args as { event: any }; @@ -862,7 +862,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => { } default: - throw new Error(`Unknown tool: ${name}`); + throw new Error(`Unknown tool: ${toolName}`); } } catch (error) { return { diff --git a/app/scripts/remove-talk.ts b/app/scripts/remove-talk.ts index 50ecac13..3a6d4fc0 100644 --- a/app/scripts/remove-talk.ts +++ b/app/scripts/remove-talk.ts @@ -8,4 +8,4 @@ async function main() { console.log(`Deleted ${result.length} talk(s) from event`); } -main(); +await main(); diff --git a/app/scripts/remove-user-from-admins.ts b/app/scripts/remove-user-from-admins.ts index 705361bc..f46c58c8 100644 --- a/app/scripts/remove-user-from-admins.ts +++ b/app/scripts/remove-user-from-admins.ts @@ -16,4 +16,4 @@ async function main() { } } -main(); +await main(); diff --git a/app/scripts/update-event.ts b/app/scripts/update-event.ts index 111effba..d9fe9844 100644 --- a/app/scripts/update-event.ts +++ b/app/scripts/update-event.ts @@ -11,4 +11,4 @@ async function main() { console.log(res); } -main(); +await main(); diff --git a/app/src/app/2025-06-02-nextdevfm-live/page.tsx b/app/src/app/2025-06-02-nextdevfm-live/page.tsx index 3e68e087..71868ed6 100644 --- a/app/src/app/2025-06-02-nextdevfm-live/page.tsx +++ b/app/src/app/2025-06-02-nextdevfm-live/page.tsx @@ -1,7 +1,7 @@ import { notFound } from "next/navigation"; import { Metadata } from "next"; import Link from "next/link"; -import { getExpandedEventBySlug } from "@/lib/expanded-events"; +import { getExpandedEventBySlug, type Speaker } from "@/lib/expanded-events"; import { isEventInPast } from "@/lib/events"; import { mainConfig } from "@/lib/config"; import { @@ -123,8 +123,8 @@ function LivePodcastSection({ guest, showEventImageSection, }: { - hosts: (any | null)[]; - guest: any | null; + hosts: (Speaker | null)[]; + guest: Speaker | null; showEventImageSection: boolean; }) { return ( @@ -163,7 +163,7 @@ function LivePodcastSection({

Hosts

{hosts.map( - (host, i) => + (host) => host && ( { if (selectedProfileId) { - loadCurrentAssignment(selectedProfileId); + void loadCurrentAssignment(selectedProfileId); } else { setCurrentAssignment(null); setSelectedUserId(""); diff --git a/app/src/app/admin/images/admin-images-client.tsx b/app/src/app/admin/images/admin-images-client.tsx index 64cd6b42..a4a5e187 100644 --- a/app/src/app/admin/images/admin-images-client.tsx +++ b/app/src/app/admin/images/admin-images-client.tsx @@ -25,7 +25,6 @@ interface AdminImagesClientProps { export function AdminImagesClient({ initialEventImages, - sortedEvents: initialSortedEvents, }: AdminImagesClientProps) { const [eventImages, setEventImages] = useState(initialEventImages); const [message, setMessage] = useState<{ @@ -59,7 +58,7 @@ export function AdminImagesClient({ >, ); - const sortedEvents = Object.entries(imagesByEvent).sort( + const sortedEvents = Object.entries(imagesByEvent).toSorted( ([, a], [, b]) => b.eventStartDate.getTime() - a.eventStartDate.getTime(), ); diff --git a/app/src/app/admin/images/page.tsx b/app/src/app/admin/images/page.tsx index 2eeb786f..14cfd93e 100644 --- a/app/src/app/admin/images/page.tsx +++ b/app/src/app/admin/images/page.tsx @@ -4,11 +4,9 @@ import { isAdmin } from "@/lib/admin"; import { getAllEventImagesWithDetails } from "@/lib/images"; import { PageLayout } from "@/components/page-layout"; import { Section } from "@/components/ui/section"; -import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; -import { Badge } from "@/components/ui/badge"; +import { Card, CardContent } from "@/components/ui/card"; import { Button } from "@/components/ui/button"; -import { ArrowLeft, ExternalLink } from "lucide-react"; -import { toReadableDateTimeStr } from "@/lib/datetime"; +import { ArrowLeft } from "lucide-react"; import { AdminImagesClient } from "./admin-images-client"; export default async function AdminImagesPage() { @@ -70,7 +68,7 @@ export default async function AdminImagesPage() { >, ); - const sortedEvents = Object.entries(imagesByEvent).sort( + const sortedEvents = Object.entries(imagesByEvent).toSorted( ([, a], [, b]) => b.eventStartDate.getTime() - a.eventStartDate.getTime(), ); diff --git a/app/src/app/admin/raw/talks/create-talk-form.tsx b/app/src/app/admin/raw/talks/create-talk-form.tsx index a8720627..bc793255 100644 --- a/app/src/app/admin/raw/talks/create-talk-form.tsx +++ b/app/src/app/admin/raw/talks/create-talk-form.tsx @@ -39,7 +39,7 @@ export default function CreateTalkForm({ const sortedProfiles = useMemo( () => - [...profiles].sort((a, b) => { + profiles.toSorted((a, b) => { if (a.profileType === b.profileType) { return a.name.localeCompare(b.name); } @@ -226,9 +226,9 @@ export default function CreateTalkForm({
- + {selectedSpeakerIds.length} selected @@ -341,9 +341,9 @@ export default function CreateTalkForm({
- + {editSpeakerIds.length} selected diff --git a/app/src/app/admin/upload-images/upload-images-form.tsx b/app/src/app/admin/upload-images/upload-images-form.tsx index e63d3ee1..f10ae1cc 100644 --- a/app/src/app/admin/upload-images/upload-images-form.tsx +++ b/app/src/app/admin/upload-images/upload-images-form.tsx @@ -47,7 +47,7 @@ export default function UploadImagesForm({ events }: UploadImagesFormProps) { // Load existing images when event is selected useEffect(() => { if (selectedEventId) { - loadExistingImages(selectedEventId); + void loadExistingImages(selectedEventId); } else { setExistingImages([]); } @@ -70,7 +70,7 @@ export default function UploadImagesForm({ events }: UploadImagesFormProps) { } }; - const handleDeleteImage = async (imageId: string, imageUrl: string) => { + const handleDeleteImage = async (imageId: string) => { setMessage(null); try { @@ -429,7 +429,7 @@ export default function UploadImagesForm({ events }: UploadImagesFormProps) { image={image} showDelete={true} onDelete={async (imageId) => { - await handleDeleteImage(imageId, image.imageUrl); + await handleDeleteImage(imageId); }} imageContainerClassName="aspect-square" /> diff --git a/app/src/app/api/sentry-example-api/route.ts b/app/src/app/api/sentry-example-api/route.ts index baf85806..66fc4867 100644 --- a/app/src/app/api/sentry-example-api/route.ts +++ b/app/src/app/api/sentry-example-api/route.ts @@ -1,5 +1,3 @@ -import { NextResponse } from "next/server"; - export const dynamic = "force-dynamic"; class SentryExampleAPIError extends Error { constructor(message: string | undefined) { @@ -12,5 +10,4 @@ export function GET() { throw new SentryExampleAPIError( "This error is raised on the backend called by the example page.", ); - return NextResponse.json({ data: "Testing Sentry Error..." }); } diff --git a/app/src/app/api/v1/preview.png/route.tsx b/app/src/app/api/v1/preview.png/route.tsx index 5de46b16..b91411e5 100644 --- a/app/src/app/api/v1/preview.png/route.tsx +++ b/app/src/app/api/v1/preview.png/route.tsx @@ -1,11 +1,10 @@ -import { NextRequest } from "next/server"; import { ImageResponse } from "next/og"; import { LandingPagePreview } from "@/lib/image-gen/templates"; import { getFont } from "@/lib/image-gen/utils"; import { getPastEventImages } from "@/lib/images"; import { generatedImageCacheControl } from "@/lib/image-gen/cache"; -export async function GET(request: NextRequest) { +export async function GET() { try { // Get past event images const pastEventImages = await getPastEventImages(); diff --git a/app/src/app/api/v1/speakers.png/route.tsx b/app/src/app/api/v1/speakers.png/route.tsx index 566838f4..e5fa1bd5 100644 --- a/app/src/app/api/v1/speakers.png/route.tsx +++ b/app/src/app/api/v1/speakers.png/route.tsx @@ -1,11 +1,10 @@ -import { NextRequest } from "next/server"; import { ImageResponse } from "next/og"; import { SpeakersPreview } from "@/lib/image-gen/templates"; import { getFont } from "@/lib/image-gen/utils"; import { getSpeakersWithTalks } from "@/lib/speakers"; import { generatedImageCacheControl } from "@/lib/image-gen/cache"; -export async function GET(request: NextRequest) { +export async function GET() { try { const { speakers } = await getSpeakersWithTalks(); diff --git a/app/src/app/global-error.tsx b/app/src/app/global-error.tsx index 9388e06e..4f9c8a9d 100644 --- a/app/src/app/global-error.tsx +++ b/app/src/app/global-error.tsx @@ -14,7 +14,7 @@ export default function GlobalError({ }, [error]); return ( - + {/* `NextError` is the default Next.js error page component. Its type definition requires a `statusCode` prop. However, since the App Router diff --git a/app/src/app/page.tsx b/app/src/app/page.tsx index 3945e359..82784b5d 100644 --- a/app/src/app/page.tsx +++ b/app/src/app/page.tsx @@ -6,7 +6,7 @@ import { } from "lucide-react"; import Link from "next/link"; import { Metadata } from "next"; -import { eq, and, gt, lte, gte, lt, desc } from "drizzle-orm"; +import { eq, and, gt, lte, gte, desc } from "drizzle-orm"; import { db } from "@/lib/db"; import { eventsTable, imagesTable } from "@/lib/schema"; import { PageLayout } from "@/components/page-layout"; @@ -17,7 +17,7 @@ import { PastEventsGrid } from "@/components/past-events-grid"; import { LandingHero } from "@/components/landing-hero"; import { DiscordLogoIcon } from "@/components/ui/icons"; import { Event } from "@/lib/events"; -import { toReadableDateTimeStr, toShortDateStr } from "@/lib/datetime"; +import { toShortDateStr } from "@/lib/datetime"; import { getPastEventImages, getPastEventsWithImages, diff --git a/app/src/app/r/[id]/route.ts b/app/src/app/r/[id]/route.ts index c58e63dd..775c41d5 100644 --- a/app/src/app/r/[id]/route.ts +++ b/app/src/app/r/[id]/route.ts @@ -36,5 +36,5 @@ export async function GET( // Redirect to the destination URL (moved permanently for SEO authority) // Note: redirect() will throw NEXT_REDIRECT which Next.js handles automatically - redirect(redirectLink.destinationUrl); + return redirect(redirectLink.destinationUrl); } diff --git a/app/src/app/sentry-example-page/page.tsx b/app/src/app/sentry-example-page/page.tsx index fca665a9..0dea993c 100644 --- a/app/src/app/sentry-example-page/page.tsx +++ b/app/src/app/sentry-example-page/page.tsx @@ -20,7 +20,7 @@ export default function Page() { const result = await Sentry.diagnoseSdkConnectivity(); setIsConnected(result !== "sentry-unreachable"); } - checkConnectivity(); + void checkConnectivity(); }, []); return ( diff --git a/app/src/components/admin/copy-button.tsx b/app/src/components/admin/copy-button.tsx index cb81fd41..a8a01575 100644 --- a/app/src/components/admin/copy-button.tsx +++ b/app/src/components/admin/copy-button.tsx @@ -38,12 +38,12 @@ export function CopyButton({ setCopied(true); toast.success("Copied to clipboard"); setTimeout(() => setCopied(false), 2000); - } catch (err) { + } catch { toast.error("Failed to copy to clipboard"); } document.body.removeChild(textArea); } - } catch (err) { + } catch { toast.error("Failed to copy to clipboard"); } }; diff --git a/app/src/components/admin/event-image-card.tsx b/app/src/components/admin/event-image-card.tsx index e3ac700e..c0ca9a91 100644 --- a/app/src/components/admin/event-image-card.tsx +++ b/app/src/components/admin/event-image-card.tsx @@ -57,6 +57,7 @@ export function EventImageCard({
+ {/* oxlint-disable-next-line nextjs/no-img-element -- admin preview of the stored original; onError swaps in an inline SVG placeholder */} {image.imageAlt}
- {!!children ? ( + {children ? ( children ) : ( <> diff --git a/app/src/components/image-upload-crop.tsx b/app/src/components/image-upload-crop.tsx index 318cc3ea..cbaaeac6 100644 --- a/app/src/components/image-upload-crop.tsx +++ b/app/src/components/image-upload-crop.tsx @@ -1,7 +1,8 @@ "use client"; import { useState, useRef, useCallback } from "react"; -import ReactCrop, { +import { + ReactCrop, type Crop, type PixelCrop, centerCrop, @@ -52,7 +53,7 @@ export function ImageUploadCrop({ if (e.target.files && e.target.files.length > 0) { const reader = new FileReader(); reader.addEventListener("load", () => { - const imageUrl = reader.result?.toString() || ""; + const imageUrl = typeof reader.result === "string" ? reader.result : ""; setImgSrc(imageUrl); setIsDialogOpen(true); }); @@ -64,7 +65,7 @@ export function ImageUploadCrop({ (e: React.SyntheticEvent) => { const { naturalWidth: width, naturalHeight: height } = e.currentTarget; - const crop = centerCrop( + const centeredCrop = centerCrop( makeAspectCrop( { unit: "%", @@ -78,7 +79,7 @@ export function ImageUploadCrop({ height, ); - setCrop(crop); + setCrop(centeredCrop); }, [aspectRatio], ); @@ -86,7 +87,7 @@ export function ImageUploadCrop({ const getCroppedImg = useCallback( async ( image: HTMLImageElement, - crop: PixelCrop, + pixelCrop: PixelCrop, fileName: string, ): Promise => { const canvas = document.createElement("canvas"); @@ -100,8 +101,8 @@ export function ImageUploadCrop({ const scaleY = image.naturalHeight / image.height; // Set canvas size to the crop size, but respect max dimensions - const cropWidth = crop.width * scaleX; - const cropHeight = crop.height * scaleY; + const cropWidth = pixelCrop.width * scaleX; + const cropHeight = pixelCrop.height * scaleY; let finalWidth = cropWidth; let finalHeight = cropHeight; @@ -120,10 +121,10 @@ export function ImageUploadCrop({ ctx.drawImage( image, - crop.x * scaleX, - crop.y * scaleY, - crop.width * scaleX, - crop.height * scaleY, + pixelCrop.x * scaleX, + pixelCrop.y * scaleY, + pixelCrop.width * scaleX, + pixelCrop.height * scaleY, 0, 0, finalWidth, @@ -199,9 +200,10 @@ export function ImageUploadCrop({ {/* Current image preview */} {currentImageUrl && (
+ {/* oxlint-disable-next-line nextjs/no-img-element -- a 96px admin preview of the stored original; not worth an optimizer transform */} Current image
@@ -257,6 +259,7 @@ export function ImageUploadCrop({ minWidth={100} minHeight={aspectRatio === 1 ? 100 : 100 / aspectRatio} > + {/* oxlint-disable-next-line nextjs/no-img-element -- ReactCrop measures and crops this element; src is a local data: URL */} Crop me parseInt(b) - parseInt(a), ); return sortedYears.map((year) => ({ diff --git a/app/src/components/profile-button.tsx b/app/src/components/profile-button.tsx index 86725105..963a6d00 100644 --- a/app/src/components/profile-button.tsx +++ b/app/src/components/profile-button.tsx @@ -11,7 +11,7 @@ import { DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; -import { User, Settings, LogOut, Shield } from "lucide-react"; +import { User, LogOut, Shield } from "lucide-react"; import type { ClientUser } from "@/lib/client-user"; interface ProfileButtonProps { diff --git a/app/src/components/profile-card.tsx b/app/src/components/profile-card.tsx index f67c1e2c..c252a585 100644 --- a/app/src/components/profile-card.tsx +++ b/app/src/components/profile-card.tsx @@ -65,14 +65,14 @@ export function SocialsList({ const getUrls = () => { if (isUrlFormat) { - const urlSocials = socials as Socials; + const urlSocials = socials; return { twitter: urlSocials.twitterUrl, bluesky: urlSocials.blueskyUrl, linkedin: urlSocials.linkedinUrl, }; } else { - const handleSocials = socials as SpeakerSocials; + const handleSocials = socials; return { twitter: handleSocials.twitter ? `https://twitter.com/${handleSocials.twitter}` diff --git a/app/src/components/profile-form.tsx b/app/src/components/profile-form.tsx index 3e6ed545..bb173337 100644 --- a/app/src/components/profile-form.tsx +++ b/app/src/components/profile-form.tsx @@ -25,7 +25,8 @@ interface Profile { } export function ProfileForm() { - const user = useUser({ or: "redirect" }); + // Redirects signed-out visitors to sign-in. + useUser({ or: "redirect" }); const [profile, setProfile] = useState(null); const [isLoading, setIsLoading] = useState(true); const [isSaving, setSaving] = useState(false); @@ -67,7 +68,7 @@ export function ProfileForm() { } } - fetchProfile(); + void fetchProfile(); }, []); const handleInputChange = (field: string, value: string) => { diff --git a/app/src/components/top-nav.tsx b/app/src/components/top-nav.tsx index 48fdf1cc..f896f860 100644 --- a/app/src/components/top-nav.tsx +++ b/app/src/components/top-nav.tsx @@ -10,7 +10,6 @@ import { cn } from "@/lib/utils"; import Link from "next/link"; import { ModeToggle } from "@/components/theme-toggle"; import type { ClientUser } from "@/lib/client-user"; -import { Button } from "@/components/ui/button"; interface TopNavProps { authNav: React.ReactNode; diff --git a/app/src/components/ui/breadcrumb.tsx b/app/src/components/ui/breadcrumb.tsx index f63ae19a..1e96cb4d 100644 --- a/app/src/components/ui/breadcrumb.tsx +++ b/app/src/components/ui/breadcrumb.tsx @@ -53,6 +53,7 @@ function BreadcrumbPage({ className, ...props }: React.ComponentProps<"span">) { return ( without href has no link role role="link" aria-disabled="true" aria-current="page" diff --git a/app/src/components/ui/calendar.tsx b/app/src/components/ui/calendar.tsx index bf220321..c5b56e50 100644 --- a/app/src/components/ui/calendar.tsx +++ b/app/src/components/ui/calendar.tsx @@ -6,7 +6,12 @@ import { ChevronLeftIcon, ChevronRightIcon, } from "lucide-react"; -import { DayButton, DayPicker, getDefaultClassNames } from "react-day-picker"; +import { + DayButton, + DayPicker, + getDefaultClassNames, + type CustomComponents, +} from "react-day-picker"; import { cn } from "@/lib/utils"; import { Button, buttonVariants } from "@/components/ui/button"; @@ -125,46 +130,10 @@ function Calendar({ ...classNames, }} components={{ - Root: ({ className, rootRef, ...props }) => { - return ( -
- ); - }, - Chevron: ({ className, orientation, ...props }) => { - if (orientation === "left") { - return ( - - ); - } - - if (orientation === "right") { - return ( - - ); - } - - return ( - - ); - }, + Root: CalendarRoot, + Chevron: CalendarChevron, DayButton: CalendarDayButton, - WeekNumber: ({ children, ...props }) => { - return ( - -
- {children} -
- - ); - }, + WeekNumber: CalendarWeekNumber, ...components, }} {...props} @@ -172,6 +141,50 @@ function Calendar({ ); } +function CalendarRoot({ + className, + rootRef, + ...props +}: React.ComponentProps) { + return ( +
+ ); +} + +function CalendarChevron({ + className, + orientation, + ...props +}: React.ComponentProps) { + if (orientation === "left") { + return ; + } + + if (orientation === "right") { + return ; + } + + return ; +} + +function CalendarWeekNumber({ + children, + ...props +}: React.ComponentProps) { + return ( + +
+ {children} +
+ + ); +} + function CalendarDayButton({ className, day, diff --git a/app/src/components/ui/carousel.tsx b/app/src/components/ui/carousel.tsx index dc1985af..8fc8b449 100644 --- a/app/src/components/ui/carousel.tsx +++ b/app/src/components/ui/carousel.tsx @@ -61,10 +61,10 @@ function Carousel({ const [canScrollPrev, setCanScrollPrev] = React.useState(false); const [canScrollNext, setCanScrollNext] = React.useState(false); - const onSelect = React.useCallback((api: CarouselApi) => { - if (!api) return; - setCanScrollPrev(api.canScrollPrev()); - setCanScrollNext(api.canScrollNext()); + const onSelect = React.useCallback((carouselApi: CarouselApi) => { + if (!carouselApi) return; + setCanScrollPrev(carouselApi.canScrollPrev()); + setCanScrollNext(carouselApi.canScrollNext()); }, []); const scrollPrev = React.useCallback(() => { @@ -94,7 +94,7 @@ function Carousel({ }, [api, setApi]); React.useEffect(() => { - if (!api) return; + if (!api) return undefined; onSelect(api); api.on("reInit", onSelect); api.on("select", onSelect); @@ -121,6 +121,7 @@ function Carousel({
without an accessible name has no region role role="region" aria-roledescription="carousel" data-slot="carousel" @@ -158,6 +159,7 @@ function CarouselItem({ className, ...props }: React.ComponentProps<"div">) { return (
{ const colorConfig = Object.entries(config).filter( - ([, config]) => config.theme || config.color, + ([, itemConfig]) => itemConfig.theme || itemConfig.color, ); if (!colorConfig.length) { @@ -138,7 +138,7 @@ function ChartTooltipContent({ const itemConfig = getPayloadConfigFromPayload(config, item, key); const value = !labelKey && typeof label === "string" - ? config[label as keyof typeof config]?.label || label + ? config[label]?.label || label : itemConfig?.label; if (labelFormatter) { @@ -276,7 +276,7 @@ function ChartLegendContent({ )} > {payload.map((item) => { - const key = `${nameKey || item.dataKey || "value"}`; + const key = String(nameKey || item.dataKey || "value"); const itemConfig = getPayloadConfigFromPayload(config, item, key); return ( @@ -327,20 +327,16 @@ function getPayloadConfigFromPayload( key in payload && typeof payload[key as keyof typeof payload] === "string" ) { - configLabelKey = payload[key as keyof typeof payload] as string; + configLabelKey = payload[key as keyof typeof payload]; } else if ( payloadPayload && key in payloadPayload && typeof payloadPayload[key as keyof typeof payloadPayload] === "string" ) { - configLabelKey = payloadPayload[ - key as keyof typeof payloadPayload - ] as string; + configLabelKey = payloadPayload[key as keyof typeof payloadPayload]; } - return configLabelKey in config - ? config[configLabelKey] - : config[key as keyof typeof config]; + return configLabelKey in config ? config[configLabelKey] : config[key]; } export { diff --git a/app/src/components/ui/form.tsx b/app/src/components/ui/form.tsx index bf4214d8..fe0ab4cf 100644 --- a/app/src/components/ui/form.tsx +++ b/app/src/components/ui/form.tsx @@ -113,9 +113,7 @@ function FormControl({ ...props }: React.ComponentProps) { data-slot="form-control" id={formItemId} aria-describedby={ - !error - ? `${formDescriptionId}` - : `${formDescriptionId} ${formMessageId}` + !error ? formDescriptionId : `${formDescriptionId} ${formMessageId}` } aria-invalid={!!error} {...props} @@ -138,7 +136,7 @@ function FormDescription({ className, ...props }: React.ComponentProps<"p">) { function FormMessage({ className, ...props }: React.ComponentProps<"p">) { const { error, formMessageId } = useFormField(); - const body = error ? String(error?.message ?? "") : props.children; + const body = error ? (error.message ?? "") : props.children; if (!body) { return null; diff --git a/app/src/components/ui/input-otp.tsx b/app/src/components/ui/input-otp.tsx index 40f1aa03..33413f27 100644 --- a/app/src/components/ui/input-otp.tsx +++ b/app/src/components/ui/input-otp.tsx @@ -68,6 +68,7 @@ function InputOTPSlot({ function InputOTPSeparator({ ...props }: React.ComponentProps<"div">) { return ( + // oxlint-disable-next-line jsx-a11y/prefer-tag-over-role -- the separator wraps an icon, and
cannot have children
diff --git a/app/src/components/ui/pagination.tsx b/app/src/components/ui/pagination.tsx index 1621cf3b..0ca39f5b 100644 --- a/app/src/components/ui/pagination.tsx +++ b/app/src/components/ui/pagination.tsx @@ -46,6 +46,7 @@ function PaginationLink({ className, isActive, size = "icon", + children, ...props }: PaginationLinkProps) { return ( @@ -61,7 +62,9 @@ function PaginationLink({ className, )} {...props} - /> + > + {children} + ); } diff --git a/app/src/components/ui/sidebar.tsx b/app/src/components/ui/sidebar.tsx index 2eb8fb4c..cbf9ba99 100644 --- a/app/src/components/ui/sidebar.tsx +++ b/app/src/components/ui/sidebar.tsx @@ -90,7 +90,9 @@ function SidebarProvider({ // Helper to toggle the sidebar. const toggleSidebar = React.useCallback(() => { - return isMobile ? setOpenMobile((open) => !open) : setOpen((open) => !open); + return isMobile + ? setOpenMobile((wasOpen) => !wasOpen) + : setOpen((wasOpen) => !wasOpen); }, [isMobile, setOpen, setOpenMobile]); // Adds a keyboard shortcut to toggle the sidebar. diff --git a/app/src/components/ui/typing-animation.tsx b/app/src/components/ui/typing-animation.tsx index f0efdb0a..fed119b8 100644 --- a/app/src/components/ui/typing-animation.tsx +++ b/app/src/components/ui/typing-animation.tsx @@ -1,6 +1,6 @@ "use client"; -import clsx from "clsx"; +import { clsx } from "clsx"; import { useState, useEffect } from "react"; import { Card } from "./card"; diff --git a/app/src/hooks/use-carousel.ts b/app/src/hooks/use-carousel.ts index d8987b39..8a91fde1 100644 --- a/app/src/hooks/use-carousel.ts +++ b/app/src/hooks/use-carousel.ts @@ -17,9 +17,9 @@ export const usePrevNextButtons = (emblaApi: EmblaCarouselType | undefined) => { emblaApi.scrollNext(); }, [emblaApi]); - const onSelect = useCallback((emblaApi: EmblaCarouselType) => { - setPrevBtnDisabled(!emblaApi.canScrollPrev()); - setNextBtnDisabled(!emblaApi.canScrollNext()); + const onSelect = useCallback((api: EmblaCarouselType) => { + setPrevBtnDisabled(!api.canScrollPrev()); + setNextBtnDisabled(!api.canScrollNext()); }, []); useEffect(() => { diff --git a/app/src/lib/event-covers/image-signature.ts b/app/src/lib/event-covers/image-signature.ts index c25fb8d3..dad31475 100644 --- a/app/src/lib/event-covers/image-signature.ts +++ b/app/src/lib/event-covers/image-signature.ts @@ -4,7 +4,8 @@ const startsWith = ( offset = 0, ) => signature.every((byte, index) => bytes[offset + index] === byte); -const ascii = (text: string) => [...text].map((char) => char.charCodeAt(0)); +const ascii = (text: string) => + text.split("").map((char) => char.charCodeAt(0)); /** * Whether bytes begin like an image format we can process. Luma's CDN does diff --git a/app/src/lib/event-covers/luma-cover.ts b/app/src/lib/event-covers/luma-cover.ts index 3f22dc8b..302d5e30 100644 --- a/app/src/lib/event-covers/luma-cover.ts +++ b/app/src/lib/event-covers/luma-cover.ts @@ -49,6 +49,7 @@ export async function findLumaCoverUrl( error instanceof Error ? error.message : String(error); throw new Error( `Luma API: ${reason(apiError)}; public event data: ${reason(publicError)}`, + { cause: publicError }, ); } } diff --git a/app/src/lib/event-covers/runtime.ts b/app/src/lib/event-covers/runtime.ts index c687a34d..4da79e3d 100644 --- a/app/src/lib/event-covers/runtime.ts +++ b/app/src/lib/event-covers/runtime.ts @@ -28,8 +28,8 @@ export async function ingestMissingLumaCovers({ database: db, findCoverUrl: ({ lumaEventId }, options) => findLumaCoverUrl(lumaEventId, options, { - api: async (id, { signal }) => - (await luma.getEvent(id, { signal }))?.event.cover_url ?? null, + api: async (id, apiOptions) => + (await luma.getEvent(id, apiOptions))?.event.cover_url ?? null, publicData: publicLumaCoverUrl, }), download: (url, options) => downloadImage(url, coverHosts, options), diff --git a/app/src/lib/event-feeds.ts b/app/src/lib/event-feeds.ts index 5ca65087..3fd3129c 100644 --- a/app/src/lib/event-feeds.ts +++ b/app/src/lib/event-feeds.ts @@ -8,6 +8,7 @@ type FeedEvent = Pick< function escapeXml(value: string): string { return value .replace( + // oxlint-disable-next-line no-control-regex -- removes the control characters XML 1.0 forbids /[^\u0009\u000A\u000D\u0020-\uD7FF\uE000-\uFFFD\u{10000}-\u{10FFFF}]/gu, "", ) diff --git a/app/src/lib/image-gen/templates.tsx b/app/src/lib/image-gen/templates.tsx index 148929f8..f6947b39 100644 --- a/app/src/lib/image-gen/templates.tsx +++ b/app/src/lib/image-gen/templates.tsx @@ -1,3 +1,4 @@ +/* oxlint-disable nextjs/no-img-element -- Satori (next/og) renders these templates to PNG and only understands plain . */ import { toReadableDateTimeStr } from "@/lib/datetime"; import { Image } from "@/lib/events"; import { ExpandedEvent, Talk as ExpandedTalk } from "@/lib/expanded-events"; @@ -344,7 +345,7 @@ export function LandingPagePreview({ images }: { images: Image[] }) { Past event image + .toSorted((a, b) => when === "upcoming" ? a.startDate.getTime() - b.startDate.getTime() : b.startDate.getTime() - a.startDate.getTime(), diff --git a/app/src/lib/public-api/mappers.ts b/app/src/lib/public-api/mappers.ts index 042520b4..5f392d1d 100644 --- a/app/src/lib/public-api/mappers.ts +++ b/app/src/lib/public-api/mappers.ts @@ -70,7 +70,7 @@ export function htmlToPlainText(html: string): string { .replace(/]*>/gi, "\n- ") .replace(/|<\/(p|ul|ol|blockquote|pre|h[1-6])>/gi, "\n"); return sanitizeHtml(withBreaks, { allowedTags: [], allowedAttributes: {} }) - .replace(/&(amp|lt|gt|quot|#39|nbsp);/g, (entity) => entities[entity]!) + .replace(/&(amp|lt|gt|quot|#39|nbsp);/g, (entity) => entities[entity]) .replace(/[ \t]+\n/g, "\n") .replace(/\n{3,}/g, "\n\n") .trim(); diff --git a/app/src/lib/published-events.ts b/app/src/lib/published-events.ts index 6a4c3d87..df25aed5 100644 --- a/app/src/lib/published-events.ts +++ b/app/src/lib/published-events.ts @@ -1,4 +1,4 @@ -import { eq, and, desc } from "drizzle-orm"; +import { eq, desc } from "drizzle-orm"; import { db } from "@/lib/db"; import { eventsTable, imagesTable } from "@/lib/schema"; import { Event } from "@/lib/events"; diff --git a/app/src/lib/speaker-directory.ts b/app/src/lib/speaker-directory.ts index bcf09e21..04e71760 100644 --- a/app/src/lib/speaker-directory.ts +++ b/app/src/lib/speaker-directory.ts @@ -94,7 +94,7 @@ export async function getSpeakerDirectory( } return { speakers: [...speakers.values()], - talks: [...talks.values()].sort( + talks: [...talks.values()].toSorted( (a, b) => b.eventStart.getTime() - a.eventStart.getTime() || a.eventId.localeCompare(b.eventId) || diff --git a/app/tests/cover-source.test.ts b/app/tests/cover-source.test.ts index 71fbd2b1..a774fedd 100644 --- a/app/tests/cover-source.test.ts +++ b/app/tests/cover-source.test.ts @@ -6,7 +6,7 @@ const cover = "https://images.lumacdn.com/uploads/gl/cover.png"; function fakeFetch(...responses: Response[]) { const requested: string[] = []; const impl = (async (input: string | URL | Request) => { - requested.push(String(input)); + requested.push(input instanceof Request ? input.url : String(input)); const response = responses.shift(); if (!response) throw new Error("Unexpected request"); return response; diff --git a/app/tests/event-covers.test.ts b/app/tests/event-covers.test.ts index cc2c4d02..34321aa7 100644 --- a/app/tests/event-covers.test.ts +++ b/app/tests/event-covers.test.ts @@ -88,7 +88,7 @@ async function insertEvent( ...values, }) .returning(); - return row!; + return row; } describe("event cover ingestion", () => { @@ -126,11 +126,11 @@ describe("event cover ingestion", () => { .from(eventsTable) .innerJoin(imagesTable, eq(eventsTable.previewImage, imagesTable.id)) .where(eq(eventsTable.id, event.id)); - expect(updated!.images).toMatchObject({ + expect(updated.images).toMatchObject({ alt: "effect event cover", width: 800, height: 800, - url: `https://bucket.example/events/${event.id}/cover-${updated!.images.id}.png`, + url: `https://bucket.example/events/${event.id}/cover-${updated.images.id}.png`, }); expect(d.stored).toHaveLength(1); }); @@ -146,7 +146,7 @@ describe("event cover ingestion", () => { height: 630, }) .returning(); - await insertEvent("has-cover", { previewImage: manual!.id }); + await insertEvent("has-cover", { previewImage: manual.id }); await insertEvent("site-only", { lumaEventId: null }); const d = deps(); @@ -175,7 +175,7 @@ describe("event cover ingestion", () => { d.stored.push(key); await db .update(eventsTable) - .set({ previewImage: manual!.id }) + .set({ previewImage: manual.id }) .where(eq(eventsTable.id, event.id)); return `https://bucket.example/${key}`; }, @@ -186,7 +186,7 @@ describe("event cover ingestion", () => { .select() .from(eventsTable) .where(eq(eventsTable.id, event.id)); - expect(row!.previewImage).toBe(manual!.id); + expect(row.previewImage).toBe(manual.id); expect(await db.select().from(imagesTable)).toHaveLength(1); expect(d.removed).toEqual(d.stored); }); diff --git a/app/tests/image-signature.test.ts b/app/tests/image-signature.test.ts index fd658f86..d0c78c84 100644 --- a/app/tests/image-signature.test.ts +++ b/app/tests/image-signature.test.ts @@ -4,7 +4,9 @@ import { looksLikeImage } from "../src/lib/event-covers/image-signature"; const bytes = (...parts: (number[] | string)[]) => new Uint8Array( parts.flatMap((part) => - typeof part === "string" ? [...part].map((c) => c.charCodeAt(0)) : part, + typeof part === "string" + ? part.split("").map((c) => c.charCodeAt(0)) + : part, ), ); diff --git a/app/tests/luma-cover.test.ts b/app/tests/luma-cover.test.ts index 14d77bc1..1fcd69b1 100644 --- a/app/tests/luma-cover.test.ts +++ b/app/tests/luma-cover.test.ts @@ -50,7 +50,7 @@ describe("finding a Luma event's cover", () => { describe("Luma's public event data", () => { const respond = (body: unknown, status = 200) => (async (input: string | URL | Request) => { - expect(String(input)).toBe( + expect(input instanceof Request ? input.url : String(input)).toBe( "https://api.lu.ma/event/get?event_api_id=evt-TpDFOGNSBwCxU72", ); return new Response(JSON.stringify(body), { status }); diff --git a/app/tests/mcp.test.ts b/app/tests/mcp.test.ts index 61c982f1..4b7dd5d8 100644 --- a/app/tests/mcp.test.ts +++ b/app/tests/mcp.test.ts @@ -208,10 +208,10 @@ describe("public MCP server", () => { const details = eventSchema.parse(result.structuredContent); expect(details.url).toBe("https://allthingsweb.dev/effect-sf"); expect(details.rsvpUrl).toBe("https://lu.ma/event/evt-1"); - expect(details.talks[0]!.description).toBe( + expect(details.talks[0].description).toBe( "Effect & you\n\n- Typed errors\n- Concurrency", ); - expect(details.talks[0]!.speakers[0]).toEqual({ + expect(details.talks[0].speakers[0]).toEqual({ name: "Michael Arnaldi", title: "Creator of Effect", bio: "Founder.", @@ -231,7 +231,7 @@ describe("public MCP server", () => { const result = await callTool("get_event", { slug }); expect(result.isError).toBe(true); expect(result.structuredContent).toBeUndefined(); - expect(result.content[0]!.text).toContain(slug); + expect(result.content[0].text).toContain(slug); } }); @@ -289,8 +289,8 @@ describe("public MCP server", () => { ] as const) { const result = await callTool(tool, args, mcp); expect(result.isError).toBe(true); - expect(result.content[0]!.text).toContain("temporarily unavailable"); - expect(result.content[0]!.text).not.toContain("db.internal"); + expect(result.content[0].text).toContain("temporarily unavailable"); + expect(result.content[0].text).not.toContain("db.internal"); } expect(reported.map((r) => r.tool)).toEqual([ "list_events", diff --git a/app/tests/media-store.test.ts b/app/tests/media-store.test.ts index 9fc806cd..83255e27 100644 --- a/app/tests/media-store.test.ts +++ b/app/tests/media-store.test.ts @@ -10,7 +10,10 @@ const config: MediaStoreConfig = { function recordingFetch(status: number) { const calls: { url: string; init: RequestInit }[] = []; const impl = (async (input: string | URL | Request, init?: RequestInit) => { - calls.push({ url: String(input), init: init ?? {} }); + calls.push({ + url: input instanceof Request ? input.url : String(input), + init: init ?? {}, + }); return new Response(null, { status }); }) as typeof fetch; return { impl, calls }; diff --git a/app/tests/profile-photos.test.ts b/app/tests/profile-photos.test.ts index 48567b8c..d1a7d1a3 100644 --- a/app/tests/profile-photos.test.ts +++ b/app/tests/profile-photos.test.ts @@ -85,7 +85,7 @@ async function insertProfile( ...values, }) .returning(); - return row!; + return row; } describe("profile photo ingestion", () => { @@ -108,10 +108,10 @@ describe("profile photo ingestion", () => { .from(profilesTable) .innerJoin(imagesTable, eq(profilesTable.image, imagesTable.id)) .where(eq(profilesTable.id, profile.id)); - expect(row!.images).toMatchObject({ + expect(row.images).toMatchObject({ alt: "Simon Farshid", width: 460, - url: `https://bucket.example/profiles/simon-farshid-${row!.images.id}.jpeg`, + url: `https://bucket.example/profiles/simon-farshid-${row.images.id}.jpeg`, }); }); @@ -141,7 +141,7 @@ describe("profile photo ingestion", () => { height: 400, }) .returning(); - await insertProfile("Has Photo", { image: existing!.id }); + await insertProfile("Has Photo", { image: existing.id }); await insertProfile("No Source", { photoSourceUrl: null }); const d = deps(); expect(await ingestProfilePhotos(d)).toEqual({ ingested: [], failed: [] }); @@ -165,7 +165,7 @@ describe("profile photo ingestion", () => { d.stored.push(key); await db .update(profilesTable) - .set({ image: manual!.id }) + .set({ image: manual.id }) .where(eq(profilesTable.id, profile.id)); return `https://bucket.example/${key}`; }, diff --git a/app/tests/speaker-directory.test.ts b/app/tests/speaker-directory.test.ts index 1e9a2c72..0bbe3cfd 100644 --- a/app/tests/speaker-directory.test.ts +++ b/app/tests/speaker-directory.test.ts @@ -111,7 +111,7 @@ test("retains repeat appearances and co-speakers without duplicate profile talk [shared.id], [shared.id], ]); - expect(result.talks.map((t) => t.eventSlug).sort()).toEqual([ + expect(result.talks.map((t) => t.eventSlug).toSorted()).toEqual([ "first", "second", ]); @@ -141,7 +141,7 @@ test("orders every speaker's appearances newest first, including shared talks", const result = await getSpeakerDirectory(db, now); expect( result.talks - .filter((talk) => talk.speakerIds.includes(b.id)) - .map((talk) => talk.eventSlug), + .filter((entry) => entry.speakerIds.includes(b.id)) + .map((entry) => entry.eventSlug), ).toEqual(["newer", "older"]); }); diff --git a/app/tsconfig.json b/app/tsconfig.json index 6863917c..cdf80b85 100644 --- a/app/tsconfig.json +++ b/app/tsconfig.json @@ -12,6 +12,7 @@ "resolveJsonModule": true, "isolatedModules": true, "jsx": "react-jsx", + "types": ["bun"], "incremental": true, "plugins": [ { diff --git a/atw-cli/src/index.ts b/atw-cli/src/index.ts index de2a82ff..bb5cbacb 100644 --- a/atw-cli/src/index.ts +++ b/atw-cli/src/index.ts @@ -11,6 +11,7 @@ const opener = const exitCode = await run(process.argv.slice(2), { stdout: (text) => process.stdout.write(text), stderr: (text) => process.stderr.write(text), + // oxlint-disable-next-line typescript/no-unnecessary-boolean-literal-compare -- @types/node says boolean, but non-TTY streams leave isTTY undefined isTTY: process.stdout.isTTY === true, env: process.env, openUrl: async (url) => { diff --git a/atw-cli/tests/client.test.ts b/atw-cli/tests/client.test.ts index 23ab546a..715456a7 100644 --- a/atw-cli/tests/client.test.ts +++ b/atw-cli/tests/client.test.ts @@ -11,7 +11,10 @@ function respondWith( return { requests, fetch: async (_url, init) => { - requests.push(JSON.parse(String(init.body))); + if (typeof init.body !== "string") { + throw new Error("Expected a JSON string body"); + } + requests.push(JSON.parse(init.body)); const json = JSON.stringify({ jsonrpc: "2.0", id: 1, diff --git a/brand/src/contrast.ts b/brand/src/contrast.ts index 1f535c50..b13da70b 100644 --- a/brand/src/contrast.ts +++ b/brand/src/contrast.ts @@ -27,6 +27,7 @@ function screenLuminance([r, g, b]: Rgb): number { /** Near-black luminances are lifted toward this threshold (flare). */ const blackThreshold = 0.022; +// oxlint-disable-next-line oxc/approx-constant -- APCA 0.0.98G defines this exponent as exactly 1.414, not โˆš2 const blackClamp = 1.414; const softClamp = (y: number) => @@ -66,6 +67,6 @@ export function wcagContrast(a: string, b: string): number { const [lighter, darker] = [ relativeLuminance(rgb(a)), relativeLuminance(rgb(b)), - ].sort((x, y) => y - x); + ].toSorted((x, y) => y - x); return ((lighter ?? 0) + 0.05) / ((darker ?? 0) + 0.05); } diff --git a/brand/src/tokens.ts b/brand/src/tokens.ts index 06df3fbe..bb0278f5 100644 --- a/brand/src/tokens.ts +++ b/brand/src/tokens.ts @@ -147,7 +147,7 @@ function tokensOf( /** Every reference names a token that exists, and both modes define the same roles. */ function referenceIssues(file: TokenFileShape): ReadonlyArray { - const colors = new Set(tokensOf(file.color).map(([name]) => name)); + const colorNames = new Set(tokensOf(file.color).map(([name]) => name)); const fonts = new Set(["display", "mono"]); const issues: Array = []; for (const [role, token] of tokensOf(file.type)) { @@ -157,7 +157,7 @@ function referenceIssues(file: TokenFileShape): ReadonlyArray { } for (const mode of ["paper", "night"] as const) { for (const [role, token] of tokensOf(file.theme[mode])) { - if (!colors.has(referencedName(token.$value))) { + if (!colorNames.has(referencedName(token.$value))) { issues.push(`theme.${mode}.${role}: unknown color ${token.$value}`); } } @@ -173,7 +173,7 @@ function referenceIssues(file: TokenFileShape): ReadonlyArray { } file.$extensions["dev.allthings"].contrast.forEach((pair, index) => { for (const side of ["text", "background"] as const) { - if (!colors.has(referencedName(pair[side]))) { + if (!colorNames.has(referencedName(pair[side]))) { issues.push(`contrast[${index}].${side}: unknown color ${pair[side]}`); } } diff --git a/bun.lock b/bun.lock index ecdd3a6c..78df484d 100644 --- a/bun.lock +++ b/bun.lock @@ -4,6 +4,10 @@ "workspaces": { "": { "name": "allthings", + "devDependencies": { + "oxlint": "1.86.0", + "oxlint-tsgolint": "7.0.2003", + }, }, "brand": { "name": "allthings-brand", @@ -305,6 +309,56 @@ "@oxc-project/types": ["@oxc-project/types@0.146.0", "", {}, "sha512-XC0QsnnhVe7sLIWmYmdPw7x5P0h4W8vUU3Nv1ySgWXtvCz8NizoAEpGXA0sOYoJQV2Rl13LgURAHQ5cI5ILCSA=="], + "@oxlint-tsgolint/darwin-arm64": ["@oxlint-tsgolint/darwin-arm64@7.0.2003", "", { "os": "darwin", "cpu": "arm64" }, "sha512-TgV33rXr6ueXBwvc+0nssUkTBSXHJxv77I8p4RCjDjCnvexHtmoPiudVRDfj3S3puMDdeQdHW6jdUgUPy3nr/w=="], + + "@oxlint-tsgolint/darwin-x64": ["@oxlint-tsgolint/darwin-x64@7.0.2003", "", { "os": "darwin", "cpu": "x64" }, "sha512-hY3FMAjIaPDdK3FNxyRXRfHPOFeoBn6dmnLyKZgQ2IbTTD1adXhl6PfCIqHhCPvurigv7nf7mYuWZZ19MmJzmg=="], + + "@oxlint-tsgolint/linux-arm64": ["@oxlint-tsgolint/linux-arm64@7.0.2003", "", { "os": "linux", "cpu": "arm64" }, "sha512-eAET4JpyfBbg8SO0K74o4R55tEjVC292pIyxGOw2XGf8x/HrPNyxwQ478jMYOM54FIVOHc8sJ6VRHxluy6lucw=="], + + "@oxlint-tsgolint/linux-x64": ["@oxlint-tsgolint/linux-x64@7.0.2003", "", { "os": "linux", "cpu": "x64" }, "sha512-GXdyO/XyqDJ3s/llR/oOktLsYNjZtWSQBy0JMc+/0gsNPvTcseKPhn9c6KcFyWmnr6H4vljYALbujonqSzzEGw=="], + + "@oxlint-tsgolint/win32-arm64": ["@oxlint-tsgolint/win32-arm64@7.0.2003", "", { "os": "win32", "cpu": "arm64" }, "sha512-TWauXnPfet0VgpmrstoAK58eJ4gbuwPUuUTQmYQAzE/RG1CpnxXtrKUJULf6lyerPE4KN3gM+DflIA1hOH+38Q=="], + + "@oxlint-tsgolint/win32-x64": ["@oxlint-tsgolint/win32-x64@7.0.2003", "", { "os": "win32", "cpu": "x64" }, "sha512-DoRmfe7j8VqNlukp8liRVW45GQDhzRccNenjD/pdzelgtffW47pCMd1xbJLkPaPbKnTwID3onn3VZlL7JbebEA=="], + + "@oxlint/binding-android-arm-eabi": ["@oxlint/binding-android-arm-eabi@1.86.0", "", { "os": "android", "cpu": "arm" }, "sha512-63Ozq6yQn79B2UxjSZ9huJmI+3/ZPI7hxAe6TyFh9oQL447KnIMoffOdtog+h4KILbQr61ZRvFPW5gYbAUgpgQ=="], + + "@oxlint/binding-android-arm64": ["@oxlint/binding-android-arm64@1.86.0", "", { "os": "android", "cpu": "arm64" }, "sha512-JdLGp44phbf/0Du236Dt06yfK6Qw8xhTFeDvHJdjk167r59OfP5+BL31mgMYVnswS8e+j6tFuccSY0QDC/lgCw=="], + + "@oxlint/binding-darwin-arm64": ["@oxlint/binding-darwin-arm64@1.86.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-h+vkOr4ik6KLFCXdtNVEj9xfnXfNUpPoF95QjORdhiSpLqQePzcN81kGHjAgsw1/G7WBWK5KsFKbI2UK8EyzMA=="], + + "@oxlint/binding-darwin-x64": ["@oxlint/binding-darwin-x64@1.86.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-wbglRuH5eKsp68keTOOeKDHU1astWSRYFsh2bwKQTAPb44rD6kVgzYI1ZrAJRTc6/7PH55ZXFCQx0rQXrj0UdA=="], + + "@oxlint/binding-freebsd-x64": ["@oxlint/binding-freebsd-x64@1.86.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-yTQ7QSOlNfQsrfbeVweTiegQJLKPjUKgXp/JpGiFzza6Hp29S9LAoH77TM3L0RjK39iQRZB5lk5iq2t241Mfag=="], + + "@oxlint/binding-linux-arm-gnueabihf": ["@oxlint/binding-linux-arm-gnueabihf@1.86.0", "", { "os": "linux", "cpu": "arm" }, "sha512-QYJGy0E2Tv8GAayyBIeHFTwQb8u+zrvrOYrifuDGZDr9ad5XsDhiMScpZymhS9fwWjqrgG2ZUwT4vpY0ISqp4Q=="], + + "@oxlint/binding-linux-arm-musleabihf": ["@oxlint/binding-linux-arm-musleabihf@1.86.0", "", { "os": "linux", "cpu": "arm" }, "sha512-oQo83CafLT8p3qh0AGkN0tSUIh5Hm63mfDsoqUmBQUBnSZLNBAbyRaAftfGY5a0cKZ34dG6jAtMYgcY5kZqt5g=="], + + "@oxlint/binding-linux-arm64-gnu": ["@oxlint/binding-linux-arm64-gnu@1.86.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-EM6wy5c2UM12qPb7iDJDBAZvHyfEJGH6iosFgaFiaRZpirz99//EHcUyD2KAskz07x1TULuiAtsojf4nvWWksw=="], + + "@oxlint/binding-linux-arm64-musl": ["@oxlint/binding-linux-arm64-musl@1.86.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-vCiUQb9ZNzZalxYRU4mlZwUqT5f+c7Sk9DwUOplWHtik3Dl4r3DVnUB5tBeRuTAUe6MUVCTylNW7TkkN8J1oTw=="], + + "@oxlint/binding-linux-ppc64-gnu": ["@oxlint/binding-linux-ppc64-gnu@1.86.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-T0AzE89yjTYmOhB/hb6i+rWTpe2mYt+xiguBhs9DEPIJTZ1H8OYqsE4VxJHHw2Wlq8Mztwvp2P8NV87ZOuhPRw=="], + + "@oxlint/binding-linux-riscv64-gnu": ["@oxlint/binding-linux-riscv64-gnu@1.86.0", "", { "os": "linux", "cpu": "none" }, "sha512-s49r0K2f5Oc7fJoMUO1WkdhYK5Ob79uYRwWQ/XNoGysaOq3RUGmGUbHSuXiyydTTA+P9xEdDEnnlZVXiaSJhlw=="], + + "@oxlint/binding-linux-riscv64-musl": ["@oxlint/binding-linux-riscv64-musl@1.86.0", "", { "os": "linux", "cpu": "none" }, "sha512-JIa2tYR8ayb3nvcMSZzn4O/xTy13S7MHllu7cBD+B5qDQXXSRy/Vwk1aNpiTjZmBJGwKCHOy/NvnY5Xcwg27uQ=="], + + "@oxlint/binding-linux-s390x-gnu": ["@oxlint/binding-linux-s390x-gnu@1.86.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-XwXMyBsAuWZkhZU74UNcfQKr1CJJAKnS80dZzOnjl6A4fKcf91IjFgidegQugMHA5SN8ulwDXEDV1yPntjXgQQ=="], + + "@oxlint/binding-linux-x64-gnu": ["@oxlint/binding-linux-x64-gnu@1.86.0", "", { "os": "linux", "cpu": "x64" }, "sha512-C1WjukSyMnr66b+w1/tV8RFVv6d9v0MzDf4p9IxVXknqgmTHBgZh1pccN1eHzFr0b9Tbb3OXoPsAAdAuHAQfeA=="], + + "@oxlint/binding-linux-x64-musl": ["@oxlint/binding-linux-x64-musl@1.86.0", "", { "os": "linux", "cpu": "x64" }, "sha512-ap6KLmvC38c6MdYzsIh25cXQupqYvjd37tMNftzrX1DCtkX1Gcf2B+S2B17dD4lKa5c5gJog7DQJyDo82PBKyw=="], + + "@oxlint/binding-openharmony-arm64": ["@oxlint/binding-openharmony-arm64@1.86.0", "", { "os": "none", "cpu": "arm64" }, "sha512-996Q9w/2GLwNyy5Djs39Jt09aFlr4b7TPhoj7dvo5FHmcyiF68nPz73HNc5UAYKIYeK+M9JzPQ4Wg1r1dXvP6A=="], + + "@oxlint/binding-win32-arm64-msvc": ["@oxlint/binding-win32-arm64-msvc@1.86.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-9uTCHYTknNkug5+46ViSoUMuOuGWK61D87QnblU6CXaWKJC630Z7dbls61vdHxya/cR2ycQZ6Mm6ckgF6Cag5g=="], + + "@oxlint/binding-win32-ia32-msvc": ["@oxlint/binding-win32-ia32-msvc@1.86.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-a47ga7EKVfMU8rO0i1m3mGwfNRj3FhQyt1d/ukO/E9u1HHF3KpR3tyb3VhuJBGEVPF8uR4exSyHUyx7xbE+uxw=="], + + "@oxlint/binding-win32-x64-msvc": ["@oxlint/binding-win32-x64-msvc@1.86.0", "", { "os": "win32", "cpu": "x64" }, "sha512-az4dzifCRzES1crwu6pDFXzJTIanrdxXFL9TbiJWrx9LKcuacbR83aPvxDA1LKLNswi/RoqitC7Afcflhu3zBQ=="], + "@prisma/debug": ["@prisma/debug@7.2.0", "", {}, "sha512-YSGTiSlBAVJPzX4ONZmMotL+ozJwQjRmZweQNIq/ER0tQJKJynNkRB3kyvt37eOfsbMCXk3gnLF6J9OJ4QWftw=="], "@prisma/dev": ["@prisma/dev@0.20.0", "", { "dependencies": { "@electric-sql/pglite": "0.3.15", "@electric-sql/pglite-socket": "0.0.20", "@electric-sql/pglite-tools": "0.2.20", "@hono/node-server": "1.19.9", "@mrleebo/prisma-ast": "0.13.1", "@prisma/get-platform": "7.2.0", "@prisma/query-plan-executor": "7.2.0", "foreground-child": "3.3.1", "get-port-please": "3.2.0", "hono": "4.11.4", "http-status-codes": "2.3.0", "pathe": "2.0.3", "proper-lockfile": "4.1.2", "remeda": "2.33.4", "std-env": "3.10.0", "valibot": "1.2.0", "zeptomatch": "2.1.0" } }, "sha512-ovlBYwWor0OzG+yH4J3Ot+AneD818BttLA+Ii7wjbcLHUrnC4tbUPVGyNd3c/+71KETPKZfjhkTSpdS15dmXNQ=="], @@ -543,6 +597,10 @@ "nanoid": ["nanoid@3.3.19", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug=="], + "oxlint": ["oxlint@1.86.0", "", { "optionalDependencies": { "@oxlint/binding-android-arm-eabi": "1.86.0", "@oxlint/binding-android-arm64": "1.86.0", "@oxlint/binding-darwin-arm64": "1.86.0", "@oxlint/binding-darwin-x64": "1.86.0", "@oxlint/binding-freebsd-x64": "1.86.0", "@oxlint/binding-linux-arm-gnueabihf": "1.86.0", "@oxlint/binding-linux-arm-musleabihf": "1.86.0", "@oxlint/binding-linux-arm64-gnu": "1.86.0", "@oxlint/binding-linux-arm64-musl": "1.86.0", "@oxlint/binding-linux-ppc64-gnu": "1.86.0", "@oxlint/binding-linux-riscv64-gnu": "1.86.0", "@oxlint/binding-linux-riscv64-musl": "1.86.0", "@oxlint/binding-linux-s390x-gnu": "1.86.0", "@oxlint/binding-linux-x64-gnu": "1.86.0", "@oxlint/binding-linux-x64-musl": "1.86.0", "@oxlint/binding-openharmony-arm64": "1.86.0", "@oxlint/binding-win32-arm64-msvc": "1.86.0", "@oxlint/binding-win32-ia32-msvc": "1.86.0", "@oxlint/binding-win32-x64-msvc": "1.86.0" }, "peerDependencies": { "oxlint-tsgolint": ">=7.0.2003", "vite-plus": "*" }, "optionalPeers": ["oxlint-tsgolint", "vite-plus"], "bin": { "oxlint": "bin/oxlint" } }, "sha512-og0lhgvZfgGF//gOOmZXvtr+GmBbAGEnbEhv/QUg7UW2Wi4wHMnJbnMD+zuHgPdJxdklfgpPupdlAaAySyxrZg=="], + + "oxlint-tsgolint": ["oxlint-tsgolint@7.0.2003", "", { "optionalDependencies": { "@oxlint-tsgolint/darwin-arm64": "7.0.2003", "@oxlint-tsgolint/darwin-x64": "7.0.2003", "@oxlint-tsgolint/linux-arm64": "7.0.2003", "@oxlint-tsgolint/linux-x64": "7.0.2003", "@oxlint-tsgolint/win32-arm64": "7.0.2003", "@oxlint-tsgolint/win32-x64": "7.0.2003" }, "bin": { "tsgolint": "./bin/tsgolint.js" } }, "sha512-VnK4zlqgmgq/7ZcjzCk/WpN8kKFsYGcB85Io9qT3wL4K8Un3RKmJkp793crNETieMusPMKOA4a+Mw2wbteI6TQ=="], + "parse-srcset": ["parse-srcset@1.0.2", "", {}, "sha512-/2qh0lav6CmI15FzA3i/2Bzk2zCgQhGMkvhOhKNcBVQ1ldgpbfiNTVslmooUmWJcADi1f1kIeynbDRVzNlfR6Q=="], "path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="], diff --git a/core/src/mappers.ts b/core/src/mappers.ts index c2c9288b..cc3b7092 100644 --- a/core/src/mappers.ts +++ b/core/src/mappers.ts @@ -168,7 +168,7 @@ export function selectEvents< const isPast = eventStatus(event, now) === "past"; return when === "past" ? isPast : !isPast; }) - .sort( + .toSorted( (a, b) => direction * (DateTime.toEpochMillis(a.startDate) - diff --git a/core/src/speakers.ts b/core/src/speakers.ts index d423a3e7..98c6c31c 100644 --- a/core/src/speakers.ts +++ b/core/src/speakers.ts @@ -89,7 +89,7 @@ export const toDirectory = ( } return { speakers: [...speakers.values()], - talks: [...talks.values()].sort(newestFirst), + talks: [...talks.values()].toSorted(newestFirst), }; }; diff --git a/core/tests/parity.test.ts b/core/tests/parity.test.ts index e6fb9a96..b52eb93f 100644 --- a/core/tests/parity.test.ts +++ b/core/tests/parity.test.ts @@ -48,8 +48,10 @@ const { drizzle } = (await appPackage("drizzle-orm/pglite")) as { }; const appDb = drizzle({ client: db }); // The app reads its database and config from modules; point them here. -mock.module(new URL("src/lib/db.ts", app).pathname, () => ({ db: appDb })); -mock.module(new URL("src/lib/config.ts", app).pathname, () => ({ +await mock.module(new URL("src/lib/db.ts", app).pathname, () => ({ + db: appDb, +})); +await mock.module(new URL("src/lib/config.ts", app).pathname, () => ({ mainConfig: { s3: { url: "https://storage.example" } }, })); @@ -120,7 +122,7 @@ const SpeakerList = Schema.Struct({ speakers: Schema.Array(Contract.Speaker) }); const canonical = (values: ReadonlyArray): Array => values .map((value) => [JSON.stringify(value), value] as const) - .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)) + .toSorted(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)) .map(([, value]) => value); /** @@ -147,7 +149,7 @@ describe("core answers as the app's MCP tools do", () => { "list_events when=%s", async (when) => { const appResult = await callApp("list_events", { when, limit: 100 }); - const events = await runCore( + const summaries = await runCore( Events.use((events) => events.listPublished).pipe( Effect.map((rows) => Mappers.selectEvents(rows, when, now).map((row) => @@ -156,8 +158,8 @@ describe("core answers as the app's MCP tools do", () => { ), ), ); - expect(events.length).toBeGreaterThan(0); - expect(valid(EventList, { events })).toEqual( + expect(summaries.length).toBeGreaterThan(0); + expect(valid(EventList, { events: summaries })).toEqual( valid(EventList, appResult.structuredContent), ); }, @@ -213,13 +215,13 @@ describe("core answers as the app's MCP tools do", () => { test("list_speakers", async () => { const appResult = await callApp("list_speakers", { limit: 200 }); - const speakers = await runCore( + const coreSpeakers = await runCore( Speakers.use((speakers) => speakers.directory).pipe( Effect.map((directory) => Mappers.toSpeakers(directory, origin)), ), ); - expect(speakers.length).toBeGreaterThan(0); - expect(valid(SpeakerList, { speakers })).toEqual( + expect(coreSpeakers.length).toBeGreaterThan(0); + expect(valid(SpeakerList, { speakers: coreSpeakers })).toEqual( valid(SpeakerList, appResult.structuredContent), ); }); diff --git a/core/tests/repositories.test.ts b/core/tests/repositories.test.ts index 7a805392..d0fe332f 100644 --- a/core/tests/repositories.test.ts +++ b/core/tests/repositories.test.ts @@ -142,8 +142,8 @@ const serverComponents = { describe("Events", () => { test("lists published events only, latest start first", async () => { - const events = await run(Events.use((events) => events.listPublished)); - expect(events.map((event) => event.slug)).toEqual([ + const published = await run(Events.use((events) => events.listPublished)); + expect(published.map((event) => event.slug)).toEqual([ "2026-11-05-upcoming", "2026-10-03-hack-day", "2026-10-03-ends-now", @@ -153,9 +153,9 @@ describe("Events", () => { }); test("reads an event's columns and preview image", async () => { - const events = await run(Events.use((events) => events.listPublished)); + const published = await run(Events.use((events) => events.listPublished)); expect( - events.find((event) => event.slug === "2026-08-12-react-at-acme"), + published.find((event) => event.slug === "2026-08-12-react-at-acme"), ).toEqual({ id: "e0000000-0000-4000-8000-000000000001", slug: "2026-08-12-react-at-acme", @@ -172,7 +172,7 @@ describe("Events", () => { previewImage: cover, }); expect( - events.find((event) => event.slug === "2026-10-03-hack-day"), + published.find((event) => event.slug === "2026-10-03-hack-day"), ).toMatchObject({ shortLocation: null, fullAddress: null, diff --git a/core/tests/support/database.ts b/core/tests/support/database.ts index 0032a5fb..c89c76c3 100644 --- a/core/tests/support/database.ts +++ b/core/tests/support/database.ts @@ -53,7 +53,7 @@ export async function migrate( const journal = Schema.decodeUnknownSync(Journal)( await Bun.file(new URL("meta/_journal.json", migrations)).json(), ); - const entries = [...journal.entries].sort((a, b) => a.idx - b.idx); + const entries = journal.entries.toSorted((a, b) => a.idx - b.idx); // Neon Auth creates neon_auth.users_sync before any migration ran, and 0001 // already references it. 0010 records that table's definition (IF NOT diff --git a/infra/src/media-upload.ts b/infra/src/media-upload.ts index 30148623..2539a82e 100644 --- a/infra/src/media-upload.ts +++ b/infra/src/media-upload.ts @@ -46,9 +46,11 @@ export default { switch (request.method) { case "PUT": { const contentType = request.headers.get("content-type"); - const object = await env.MEDIA.put(key, request.body, { - ...(contentType ? { httpMetadata: { contentType } } : {}), - }); + const object = await env.MEDIA.put( + key, + request.body, + contentType ? { httpMetadata: { contentType } } : {}, + ); return Response.json( { key: object.key, size: object.size }, { status: 201 }, diff --git a/infra/src/upload-worker.ts b/infra/src/upload-worker.ts index db4f4572..646694d4 100644 --- a/infra/src/upload-worker.ts +++ b/infra/src/upload-worker.ts @@ -28,11 +28,7 @@ export type MediaUploadEnv = Cloudflare.InferEnv; */ export const MediaUploadCheck = Alchemy.Action( "MediaUploadCheck", - (input: { - url: string; - token: Redacted.Redacted; - workerHash: string; - }) => { + (input: { url: string; token: Redacted.Redacted; workerHash: string }) => { const object = `${input.url}/deploy-checks/${input.workerHash}.txt`; const authorization = `Bearer ${Redacted.value(input.token)}`; const expect = (method: "PUT" | "DELETE", status: number) => diff --git a/infra/src/vercel-env.ts b/infra/src/vercel-env.ts index 992f0782..a21fd761 100644 --- a/infra/src/vercel-env.ts +++ b/infra/src/vercel-env.ts @@ -13,7 +13,7 @@ const PROJECT = "--scope andrelandgraf --project allthingsweb"; */ export const VercelEnv = ( name: string, - value: Input>, + value: Input, { sensitive, targets }: { sensitive: boolean; targets: readonly string[] }, ) => Command.Exec(`Vercel${name}`, { diff --git a/package.json b/package.json index 371e35f9..9c2659ab 100644 --- a/package.json +++ b/package.json @@ -1,5 +1,12 @@ { "name": "allthings", "private": true, - "workspaces": ["brand", "core", "tools/xss-scan", "web"] + "workspaces": ["brand", "core", "tools/xss-scan", "web"], + "scripts": { + "lint": "oxlint" + }, + "devDependencies": { + "oxlint": "1.86.0", + "oxlint-tsgolint": "7.0.2003" + } } diff --git a/tools/pr-header/src/main.ts b/tools/pr-header/src/main.ts index c0b82723..923c6df3 100644 --- a/tools/pr-header/src/main.ts +++ b/tools/pr-header/src/main.ts @@ -82,7 +82,7 @@ async function refresh( ); } catch (error) { console.warn( - `#${number}: no stable preview: ${error instanceof Error ? error.message : error}`, + `#${number}: no stable preview: ${error instanceof Error ? error.message : String(error)}`, ); } } @@ -142,7 +142,7 @@ async function main(): Promise { } catch (error) { failed = true; console.error( - `#${number}: ${error instanceof Error ? error.message : error}`, + `#${number}: ${error instanceof Error ? error.message : String(error)}`, ); } } diff --git a/tools/pr-header/src/render.ts b/tools/pr-header/src/render.ts index b7bfc73d..f718ad2d 100644 --- a/tools/pr-header/src/render.ts +++ b/tools/pr-header/src/render.ts @@ -33,7 +33,7 @@ function badge({ label, message, tone, href, logo }: Badge): string { style: "flat-square", }); if (logo) params.set("logo", logo); - return `[![${label}: ${message}](https://img.shields.io/static/v1?${params})](${href})`; + return `[![${label}: ${message}](https://img.shields.io/static/v1?${params.toString()})](${href})`; } export function changeStackUrl(pr: Pick): string { diff --git a/tools/pr-header/src/vercel.ts b/tools/pr-header/src/vercel.ts index 9c619347..cb212280 100644 --- a/tools/pr-header/src/vercel.ts +++ b/tools/pr-header/src/vercel.ts @@ -9,13 +9,14 @@ export function previewAlias(prNumber: number): string { return `allthings-pr-${prNumber}.vercel.app`; } -async function call( +/** Vercel's JSON response, unvalidated; callers state the shape they expect. */ +async function call( config: VercelConfig, method: "GET" | "POST", path: string, fetchImpl: typeof fetch, body?: unknown, -): Promise<{ status: number; data: T | null }> { +): Promise<{ status: number; data: unknown }> { const url = new URL(`https://api.vercel.com${path}`); url.searchParams.set("teamId", config.teamId); const response = await fetchImpl(url, { @@ -32,7 +33,7 @@ async function call( `Vercel ${method} ${path}: ${response.status} ${await response.text()}`, ); } - return { status: response.status, data: (await response.json()) as T }; + return { status: response.status, data: await response.json() }; } /** Points the PR's alias at a deployment, given its *.vercel.app URL. */ @@ -43,12 +44,9 @@ export async function assignPreviewAlias( fetchImpl: typeof fetch = fetch, ): Promise { const host = new URL(deploymentUrl).host; - const { data } = await call<{ id: string; projectId: string }>( - config, - "GET", - `/v13/deployments/${host}`, - fetchImpl, - ); + const data = ( + await call(config, "GET", `/v13/deployments/${host}`, fetchImpl) + ).data as { id: string; projectId: string } | null; if (!data || data.projectId !== config.projectId) { throw new Error(`${host} is not a deployment of this project`); } @@ -69,22 +67,19 @@ export async function stablePreviewUrl( ): Promise { if (!deploymentUrl) return null; const [deployment, target] = await Promise.all([ - call<{ id: string }>( + call( config, "GET", `/v13/deployments/${new URL(deploymentUrl).host}`, fetchImpl, - ), - call<{ projectId: string; deploymentId: string }>( - config, - "GET", - `/v4/aliases/${alias}`, - fetchImpl, + ).then(({ data }) => data as { id: string } | null), + call(config, "GET", `/v4/aliases/${alias}`, fetchImpl).then( + ({ data }) => data as { projectId: string; deploymentId: string } | null, ), ]); const current = - target.data?.projectId === config.projectId && - deployment.data !== null && - target.data.deploymentId === deployment.data.id; + target?.projectId === config.projectId && + deployment !== null && + target.deploymentId === deployment.id; return current ? `https://${alias}` : null; } diff --git a/tools/pr-header/tests/vercel.test.ts b/tools/pr-header/tests/vercel.test.ts index 23bbaa22..4c72d3e1 100644 --- a/tools/pr-header/tests/vercel.test.ts +++ b/tools/pr-header/tests/vercel.test.ts @@ -12,18 +12,24 @@ const config: VercelConfig = { projectId: "prj_1", }; +/** The client sends JSON strings; any other body is a bug worth failing on. */ +function jsonText(body: RequestInit["body"]): string { + if (typeof body !== "string") throw new Error("Expected a JSON string body"); + return body; +} + function fakeVercel( routes: Record, ) { const calls: { method: string; path: string; body?: unknown }[] = []; const impl = (async (input: string | URL | Request, init?: RequestInit) => { - const url = new URL(String(input)); + const url = new URL(input instanceof Request ? input.url : input); expect(url.searchParams.get("teamId")).toBe("team_1"); const method = init?.method ?? "GET"; calls.push({ method, path: url.pathname, - ...(init?.body ? { body: JSON.parse(String(init.body)) } : {}), + ...(init?.body ? { body: JSON.parse(jsonText(init.body)) } : {}), }); const route = routes[`${method} ${url.pathname}`]; if (!route) throw new Error(`Unexpected ${method} ${url.pathname}`); diff --git a/web/.oxlintrc.json b/web/.oxlintrc.json new file mode 100644 index 00000000..08e6868e --- /dev/null +++ b/web/.oxlintrc.json @@ -0,0 +1,13 @@ +{ + "$schema": "../node_modules/oxlint/configuration_schema.json", + // The root rule set, plus accessibility checks for the pages' JSX, which + // renders straight to HTML. A nested config because `overrides` cannot + // enable categories. + "extends": ["../.oxlintrc.json"], + "plugins": ["typescript", "unicorn", "oxc", "import", "promise", "jsx-a11y"], + "rules": { + // Cell text sits three elements deep inside its table cell. + "jsx-a11y/label-has-associated-control": ["error", { "depth": 3 }], + "jsx-a11y/control-has-associated-label": ["error", { "depth": 3 }] + } +} diff --git a/web/src/pages/brand.tsx b/web/src/pages/brand.tsx index 6d2a72d1..e007203f 100644 --- a/web/src/pages/brand.tsx +++ b/web/src/pages/brand.tsx @@ -103,6 +103,7 @@ function Palette() { ))}

Pairings and their targets

+ {/* oxlint-disable-next-line jsx-a11y/no-noninteractive-tabindex -- keyboard users need focus to scroll this wide table (WCAG 2.1.1) */}
diff --git a/web/src/pages/response.ts b/web/src/pages/response.ts index b9926bc2..85bd4dfd 100644 --- a/web/src/pages/response.ts +++ b/web/src/pages/response.ts @@ -21,7 +21,7 @@ export const contentSecurityPolicy = [ * without a readable one weighs 1. */ function weights(acceptEncoding: string): Map { - const weights = new Map(); + const byCoding = new Map(); for (const part of acceptEncoding.split(",")) { const [coding = "", ...parameters] = part .split(";") @@ -31,9 +31,9 @@ function weights(acceptEncoding: string): Map { .map((parameter) => /^q\s*=\s*([0-9.]+)$/i.exec(parameter)?.[1]) .find((value) => value !== undefined); const weight = q === undefined ? Number.NaN : Number(q); - weights.set(coding.toLowerCase(), Number.isNaN(weight) ? 1 : weight); + byCoding.set(coding.toLowerCase(), Number.isNaN(weight) ? 1 : weight); } - return weights; + return byCoding; } /** diff --git a/web/tests/support/app.ts b/web/tests/support/app.ts index 77b11535..477fa065 100644 --- a/web/tests/support/app.ts +++ b/web/tests/support/app.ts @@ -39,8 +39,10 @@ export async function loadApp( }; const appDb = drizzle({ client: db }); // The app reads its database and config from modules; point them here. - mock.module(new URL("src/lib/db.ts", app).pathname, () => ({ db: appDb })); - mock.module(new URL("src/lib/config.ts", app).pathname, () => ({ + await mock.module(new URL("src/lib/db.ts", app).pathname, () => ({ + db: appDb, + })); + await mock.module(new URL("src/lib/config.ts", app).pathname, () => ({ mainConfig: { instance: { origin: settings.origin }, }, diff --git a/web/tests/support/order.ts b/web/tests/support/order.ts index 6c994d30..1e80ba1e 100644 --- a/web/tests/support/order.ts +++ b/web/tests/support/order.ts @@ -18,7 +18,7 @@ export type Json = const canonical = (values: ReadonlyArray): Array => values .map((value) => [JSON.stringify(value), value] as const) - .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)) + .toSorted(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)) .map(([, value]) => value); /** `event` with its attached lists, and each talk's speakers, in canonical order. */ From 8d72b2d99e5266169ea96c284881e9b7a4df2bd1 Mon Sep 17 00:00:00 2001 From: Erik Thorelli Date: Sun, 4 Oct 2026 06:32:19 -0700 Subject: [PATCH 2/2] Check Vercel's response fields before using them The pr-header's Vercel client now reads each field it needs as a string instead of casting the JSON. A deployment without an id is never aliased, and two responses that both lack ids no longer count as a match. --- tools/pr-header/src/vercel.ts | 34 ++++++++++++++++++---------- tools/pr-header/tests/vercel.test.ts | 34 ++++++++++++++++++++++++++++ 2 files changed, 56 insertions(+), 12 deletions(-) diff --git a/tools/pr-header/src/vercel.ts b/tools/pr-header/src/vercel.ts index cb212280..0591931e 100644 --- a/tools/pr-header/src/vercel.ts +++ b/tools/pr-header/src/vercel.ts @@ -9,7 +9,7 @@ export function previewAlias(prNumber: number): string { return `allthings-pr-${prNumber}.vercel.app`; } -/** Vercel's JSON response, unvalidated; callers state the shape they expect. */ +/** Vercel's JSON response, unvalidated: callers check each field they use. */ async function call( config: VercelConfig, method: "GET" | "POST", @@ -36,6 +36,13 @@ async function call( return { status: response.status, data: await response.json() }; } +/** `value[key]` if `value` is an object with a string there, else undefined. */ +function stringField(value: unknown, key: string): string | undefined { + if (typeof value !== "object" || value === null) return undefined; + const field: unknown = Reflect.get(value, key); + return typeof field === "string" ? field : undefined; +} + /** Points the PR's alias at a deployment, given its *.vercel.app URL. */ export async function assignPreviewAlias( config: VercelConfig, @@ -44,13 +51,17 @@ export async function assignPreviewAlias( fetchImpl: typeof fetch = fetch, ): Promise { const host = new URL(deploymentUrl).host; - const data = ( - await call(config, "GET", `/v13/deployments/${host}`, fetchImpl) - ).data as { id: string; projectId: string } | null; - if (!data || data.projectId !== config.projectId) { + const { data } = await call( + config, + "GET", + `/v13/deployments/${host}`, + fetchImpl, + ); + const id = stringField(data, "id"); + if (id === undefined || stringField(data, "projectId") !== config.projectId) { throw new Error(`${host} is not a deployment of this project`); } - await call(config, "POST", `/v2/deployments/${data.id}/aliases`, fetchImpl, { + await call(config, "POST", `/v2/deployments/${id}/aliases`, fetchImpl, { alias, }); } @@ -72,14 +83,13 @@ export async function stablePreviewUrl( "GET", `/v13/deployments/${new URL(deploymentUrl).host}`, fetchImpl, - ).then(({ data }) => data as { id: string } | null), - call(config, "GET", `/v4/aliases/${alias}`, fetchImpl).then( - ({ data }) => data as { projectId: string; deploymentId: string } | null, ), + call(config, "GET", `/v4/aliases/${alias}`, fetchImpl), ]); + const deploymentId = stringField(deployment.data, "id"); const current = - target?.projectId === config.projectId && - deployment !== null && - target.deploymentId === deployment.id; + deploymentId !== undefined && + stringField(target.data, "projectId") === config.projectId && + stringField(target.data, "deploymentId") === deploymentId; return current ? `https://${alias}` : null; } diff --git a/tools/pr-header/tests/vercel.test.ts b/tools/pr-header/tests/vercel.test.ts index 4c72d3e1..64c9eb27 100644 --- a/tools/pr-header/tests/vercel.test.ts +++ b/tools/pr-header/tests/vercel.test.ts @@ -84,6 +84,24 @@ describe("stable preview aliases", () => { expect(calls.map((c) => c.method)).toEqual(["GET"]); }); + test("never alias a deployment Vercel describes without an id", async () => { + const { impl, calls } = fakeVercel({ + "GET /v13/deployments/odd.vercel.app": { + status: 200, + body: { projectId: "prj_1" }, + }, + }); + await expect( + assignPreviewAlias( + config, + "https://odd.vercel.app", + previewAlias(52), + impl, + ), + ).rejects.toThrow("not a deployment of this project"); + expect(calls.map((c) => c.method)).toEqual(["GET"]); + }); + describe("are linked only when they point at the current deployment", () => { const current = "https://allthingsweb-new-team.vercel.app"; const routes = (alias: { status: number; body?: unknown }) => ({ @@ -146,5 +164,21 @@ describe("stable preview aliases", () => { ).toBeNull(); expect(calls).toEqual([]); }); + + test("not linked when neither response names a deployment id", async () => { + const { impl } = fakeVercel({ + "GET /v13/deployments/allthingsweb-new-team.vercel.app": { + status: 200, + body: { projectId: "prj_1" }, + }, + "GET /v4/aliases/allthings-pr-52.vercel.app": { + status: 200, + body: { projectId: "prj_1" }, + }, + }); + expect( + await stablePreviewUrl(config, previewAlias(52), current, impl), + ).toBeNull(); + }); }); });