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({
-
+
Speakers (select at least one)
-
+
{selectedSpeakerIds.length} selected
@@ -341,9 +341,9 @@ export default function CreateTalkForm({
-
+
Speakers
-
+
{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 */}
- {!!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 */}
@@ -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 */}
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[] }) {
+ .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 `[](${href})`;
+ return `[})](${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..0591931e 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 check each field they use. */
+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,14 @@ 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() };
+}
+
+/** `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. */
@@ -43,16 +51,17 @@ export async function assignPreviewAlias(
fetchImpl: typeof fetch = fetch,
): Promise {
const host = new URL(deploymentUrl).host;
- const { data } = await call<{ id: string; projectId: string }>(
+ const { data } = await call(
config,
"GET",
`/v13/deployments/${host}`,
fetchImpl,
);
- if (!data || data.projectId !== config.projectId) {
+ 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,
});
}
@@ -69,22 +78,18 @@ 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,
- ),
+ call(config, "GET", `/v4/aliases/${alias}`, fetchImpl),
]);
+ const deploymentId = stringField(deployment.data, "id");
const current =
- target.data?.projectId === config.projectId &&
- deployment.data !== null &&
- target.data.deploymentId === deployment.data.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 23bbaa22..64c9eb27 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}`);
@@ -78,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 }) => ({
@@ -140,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();
+ });
});
});
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) */}