From 57f90600f8d036bf2b67ba4cc8d62a1124bce2d8 Mon Sep 17 00:00:00 2001 From: Logan McAnsh Date: Sat, 4 Apr 2026 01:05:16 -0400 Subject: [PATCH 01/10] chore: install remix cli preview and install skills Signed-off-by: Logan McAnsh --- .agents/skills/remix-project-layout/SKILL.md | 182 ++++ .../scripts/bootstrap_remix_application.ts | 965 ++++++++++++++++++ .../skills/remix-project-layout/tsconfig.json | 13 + .agents/skills/remix-ui/SKILL.md | 62 ++ .../remix-ui/references/animate-elements.md | 110 ++ .../remix-ui/references/component-model.md | 72 ++ .../remix-ui/references/create-mixins.md | 106 ++ .../references/hydration-frames-navigation.md | 128 +++ .../references/mixins-styling-events.md | 92 ++ .../remix-ui/references/testing-patterns.md | 40 + package.json | 1 + pnpm-lock.yaml | 22 + pnpm-workspace.yaml | 1 + 13 files changed, 1794 insertions(+) create mode 100644 .agents/skills/remix-project-layout/SKILL.md create mode 100644 .agents/skills/remix-project-layout/scripts/bootstrap_remix_application.ts create mode 100644 .agents/skills/remix-project-layout/tsconfig.json create mode 100644 .agents/skills/remix-ui/SKILL.md create mode 100644 .agents/skills/remix-ui/references/animate-elements.md create mode 100644 .agents/skills/remix-ui/references/component-model.md create mode 100644 .agents/skills/remix-ui/references/create-mixins.md create mode 100644 .agents/skills/remix-ui/references/hydration-frames-navigation.md create mode 100644 .agents/skills/remix-ui/references/mixins-styling-events.md create mode 100644 .agents/skills/remix-ui/references/testing-patterns.md diff --git a/.agents/skills/remix-project-layout/SKILL.md b/.agents/skills/remix-project-layout/SKILL.md new file mode 100644 index 0000000..1e107c0 --- /dev/null +++ b/.agents/skills/remix-project-layout/SKILL.md @@ -0,0 +1,182 @@ +--- +name: remix-project-layout +description: Describe the ideal layout of a Remix application, including canonical directories, route ownership, naming conventions, and file locations on disk. When asked to bootstrap that layout in a new directory, run the bundled TypeScript script. +--- + +# Remix Project Layout + +Use this skill when defining, reviewing, or bootstrapping the on-disk layout of a Remix +application. + +This skill is about structure and conventions. It defines where code belongs, how route ownership +maps to files on disk, and how a Remix app should be organized as it grows. When the user wants a +new app scaffolded, run the bundled script instead of recreating the starter files by hand. + +## Root Layout + +Use these root directories consistently: + +- `app/` for runtime application code +- `db/` for database artifacts such as migrations and SQLite files +- `public/` for static files served as-is +- `test/` for shared test helpers, fixtures, and cross-app integration coverage +- `tmp/` for runtime scratch files such as uploads, caches, or local session files + +## App Layout + +Inside `app/`, organize code by responsibility: + +- `assets/` for client entrypoints and client-owned behavior +- `controllers/` for route-owned handlers and route-local UI +- `data/` for schema, queries, persistence setup, and runtime data initialization +- `middleware/` for request lifecycle concerns such as auth, sessions, database injection, and + uploads +- `ui/` for shared cross-route UI primitives +- `utils/` for genuinely cross-layer runtime helpers +- `routes.ts` for the route contract +- `router.ts` for router setup and route wiring + +## Placement Precedence + +When code could plausibly live in more than one place, use this order of precedence: + +1. Put code in the narrowest owner first. +2. If it belongs to one route, keep it with that route. +3. If it is reused across route areas but is still UI, move it to `app/ui/`. +4. If it is part of request lifecycle setup, keep it in `app/middleware/`. +5. If it is schema, query, persistence, or startup data logic, keep it in `app/data/`. +6. Use `app/utils/` only when the code is genuinely cross-layer and does not clearly belong to one + of the other app layers. + +Prefer moving code to a narrower owner over introducing generic shared buckets. + +## Route Ownership + +The disk layout should make it possible to start from a route key in `app/routes.ts` and find the +implementation immediately. + +### Flat Leaf Routes + +Use a flat file in `app/controllers/` when a route is implemented by one exported `BuildAction`. + +Examples: + +- `routes.home` -> `app/controllers/home.tsx` +- `routes.about` -> `app/controllers/about.tsx` +- `routes.search` -> `app/controllers/search.tsx` +- `routes.uploads` -> `app/controllers/uploads.tsx` + +Flat leaf route files are self-contained. If a helper component is used only by that route, keep it +in the same module. + +### Controller Folders + +Use a folder with `controller.tsx` when the route is implemented by a `Controller`, owns nested +child routes, or owns multiple actions such as `index`, `action`, `show`, or `update`. + +Examples: + +- `routes.contact` -> `app/controllers/contact/controller.tsx` +- `routes.auth` -> `app/controllers/auth/controller.tsx` +- `routes.account` -> `app/controllers/account/controller.tsx` +- `routes.cart` -> `app/controllers/cart/controller.tsx` + +### Nested Route Objects + +If a route is nested in `app/routes.ts`, mirror that nesting on disk. + +Examples: + +- `routes.auth.login` -> `app/controllers/auth/login/controller.tsx` +- `routes.account.settings` -> `app/controllers/account/settings/controller.tsx` +- `routes.account.orders` -> `app/controllers/account/orders/controller.tsx` +- `routes.cart.api` -> `app/controllers/cart/api/controller.tsx` +- `routes.admin.users` -> `app/controllers/admin/users/controller.tsx` + +### Shared UI + +If UI is reused across route areas, it belongs in `app/ui/`, not under `app/controllers/`. + +Examples: + +- `app/ui/document.tsx` +- `app/ui/layout.tsx` +- `app/ui/form-field.tsx` +- `app/ui/restful-form.tsx` + +Only keep a component inside a controller folder when that UI is owned by that route or controller +feature. + +### Route-Local UI + +If a page component or helper is used only by one action or controller feature, keep it next to the +owning route. + +Examples: + +- `app/controllers/contact/page.tsx` +- `app/controllers/auth/login/page.tsx` +- `app/controllers/admin/books/form.tsx` +- `app/controllers/books/index-page.tsx` + +For flat leaf actions, route-local UI lives in the same file as the action. + +### Promotion Rule + +If a route starts as a flat leaf file and later grows child routes or multiple actions, promote it +from: + +- `app/controllers/home.tsx` + +to: + +- `app/controllers/home/controller.tsx` + +Do this only when the route actually needs it. Do not create controller folders preemptively for +every leaf route. + +## Naming Conventions + +Use predictable file names so route ownership is obvious without opening files: + +- `controller.tsx` for controller entrypoints +- `page.tsx` for a single controller-owned page module +- `index-page.tsx`, `show-page.tsx`, `edit-page.tsx`, and `form.tsx` for resource-style controller + UI +- flat action files named after the route key, such as `home.tsx`, `about.tsx`, `search.tsx`, or + `uploads.tsx` +- colocated tests named after the route owner, such as `home.test.ts` or `controller.test.ts` + +Do not invent one-off naming schemes when an existing convention already fits. + +## Bootstrap + +When the user wants this layout scaffolded into a new directory, run: + +```sh +node skills/remix-project-layout/scripts/bootstrap_remix_application.ts +``` + +Optional flags: + +- `--app-name ` to override the generated app name +- `--remix-version ` to override the default `remix` version +- `--force` to write into a non-empty target directory + +The script generates the starter app, including `README.md`, route handlers, shared UI, test +helpers, and the root directory structure described in this skill. + +## Anti-Patterns + +- Do not create `app/lib/` as a generic dumping ground. +- Do not create `app/components/` as a second shared UI bucket when `app/ui/` already owns that + role. +- Do not put shared cross-route UI in `app/controllers/`. +- Do not put route-owned page modules in `app/ui/`. +- Do not put middleware, session, auth, or database lifecycle helpers in `app/utils/` when they + belong in `app/middleware/`. +- Do not put schema, query, or database setup code in `app/utils/` when it belongs in `app/data/`. +- Do not create folders for simple leaf actions unless they are real controllers. +- Do not split solo actions across multiple files. +- Do not create vague files like `helpers.ts`, `common.ts`, or `misc.ts` unless the name is truly + accurate for the module's ownership. diff --git a/.agents/skills/remix-project-layout/scripts/bootstrap_remix_application.ts b/.agents/skills/remix-project-layout/scripts/bootstrap_remix_application.ts new file mode 100644 index 0000000..79276ab --- /dev/null +++ b/.agents/skills/remix-project-layout/scripts/bootstrap_remix_application.ts @@ -0,0 +1,965 @@ +#!/usr/bin/env node +import fs from 'node:fs/promises' +import path from 'node:path' +import * as process from 'node:process' +import { fileURLToPath } from 'node:url' + +type ParsedArgs = { + appName: string | null + force: boolean + remixVersion: string | null + targetDir: string +} + +type BootstrapConfig = { + appDisplayName: string + packageName: string + remixVersion: string +} + +type FileSpec = { + content: string + path: string +} + +const scriptDir = path.dirname(fileURLToPath(import.meta.url)) +const defaultTsxVersion = '^4.20.6' +const defaultTypesNodeVersion = '^24.6.0' +const defaultTypescriptVersion = '^5.9.3' + +await main() + +async function main(): Promise { + let parsed = parseArgs(process.argv.slice(2)) + let targetDir = path.resolve(parsed.targetDir) + let rawAppName = parsed.appName ?? path.basename(targetDir) + if (rawAppName.length === 0) { + fail('Could not determine an app name from the target directory.') + } + + let config = { + appDisplayName: parsed.appName ?? humanizeName(rawAppName), + packageName: toPackageName(rawAppName), + remixVersion: parsed.remixVersion ?? (await readDefaultRemixVersion()), + } satisfies BootstrapConfig + + await ensureTargetDirectory(targetDir, parsed.force) + await writeDirectories(targetDir) + + let files = createFiles(config) + for (let file of files) { + await writeFile(targetDir, file) + } + + process.stdout.write(`Created ${config.appDisplayName} at ${targetDir}\n`) +} + +function parseArgs(argv: string[]): ParsedArgs { + if (argv.includes('-h') || argv.includes('--help')) { + printUsage() + process.exit(0) + } + + let appName: string | null = null + let force = false + let remixVersion: string | null = null + let targetDir: string | null = null + let index = 0 + + while (index < argv.length) { + let arg = argv[index] + + if (arg === '--app-name') { + let next = argv[index + 1] + if (!next) { + fail('--app-name requires a value.') + } + + appName = next + index += 2 + continue + } + + if (arg === '--remix-version') { + let next = argv[index + 1] + if (!next) { + fail('--remix-version requires a value.') + } + + remixVersion = next + index += 2 + continue + } + + if (arg === '--force') { + force = true + index++ + continue + } + + if (arg.startsWith('--')) { + fail(`Unknown argument: ${arg}`) + } + + if (targetDir != null) { + fail(`Unexpected extra argument: ${arg}`) + } + + targetDir = arg + index++ + } + + if (targetDir == null) { + printUsage() + process.exit(1) + } + + let resolvedTargetDir = targetDir + return { appName, force, remixVersion, targetDir: resolvedTargetDir } +} + +function printUsage(): void { + process.stdout.write(`Usage: + bootstrap_remix_application.ts [--app-name ] [--remix-version ] [--force] + +Examples: + bootstrap_remix_application.ts ./my-remix-app + bootstrap_remix_application.ts ./my-remix-app --app-name "My Remix App" + bootstrap_remix_application.ts ./my-remix-app --remix-version 3.0.0-alpha.3 + bootstrap_remix_application.ts ./my-remix-app --force +`) +} + +async function readDefaultRemixVersion(): Promise { + let packageJsonPath = path.resolve(scriptDir, '../../../packages/remix/package.json') + let packageJson = JSON.parse(await fs.readFile(packageJsonPath, 'utf8')) as { + version?: unknown + } + + if (typeof packageJson.version !== 'string' || packageJson.version.length === 0) { + fail(`Could not read a default remix version from ${packageJsonPath}.`) + } + + return packageJson.version +} + +async function ensureTargetDirectory(targetDir: string, force: boolean): Promise { + try { + let stats = await fs.stat(targetDir) + if (!stats.isDirectory()) { + fail(`Target path is not a directory: ${targetDir}`) + } + + let entries = await fs.readdir(targetDir) + if (entries.length > 0 && !force) { + fail(`Target directory is not empty: ${targetDir}. Re-run with --force to continue.`) + } + } catch (error) { + let nodeError = error as NodeJS.ErrnoException + if (nodeError.code !== 'ENOENT') { + throw error + } + } + + await fs.mkdir(targetDir, { recursive: true }) +} + +async function writeDirectories(targetDir: string): Promise { + let directories = [ + 'app/assets', + 'app/controllers/account/settings', + 'app/controllers/auth/login', + 'app/controllers/cart/api', + 'app/controllers/contact', + 'app/data', + 'app/middleware', + 'app/ui', + 'app/utils', + 'db', + 'public', + 'test', + 'tmp', + ] + + for (let directory of directories) { + await fs.mkdir(path.join(targetDir, directory), { recursive: true }) + } +} + +async function writeFile(targetDir: string, file: FileSpec): Promise { + let filePath = path.join(targetDir, file.path) + await fs.mkdir(path.dirname(filePath), { recursive: true }) + await fs.writeFile(filePath, file.content, 'utf8') +} + +function createFiles(config: BootstrapConfig): FileSpec[] { + return [ + { path: '.gitignore', content: createGitIgnore() }, + { path: 'README.md', content: createReadme(config) }, + { path: 'package.json', content: createPackageJson(config) }, + { path: 'server.ts', content: createServerFile() }, + { path: 'tsconfig.json', content: createTsconfig() }, + { path: 'app/routes.ts', content: createRoutesFile() }, + { path: 'app/router.ts', content: createRouterFile() }, + { path: 'app/utils/render.tsx', content: createRenderFile() }, + { path: 'app/ui/document.tsx', content: createDocumentFile(config) }, + { path: 'app/ui/layout.tsx', content: createLayoutFile() }, + { path: 'app/controllers/home.tsx', content: createHomeFile(config) }, + { path: 'app/controllers/about.tsx', content: createAboutFile() }, + { path: 'app/controllers/search.tsx', content: createSearchFile() }, + { path: 'app/controllers/uploads.tsx', content: createUploadsFile() }, + { path: 'app/controllers/contact/page.tsx', content: createContactPageFile() }, + { + path: 'app/controllers/contact/controller.tsx', + content: createContactControllerFile(), + }, + { path: 'app/controllers/auth/controller.tsx', content: createAuthControllerFile() }, + { path: 'app/controllers/auth/login/page.tsx', content: createLoginPageFile() }, + { + path: 'app/controllers/auth/login/controller.tsx', + content: createLoginControllerFile(), + }, + { path: 'app/controllers/account/page.tsx', content: createAccountPageFile() }, + { + path: 'app/controllers/account/controller.tsx', + content: createAccountControllerFile(), + }, + { + path: 'app/controllers/account/settings/page.tsx', + content: createSettingsPageFile(), + }, + { + path: 'app/controllers/account/settings/controller.tsx', + content: createSettingsControllerFile(), + }, + { path: 'app/controllers/cart/page.tsx', content: createCartPageFile() }, + { path: 'app/controllers/cart/controller.tsx', content: createCartControllerFile() }, + { + path: 'app/controllers/cart/api/controller.tsx', + content: createCartApiControllerFile(), + }, + { path: 'test/helpers.ts', content: createTestHelpersFile() }, + { path: 'app/controllers/home.test.ts', content: createHomeTestFile(config) }, + ] +} + +function createGitIgnore(): string { + return `node_modules/ +db/*.sqlite +tmp/ +.DS_Store +` +} + +function createReadme(config: BootstrapConfig): string { + return `# ${config.appDisplayName} + +A Remix application starter that follows the Remix application layout conventions. + +## Application Layout + +- \`app/\` holds runtime application code. +- \`db/\` holds database artifacts such as migrations and SQLite files. +- \`public/\` holds static files served as-is. +- \`test/\` holds shared test helpers and broader integration coverage. +- \`tmp/\` holds runtime scratch files. + +Inside \`app/\`: + +- \`assets/\` holds client entrypoints and client-owned behavior. +- \`controllers/\` holds route handlers and route-local UI. +- \`data/\` holds schema, queries, persistence setup, and startup data logic. +- \`middleware/\` holds request lifecycle concerns. +- \`ui/\` holds shared cross-route UI. +- \`utils/\` holds genuinely cross-layer helpers. + +## Route Ownership + +- Flat leaf routes live in files like \`app/controllers/home.tsx\`. +- Controller-backed routes live in folders with \`controller.tsx\`, like + \`app/controllers/contact/controller.tsx\`. +- Nested route objects in \`app/routes.ts\` map to nested controller folders on disk, like + \`app/controllers/auth/login/controller.tsx\`. + +## Commands + +\`\`\`sh +npm i +npm run start +npm test +\`\`\` +` +} + +function createPackageJson(config: BootstrapConfig): string { + return ( + JSON.stringify( + { + name: config.packageName, + private: true, + type: 'module', + scripts: { + dev: 'tsx watch server.ts', + start: 'tsx server.ts', + test: 'NODE_ENV=test tsx --test', + typecheck: 'tsc --noEmit', + }, + dependencies: { + remix: config.remixVersion, + tsx: defaultTsxVersion, + }, + devDependencies: { + '@types/node': defaultTypesNodeVersion, + typescript: defaultTypescriptVersion, + }, + }, + null, + 2, + ) + '\n' + ) +} + +function createTsconfig(): string { + return `{ + "compilerOptions": { + "strict": true, + "lib": ["ES2024", "DOM", "DOM.Iterable"], + "types": ["node"], + "module": "ES2022", + "moduleResolution": "Bundler", + "target": "ESNext", + "allowImportingTsExtensions": true, + "rewriteRelativeImportExtensions": true, + "verbatimModuleSyntax": true, + "skipLibCheck": true, + "jsx": "react-jsx", + "jsxImportSource": "remix/component", + "preserveSymlinks": true, + "noEmit": true + }, + "exclude": ["dist"] +} +` +} + +function createServerFile(): string { + return `import * as http from 'node:http' + +import { createRequestListener } from 'remix/node-fetch-server' + +import { router } from './app/router.ts' + +let server = http.createServer( + createRequestListener(async (request) => { + try { + return await router.fetch(request) + } catch (error) { + console.error(error) + return new Response('Internal Server Error', { status: 500 }) + } + }), +) + +let port = process.env.PORT ? Number.parseInt(process.env.PORT, 10) : 44100 + +server.listen(port, () => { + console.log(\`Server listening on http://localhost:\${port}\`) +}) + +let shuttingDown = false + +function shutdown() { + if (shuttingDown) { + return + } + + shuttingDown = true + server.close(() => { + process.exit(0) + }) +} + +process.on('SIGINT', shutdown) +process.on('SIGTERM', shutdown) +` +} + +function createRoutesFile(): string { + return `import { form, get, post, route } from 'remix/fetch-router/routes' + +export let routes = route({ + home: '/', + about: '/about', + search: '/search', + uploads: '/uploads/*key', + + contact: form('contact'), + + auth: { + login: form('login'), + }, + + account: route('account', { + index: '/', + settings: form('settings'), + }), + + cart: route('cart', { + index: get('/'), + api: { + add: post('/api/add'), + remove: post('/api/remove'), + }, + }), +}) +` +} + +function createRouterFile(): string { + return `import { createRouter } from 'remix/fetch-router' + +import accountController from './controllers/account/controller.tsx' +import authController from './controllers/auth/controller.tsx' +import cartController from './controllers/cart/controller.tsx' +import contactController from './controllers/contact/controller.tsx' +import { about } from './controllers/about.tsx' +import { home } from './controllers/home.tsx' +import { search } from './controllers/search.tsx' +import { uploads } from './controllers/uploads.tsx' +import { routes } from './routes.ts' + +export let router = createRouter() + +router.map(routes.home, home) +router.map(routes.about, about) +router.map(routes.search, search) +router.map(routes.uploads, uploads) + +router.map(routes.contact, contactController) +router.map(routes.auth, authController) +router.map(routes.account, accountController) +router.map(routes.cart, cartController) +` +} + +function createRenderFile(): string { + return `import type { RemixNode } from 'remix/component' +import { renderToStream } from 'remix/component/server' + +export function render(node: RemixNode, init?: ResponseInit) { + let headers = new Headers(init?.headers) + if (!headers.has('Content-Type')) { + headers.set('Content-Type', 'text/html; charset=UTF-8') + } + + return new Response(renderToStream(node), { ...init, headers }) +} +` +} + +function createDocumentFile(config: BootstrapConfig): string { + return `import type { RemixNode } from 'remix/component' + +export interface DocumentProps { + children?: RemixNode + title?: string +} + +export function Document() { + return ({ title = ${JSON.stringify(config.appDisplayName)}, children }: DocumentProps) => ( + + + + + {title} + + {children} + + ) +} +` +} + +function createLayoutFile(): string { + return `import type { RemixNode } from 'remix/component' + +import { routes } from '../routes.ts' +import { Document } from './document.tsx' + +export interface LayoutProps { + children?: RemixNode + title?: string +} + +export function Layout() { + return ({ title, children }: LayoutProps) => ( + +
+ +
+
{children}
+
+ ) +} +` +} + +function createHomeFile(config: BootstrapConfig): string { + return `import type { BuildAction } from 'remix/fetch-router' + +import { routes } from '../routes.ts' +import { Layout } from '../ui/layout.tsx' +import { render } from '../utils/render.tsx' + +export let home: BuildAction<'GET', typeof routes.home> = { + handler() { + return render() + }, +} + +function HomePage() { + return () => ( + +

{${JSON.stringify(config.appDisplayName)}}

+

This app follows the Remix application layout conventions.

+

Start with flat leaf routes, promote to controller folders only when the route grows.

+
+ ) +} +` +} + +function createAboutFile(): string { + return `import type { BuildAction } from 'remix/fetch-router' + +import { routes } from '../routes.ts' +import { Layout } from '../ui/layout.tsx' +import { render } from '../utils/render.tsx' + +export let about: BuildAction<'GET', typeof routes.about> = { + handler() { + return render() + }, +} + +function AboutPage() { + return () => ( + +

About

+

+ Shared UI lives in app/ui, request lifecycle code lives in + app/middleware, and persistence setup lives in app/data. +

+
+ ) +} +` +} + +function createSearchFile(): string { + return `import type { BuildAction } from 'remix/fetch-router' + +import { routes } from '../routes.ts' +import { Layout } from '../ui/layout.tsx' +import { render } from '../utils/render.tsx' + +export let search: BuildAction<'GET', typeof routes.search> = { + handler({ url }) { + let query = url.searchParams.get('q') ?? '' + return render() + }, +} + +interface SearchPageProps { + query: string +} + +function SearchPage() { + return ({ query }: SearchPageProps) => ( + +

Search

+
+ + +
+

Current query: {query || 'none'}

+
+ ) +} +` +} + +function createUploadsFile(): string { + return `import type { BuildAction } from 'remix/fetch-router' + +import { routes } from '../routes.ts' +import { Layout } from '../ui/layout.tsx' +import { render } from '../utils/render.tsx' + +export let uploads: BuildAction<'GET', typeof routes.uploads> = { + handler({ params }) { + return render() + }, +} + +interface UploadsPageProps { + objectKey: string +} + +function UploadsPage() { + return ({ objectKey }: UploadsPageProps) => ( + +

Uploads

+

Requested key: {objectKey}

+
+ ) +} +` +} + +function createContactPageFile(): string { + return `import { routes } from '../../routes.ts' +import { Layout } from '../../ui/layout.tsx' + +export function ContactPage() { + return () => ( + +

Contact

+
+ +
+
+ ) +} + +export function ContactSuccessPage() { + return () => ( + +

Message Sent

+

Your message was accepted.

+

+ Send another message +

+
+ ) +} +` +} + +function createContactControllerFile(): string { + return `import type { Controller } from 'remix/fetch-router' + +import { routes } from '../../routes.ts' +import { render } from '../../utils/render.tsx' +import { ContactPage, ContactSuccessPage } from './page.tsx' + +export default { + actions: { + index() { + return render() + }, + + action() { + return render() + }, + }, +} satisfies Controller +` +} + +function createAuthControllerFile(): string { + return `import type { Controller } from 'remix/fetch-router' + +import { routes } from '../../routes.ts' +import loginController from './login/controller.tsx' + +export default { + actions: { + login: loginController, + }, +} satisfies Controller +` +} + +function createLoginPageFile(): string { + return `import { routes } from '../../../routes.ts' +import { Layout } from '../../../ui/layout.tsx' + +export function LoginPage() { + return () => ( + +

Log in

+
+ +
+
+ ) +} + +export function LoginSuccessPage() { + return () => ( + +

Logged In

+

This starter keeps auth logic route-owned inside its controller folder.

+

+ Go to account +

+
+ ) +} +` +} + +function createLoginControllerFile(): string { + return `import type { Controller } from 'remix/fetch-router' + +import { routes } from '../../../routes.ts' +import { render } from '../../../utils/render.tsx' +import { LoginPage, LoginSuccessPage } from './page.tsx' + +export default { + actions: { + index() { + return render() + }, + + action() { + return render() + }, + }, +} satisfies Controller +` +} + +function createAccountPageFile(): string { + return `import { routes } from '../../routes.ts' +import { Layout } from '../../ui/layout.tsx' + +export function AccountPage() { + return () => ( + +

Account

+

This route uses a controller folder because it owns nested child routes.

+

+ Account settings +

+
+ ) +} +` +} + +function createAccountControllerFile(): string { + return `import type { Controller } from 'remix/fetch-router' + +import { routes } from '../../routes.ts' +import { render } from '../../utils/render.tsx' +import { AccountPage } from './page.tsx' +import settingsController from './settings/controller.tsx' + +export default { + actions: { + index() { + return render() + }, + + settings: settingsController, + }, +} satisfies Controller +` +} + +function createSettingsPageFile(): string { + return `import { routes } from '../../../routes.ts' +import { Layout } from '../../../ui/layout.tsx' + +export function SettingsPage() { + return () => ( + +

Settings

+
+ +
+
+ ) +} + +export function SettingsSavedPage() { + return () => ( + +

Settings Saved

+

Your settings route lives under the owning account controller folder.

+

+ Back to account +

+
+ ) +} +` +} + +function createSettingsControllerFile(): string { + return `import type { Controller } from 'remix/fetch-router' + +import { routes } from '../../../routes.ts' +import { render } from '../../../utils/render.tsx' +import { SettingsPage, SettingsSavedPage } from './page.tsx' + +export default { + actions: { + index() { + return render() + }, + + action() { + return render() + }, + }, +} satisfies Controller +` +} + +function createCartPageFile(): string { + return `import { routes } from '../../routes.ts' +import { Layout } from '../../ui/layout.tsx' + +export function CartPage() { + return () => ( + +

Cart

+

+ This controller owns both the cart page and the nested API routes in + app/controllers/cart/api. +

+
+ +
+
+ +
+
+ ) +} +` +} + +function createCartControllerFile(): string { + return `import type { Controller } from 'remix/fetch-router' + +import { routes } from '../../routes.ts' +import { render } from '../../utils/render.tsx' +import apiController from './api/controller.tsx' +import { CartPage } from './page.tsx' + +export default { + actions: { + index() { + return render() + }, + + api: apiController, + }, +} satisfies Controller +` +} + +function createCartApiControllerFile(): string { + return `import type { Controller } from 'remix/fetch-router' + +import { routes } from '../../../routes.ts' + +export default { + actions: { + add() { + return new Response('Added an item to the cart.', { + headers: { 'Content-Type': 'text/plain; charset=UTF-8' }, + }) + }, + + remove() { + return new Response('Removed an item from the cart.', { + headers: { 'Content-Type': 'text/plain; charset=UTF-8' }, + }) + }, + }, +} satisfies Controller +` +} + +function createTestHelpersFile(): string { + return `import { router } from '../app/router.ts' + +export function createTestRouter() { + return router +} +` +} + +function createHomeTestFile(config: BootstrapConfig): string { + return `import * as assert from 'node:assert/strict' +import { describe, it } from 'node:test' + +import { createTestRouter } from '../../test/helpers.ts' + +let router = createTestRouter() + +describe('home handler', () => { + it('serves the home page through router.fetch', async () => { + let response = await router.fetch('http://example.com/') + assert.equal(response.status, 200) + + let contentType = response.headers.get('content-type') ?? '' + assert.match(contentType, /^text\\/html/) + + let body = await response.text() + assert.match(body, /

${escapeRegExp(config.appDisplayName)}<\\/h1>/) + }) +}) +` +} + +function humanizeName(value: string): string { + let parts = value.split(/[-_\s]+/).filter(Boolean) + if (parts.length === 0) { + return 'Remix App' + } + + return parts + .map((part) => { + let head = part.slice(0, 1).toUpperCase() + let tail = part.slice(1) + return `${head}${tail}` + }) + .join(' ') +} + +function toPackageName(value: string): string { + let packageName = value + .trim() + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, '') + + if (packageName.length === 0) { + fail(`Could not derive a valid package name from "${value}".`) + } + + return packageName +} + +function escapeRegExp(value: string): string { + return value.replace(/[|\\{}()[\]^$+*?.-]/g, '\\$&') +} + +function fail(message: string): never { + process.stderr.write(`${message}\n`) + process.exit(1) +} diff --git a/.agents/skills/remix-project-layout/tsconfig.json b/.agents/skills/remix-project-layout/tsconfig.json new file mode 100644 index 0000000..5de5560 --- /dev/null +++ b/.agents/skills/remix-project-layout/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "allowJs": false, + "module": "NodeNext", + "moduleResolution": "NodeNext", + "noEmit": true, + "strict": true, + "target": "ESNext", + "types": ["node"], + "verbatimModuleSyntax": true + }, + "include": ["scripts/**/*.ts"] +} diff --git a/.agents/skills/remix-ui/SKILL.md b/.agents/skills/remix-ui/SKILL.md new file mode 100644 index 0000000..b6a2578 --- /dev/null +++ b/.agents/skills/remix-ui/SKILL.md @@ -0,0 +1,62 @@ +--- +name: remix-ui +description: Build the UI of a Remix app. Use when creating pages, layouts, client entries, interactions, stateful UI, navigation, hydration, styling, animations, reusable mixins, or UI tests. +--- + +# Build UI + +Use this skill when building the UI of a Remix app. + +This skill uses Remix Component as the UI model behind the app's pages, layouts, interactions, and +client behavior. + +## When to Use + +- Creating or updating pages, layouts, and presentational components +- Adding interactivity, state, event handling, or reactive DOM work +- Styling with `css(...)`, `style`, refs, keyboard helpers, or press helpers +- Adding or refactoring `clientEntry(...)`, `run(...)`, frames, or UI navigation +- Implementing enter/exit/layout animations +- Authoring reusable mixins with `createMixin(...)` +- Writing or updating UI-focused component tests + +## Procedure + +1. Follow the two-phase component shape: + - setup runs once + - returned render function runs on every update +2. Keep state in setup scope as plain JavaScript variables and call `handle.update()` explicitly. +3. Prefer host-element mixins over legacy host props: + - `mix={[on(...)]}` + - `mix={[css(...)]}` + - `mix={[ref(...)]}` + - `mix={[keysEvents()]}` + - `mix={[pressEvents()]}` + - `mix={[link(href, options)]}` +4. Use `addEventListeners(target, handle.signal, listeners)` for global listeners. +5. Use `queueTask(...)` for post-render DOM work, reactive effects, or hydration-sensitive setup. +6. Keep `` explicit in document or layout code. +7. Test with real interactions and `root.flush()` when unit tests need synchronous assertions. + +## Load These References As Needed + +- [./references/component-model.md](./references/component-model.md) + Use for component shape, state, `handle` usage, and global listeners. +- [./references/mixins-styling-events.md](./references/mixins-styling-events.md) + Use for host-element events, refs, styling, and built-in behavior helpers. Prefer these helpers + over re-implementing keyboard, press, link, or animation behavior yourself. +- [./references/hydration-frames-navigation.md](./references/hydration-frames-navigation.md) + Use for `clientEntry`, `run`, frames, SSR frame context, navigation APIs, and explicit `` + management. +- [./references/testing-patterns.md](./references/testing-patterns.md) + Use for component tests, `root.flush()`, and high-value testing heuristics. +- [./references/animate-elements.md](./references/animate-elements.md) + Use when the task is about enter/exit transitions, FLIP reordering, shared-layout swaps, or + animation-heavy interactions in app code. +- [./references/create-mixins.md](./references/create-mixins.md) + Use when authoring or reviewing reusable mixins, touching `createMixin(...)`, using + `handle.addEventListener('insert' | 'remove', ...)`, or reasoning about mixin lifecycle + semantics and type flow. +- [packages/component/docs](https://github.com/remix-run/remix/tree/main/packages/component/docs) + Use as the general upstream docs directory when the local references here are not enough and you + need to choose the most relevant Component docs to open. diff --git a/.agents/skills/remix-ui/references/animate-elements.md b/.agents/skills/remix-ui/references/animate-elements.md new file mode 100644 index 0000000..010315a --- /dev/null +++ b/.agents/skills/remix-ui/references/animate-elements.md @@ -0,0 +1,110 @@ +## Animating Elements (`remix/component`) + +Use this reference when building animations in app code. + +Use [./create-mixins.md](./create-mixins.md) as a follow-up when the animation work turns into +authoring reusable animation mixins instead of applying built-in mixins in app code. + +## Quick Start + +```tsx +import { animateEntrance, animateExit, animateLayout, spring } from 'remix/component' + +let el = ( +
+) +``` + +## Core Patterns + +### Enter-only element + +```tsx +
+``` + +### Toggle visibility with enter + exit + +```tsx +{ + isVisible && ( +
+ ) +} +``` + +### Reordering/list layout animation + +```tsx +{ + items.map((item) => ( +
  • + )) +} +``` + +### Shared-layout swap + +```tsx +import { animateEntrance, animateExit, css } from 'remix/component' +;
    *': { gridArea: '1 / 1' }, + }), + ]} +> + {state ? ( +
    + ) : ( +
    + )} +
    +``` + +## Practical Guidance + +- Always key conditional or switching elements you expect to transition. +- Use `animateLayout` on the element whose position or size changes. +- Prefer one clear transition intent per mixin: + - entrance starts from a defined initial style + - exit ends at a defined final style +- For spring-style timing, spread `spring(...)` into the mixin config. +- Default to `...spring()` for duration and easing in most cases. +- Keep extra DOM work in `handle.queueTask(...)` or `ref(...)`, not in render math. + +## Checklist + +- [ ] Animated elements have stable keys where needed +- [ ] `animateLayout` is only on moving or resizing nodes +- [ ] No unnecessary custom state machines when simple mixins suffice diff --git a/.agents/skills/remix-ui/references/component-model.md b/.agents/skills/remix-ui/references/component-model.md new file mode 100644 index 0000000..8382cc3 --- /dev/null +++ b/.agents/skills/remix-ui/references/component-model.md @@ -0,0 +1,72 @@ +## Component Model + +Every component has two phases: + +1. Setup phase: runs once per instance +2. Render phase: returned function runs on initial render and every update + +```tsx +import { on, type Handle } from 'remix/component' + +function Counter(handle: Handle, initialCount = 0) { + let count = initialCount + + return (props: { label: string }) => ( + + ) +} +``` + +## State Rules + +- Keep state in setup scope as plain JavaScript variables. +- Store only what affects rendering. +- Derive computed values in render. +- Do not mirror input state unless you truly need controlled behavior. +- Use `setup` only for one-time initialization inputs. + +## Handle Usage + +- `handle.update()` + - schedules a rerender + - await it when you need updated DOM before follow-up work +- `handle.queueTask(task)` + - use for post-render DOM work, reactive loading, focus, scroll, or measurement +- `handle.signal` + - aborted when the component disconnects +- `handle.id` + - stable per instance +- `handle.context` + - ancestor or descendant communication +- `handle.frame` and `handle.frames` + - frame-aware behavior for client entries rendered inside frames + +## Global Events + +Prefer: + +```tsx +import { addEventListeners, type Handle } from 'remix/component' + +function ResizeTracker(handle: Handle) { + let width = window.innerWidth + + addEventListeners(window, handle.signal, { + resize() { + width = window.innerWidth + handle.update() + }, + }) + + return () =>
    {width}
    +} +``` diff --git a/.agents/skills/remix-ui/references/create-mixins.md b/.agents/skills/remix-ui/references/create-mixins.md new file mode 100644 index 0000000..a7d4140 --- /dev/null +++ b/.agents/skills/remix-ui/references/create-mixins.md @@ -0,0 +1,106 @@ +## Creating Mixins (`remix/component`) + +Use this reference when authoring new reusable mixins. + +Use this reference both for app-level reusable mixins and for framework-level mixin authoring in +`packages/component`. + +The key principle: model the real runtime contract first, then write the smallest code that matches +it. + +## Core Runtime Semantics + +Treat these as constraints, not suggestions: + +1. A mixin handle is tied to one mounted host node lifecycle. +2. `insert` is the host-node availability point for imperative setup. +3. `remove` is teardown for that same lifecycle. +4. `queueTask` runs post-commit and receives `(node, signal)` for mixins. +5. Mixin render functions should stay pure; side effects belong in `insert`, `remove`, or queued + work. + +```tsx +createMixin((handle) => { + handle.addEventListener('insert', (event) => { + // event.node is the mounted host node for this lifecycle. + }) + + handle.addEventListener('remove', () => { + // Clean up listeners, timers, observers, and async work here. + }) + + return (props) => { + handle.queueTask((node) => { + // Post-commit work that needs the concrete host node. + }) + + return + } +}) +``` + +If your implementation assumes semantics that do not exist (node swapping, repeated `insert` for +the same handle, or extra host lifecycles hidden behind one handle), remove that logic. + +## Authoring Rules + +1. Start with lifecycle truth: + - use `insert` for attach/setup + - use `remove` for detach/cleanup +2. Keep state minimal and intentional. +3. Use `queueTask((node, signal) => ...)` only when post-commit timing is required. +4. Use `invariant(...)` for guaranteed runtime conditions instead of defensive casts. +5. Use soft guards only when nullability is genuinely part of valid runtime flow. +6. In most mixins, only `node` is needed from `queueTask`; reach for `signal` only when work is + async or cancellation-sensitive. +7. Do not add `signal.aborted` checks for purely synchronous work. +8. Avoid speculative runtime assumptions. +9. Favor function expressions for helpers in scope. + +## Preferred Patterns + +### Pure prop transform + +```tsx +let withTitle = createMixin((handle) => (title: string, props: { title?: string }) => ( + +)) +``` + +### Lifecycle-managed imperative setup + +```tsx +let withFocus = createMixin((handle) => { + handle.addEventListener('insert', (event) => { + event.node.focus() + }) + + return (props) => +}) +``` + +### Post-commit DOM work + +```tsx +handle.queueTask((node) => { + node.removeEventListener(prevType, stableHandler, prevCapture) + node.addEventListener(nextType, stableHandler, nextCapture) +}) +``` + +## Avoid + +- State for hypothetical runtime scenarios +- Broad null guards where runtime guarantees presence +- Setup/cleanup side effects inside render-only code paths +- Boilerplate `signal.aborted` checks for purely synchronous work +- Hiding semantic uncertainty with casts instead of fixing types or contracts + +## Checklist + +- [ ] Runtime assumptions match reconciler behavior +- [ ] Lifecycle wiring uses `insert` and `remove` directly +- [ ] State is minimal +- [ ] `queueTask` is only used when timing requires it +- [ ] Type flow from `createMixin` is preserved +- [ ] Tests cover ordering, teardown, and type inference contracts diff --git a/.agents/skills/remix-ui/references/hydration-frames-navigation.md b/.agents/skills/remix-ui/references/hydration-frames-navigation.md new file mode 100644 index 0000000..e24d333 --- /dev/null +++ b/.agents/skills/remix-ui/references/hydration-frames-navigation.md @@ -0,0 +1,128 @@ +## Hydration + +Use `clientEntry()` to mark interactive islands and `run()` to hydrate them. + +```tsx +import { clientEntry, on, run, type Handle } from 'remix/component' + +export let Counter = clientEntry('/assets/entry.js#Counter', (handle: Handle) => { + let count = 0 + + return () => ( + + ) +}) + +let app = run({ + async loadModule(moduleUrl, exportName) { + let mod = await import(moduleUrl) + return mod[exportName] + }, + async resolveFrame(src, signal, target) { + let headers = new Headers({ accept: 'text/html' }) + if (target) headers.set('x-remix-target', target) + + let response = await fetch(src, { headers, signal }) + return response.body ?? (await response.text()) + }, +}) + +app.addEventListener('error', (event) => { + console.error(event.error) +}) + +await app.ready() +``` + +Rules: + +- `run()` takes only the init object. +- `app.ready()` waits for initial hydration. +- `app` emits top-level runtime errors. +- Client entry props must be serializable. + +## Frames + +Use `` for server-rendered regions that should load or reload independently. + +```tsx +import { renderToStream } from 'remix/component/server' + +let stream = renderToStream(, { + frameSrc: request.url, + resolveFrame(src, _target, context) { + let currentFrameSrc = context?.currentFrameSrc ?? request.url + let url = new URL(src, currentFrameSrc) + return renderToStream(, { + frameSrc: url, + topFrameSrc: context?.topFrameSrc ?? request.url, + resolveFrame, + }) + }, +}) +``` + +Key points: + +- `frameSrc` seeds SSR frame state for the current render. +- `topFrameSrc` preserves the outer document URL across nested frame renders. +- `resolveFrame(src, target, context)` receives the target and nested-frame context. +- Frame content can be HTML strings, streams, or Remix nodes. + +## Navigation + +Prefer real anchors for normal document navigation. + +When you need app-driven navigation, use: + +- `navigate(href, options)` +- `link(href, options)` mixin + +Attributes understood by the runtime: + +- `rmx-target` +- `rmx-src` +- `rmx-document` + +## Head Management + +Manage document head state with an explicit `` in your document or layout structure. + +```tsx +function App() { + return () => ( + + + Dashboard + + + + +
    ...
    + + + ) +} +``` + +Use this pattern when title, metadata, stylesheets, or other document-level head content should +change as the UI changes. + +Rules: + +- Put document-level `title`, `meta`, `link`, and `style` tags inside an explicit ``. +- Treat `` as part of the rendered UI tree and update it intentionally during navigation or + layout changes. +- Keep JSON-LD or other content-bearing scripts where they semantically belong. They are rendered in + place unless you explicitly place them inside ``. +- Bare head-like tags rendered outside `` stay where they are rendered; they are not moved + into the document head for you. diff --git a/.agents/skills/remix-ui/references/mixins-styling-events.md b/.agents/skills/remix-ui/references/mixins-styling-events.md new file mode 100644 index 0000000..058cb74 --- /dev/null +++ b/.agents/skills/remix-ui/references/mixins-styling-events.md @@ -0,0 +1,92 @@ +## Host Elements + +For host elements, compose behavior with `mix`, not legacy host props. + +Common mixins exported from `remix/component`: + +- `on(...)` +- `ref(...)` +- `css(...)` +- `link(...)` +- `pressEvents(...)` +- `keysEvents(...)` +- `animateEntrance(...)` +- `animateExit(...)` +- `animateLayout(...)` + +Prefer these built-ins before custom normalization code: + +- `keysEvents()` for key-specific host events +- `pressEvents()` when you need one interaction path across pointer and keyboard input +- `link(href, options)` when a non-anchor element should behave like a Remix navigation link + +## Events + +Use `mix={[on(type, handler)]}` for DOM listeners. + +```tsx +
    { + event.preventDefault() + let formData = new FormData(event.currentTarget) + await submit(formData, { signal }) + }), + ]} +/> +``` + +Rules: + +- Event handlers may receive `signal`. +- Pass `signal` to async work when possible. +- Check `signal.aborted` after async work if the API cannot cancel itself. + +## Refs + +Use `ref(...)` for DOM node access: + +```tsx + node.focus())]} /> +``` + +## Styling + +Prefer the `css(...)` mixin for static stylesheet-like rules and `style` for dynamic values. + +```tsx + - ) + return (props: { label: string }) => ( + + ) } ``` @@ -55,18 +55,18 @@ function Counter(handle: Handle, initialCount = 0) { Prefer: ```tsx -import { addEventListeners, type Handle } from 'remix/component' +import { addEventListeners, type Handle } from "remix/component" function ResizeTracker(handle: Handle) { - let width = window.innerWidth + let width = window.innerWidth - addEventListeners(window, handle.signal, { - resize() { - width = window.innerWidth - handle.update() - }, - }) + addEventListeners(window, handle.signal, { + resize() { + width = window.innerWidth + handle.update() + }, + }) - return () =>
    {width}
    + return () =>
    {width}
    } ``` diff --git a/.agents/skills/remix-ui/references/create-mixins.md b/.agents/skills/remix-ui/references/create-mixins.md index a7d4140..cd19530 100644 --- a/.agents/skills/remix-ui/references/create-mixins.md +++ b/.agents/skills/remix-ui/references/create-mixins.md @@ -21,21 +21,21 @@ Treat these as constraints, not suggestions: ```tsx createMixin((handle) => { - handle.addEventListener('insert', (event) => { - // event.node is the mounted host node for this lifecycle. - }) + handle.addEventListener("insert", (event) => { + // event.node is the mounted host node for this lifecycle. + }) - handle.addEventListener('remove', () => { - // Clean up listeners, timers, observers, and async work here. - }) + handle.addEventListener("remove", () => { + // Clean up listeners, timers, observers, and async work here. + }) - return (props) => { - handle.queueTask((node) => { - // Post-commit work that needs the concrete host node. - }) + return (props) => { + handle.queueTask((node) => { + // Post-commit work that needs the concrete host node. + }) - return - } + return + } }) ``` @@ -63,7 +63,7 @@ the same handle, or extra host lifecycles hidden behind one handle), remove that ```tsx let withTitle = createMixin((handle) => (title: string, props: { title?: string }) => ( - + )) ``` @@ -71,11 +71,11 @@ let withTitle = createMixin((handle) => (title: string, props: { title?: string ```tsx let withFocus = createMixin((handle) => { - handle.addEventListener('insert', (event) => { - event.node.focus() - }) + handle.addEventListener("insert", (event) => { + event.node.focus() + }) - return (props) => + return (props) => }) ``` @@ -83,8 +83,8 @@ let withFocus = createMixin((handle) => { ```tsx handle.queueTask((node) => { - node.removeEventListener(prevType, stableHandler, prevCapture) - node.addEventListener(nextType, stableHandler, nextCapture) + node.removeEventListener(prevType, stableHandler, prevCapture) + node.addEventListener(nextType, stableHandler, nextCapture) }) ``` diff --git a/.agents/skills/remix-ui/references/hydration-frames-navigation.md b/.agents/skills/remix-ui/references/hydration-frames-navigation.md index e24d333..b9b97a2 100644 --- a/.agents/skills/remix-ui/references/hydration-frames-navigation.md +++ b/.agents/skills/remix-ui/references/hydration-frames-navigation.md @@ -3,41 +3,41 @@ Use `clientEntry()` to mark interactive islands and `run()` to hydrate them. ```tsx -import { clientEntry, on, run, type Handle } from 'remix/component' - -export let Counter = clientEntry('/assets/entry.js#Counter', (handle: Handle) => { - let count = 0 - - return () => ( - - ) +import { clientEntry, on, run, type Handle } from "remix/component" + +export let Counter = clientEntry("/assets/entry.js#Counter", (handle: Handle) => { + let count = 0 + + return () => ( + + ) }) let app = run({ - async loadModule(moduleUrl, exportName) { - let mod = await import(moduleUrl) - return mod[exportName] - }, - async resolveFrame(src, signal, target) { - let headers = new Headers({ accept: 'text/html' }) - if (target) headers.set('x-remix-target', target) - - let response = await fetch(src, { headers, signal }) - return response.body ?? (await response.text()) - }, + async loadModule(moduleUrl, exportName) { + let mod = await import(moduleUrl) + return mod[exportName] + }, + async resolveFrame(src, signal, target) { + let headers = new Headers({ accept: "text/html" }) + if (target) headers.set("x-remix-target", target) + + let response = await fetch(src, { headers, signal }) + return response.body ?? (await response.text()) + }, }) -app.addEventListener('error', (event) => { - console.error(event.error) +app.addEventListener("error", (event) => { + console.error(event.error) }) await app.ready() @@ -55,19 +55,19 @@ Rules: Use `` for server-rendered regions that should load or reload independently. ```tsx -import { renderToStream } from 'remix/component/server' +import { renderToStream } from "remix/component/server" let stream = renderToStream(, { - frameSrc: request.url, - resolveFrame(src, _target, context) { - let currentFrameSrc = context?.currentFrameSrc ?? request.url - let url = new URL(src, currentFrameSrc) - return renderToStream(, { - frameSrc: url, - topFrameSrc: context?.topFrameSrc ?? request.url, - resolveFrame, - }) - }, + frameSrc: request.url, + resolveFrame(src, _target, context) { + let currentFrameSrc = context?.currentFrameSrc ?? request.url + let url = new URL(src, currentFrameSrc) + return renderToStream(, { + frameSrc: url, + topFrameSrc: context?.topFrameSrc ?? request.url, + resolveFrame, + }) + }, }) ``` @@ -99,18 +99,18 @@ Manage document head state with an explicit `` in your document or layout ```tsx function App() { - return () => ( - - - Dashboard - - - - -
    ...
    - - - ) + return () => ( + + + Dashboard + + + + +
    ...
    + + + ) } ``` diff --git a/.agents/skills/remix-ui/references/mixins-styling-events.md b/.agents/skills/remix-ui/references/mixins-styling-events.md index 058cb74..9305f8d 100644 --- a/.agents/skills/remix-ui/references/mixins-styling-events.md +++ b/.agents/skills/remix-ui/references/mixins-styling-events.md @@ -26,13 +26,13 @@ Use `mix={[on(type, handler)]}` for DOM listeners. ```tsx { - event.preventDefault() - let formData = new FormData(event.currentTarget) - await submit(formData, { signal }) - }), - ]} + mix={[ + on("submit", async (event, signal) => { + event.preventDefault() + let formData = new FormData(event.currentTarget) + await submit(formData, { signal }) + }), + ]} /> ``` @@ -56,15 +56,15 @@ Prefer the `css(...)` mixin for static stylesheet-like rules and `style` for dyn ```tsx -
    - Don't have an account?{" "} - - Sign up - -
    - - - , - ) - }, - - async action(context) { - try { - let user = await verifyCredentials(passwordProvider, context) - - if (user == null) { - let session = context.get(Session) - session.flash("error", "Invalid email or password. Please try again.") - return redirect( - routes.auth.login.index.href(undefined, getReturnToQuery(context.url)), - ) - } - - let session = completeAuth(context) - session.set("auth", { userId: user.id }) - return redirect(getPostAuthRedirect(context.url)) - } catch (error) { - let session = context.get(Session) - session.flash("error", "We could not complete that sign-in request.") - return redirect(routes.auth.login.index.href(undefined, getReturnToQuery(context.url))) - } - }, - }, - }, - - register: { - actions: { - index({ get, url }) { - let session = get(Session) - let error = session.get("error") - - return render( - Login - Remix Wordle}> -
    -
    - {error && typeof error === "string" ? ( -
    {error}
    - ) : null} -
    - - -
    - -
    - - -
    - -
    - - -
    - - - -

    - Already have an account?{" "} - - Login here - -

    -
    -
    -
    , - ) - }, - - async action({ get, url }) { - let session = get(Session) - let formData = get(FormData) - let result = parse(joinSchema, formData) - - // Check if user already exists - if (await getUserByEmail(result.email)) { - session.flash("error", "An account with this email already exists.") - return redirect(routes.auth.register.index.href(undefined, getReturnToQuery(url))) - } - - let user = await createUser(result) - - session.set("auth", { userId: user.id }) - - return redirect(routes.home.index.href()) - }, - }, - }, - - logout(context) { - let session = context.get(Session) - session.unset("auth") - session.regenerateId(true) - return redirect(routes.home.index.href()) - }, - - forgotPassword: { - actions: { - index({ url }) { - return render( - Login - Remix Wordle}> -
    -

    Forgot Password

    -

    Enter your email address and we'll send you a link to reset your password.

    - -
    -
    - - -
    - - -
    - -

    - Back to Login -

    -
    -
    , - ) - }, - - async action({ get, url }) { - let formData = get(FormData) - let email = formData.get("email")?.toString() ?? "" - let token = createPasswordResetToken(email) - - return render( - Login - Remix Wordle}> -
    -
    Password reset link sent! Check your email.
    - - {token ? ( -
    -

    - Demo Mode: Click the link below to reset your password -

    -

    - - Reset Password - -

    -
    - ) : null} - -

    - - Back to Login - -

    -
    -
    , - ) - }, - }, - }, - - resetPassword: { - actions: { - index({ get, params, url }) { - let session = get(Session) - let token = params.token - let error = session.get("error") - - return render( - Login - Remix Wordle}> -
    -

    Reset Password

    -

    Enter your new password below.

    - - {typeof error === "string" ? ( -
    - {error} -
    - ) : null} - -
    -
    - - -
    - -
    - - -
    - - -
    -
    -
    , - ) - }, - - async action({ get, params, url }) { - let session = get(Session) - let formData = get(FormData) - let password = formData.get("password")?.toString() ?? "" - let confirmPassword = formData.get("confirmPassword")?.toString() ?? "" - - if (password !== confirmPassword) { - session.flash("error", "Passwords do not match.") - return redirect(routes.auth.resetPassword.index.href({ token: params.token })) - } - - let success = resetPassword(params.token, password) - - if (!success) { - session.flash("error", "Invalid or expired reset token.") - return redirect(routes.auth.resetPassword.index.href({ token: params.token })) - } - - return render( - Login - Remix Wordle}> -
    -
    - Password reset successfully! You can now login with your new password. -
    -

    - - Login - -

    -
    -
    , - ) - }, - }, - }, - }, -} satisfies Controller diff --git a/app/auth/controller.test.ts b/app/controllers/auth/controller.test.ts similarity index 97% rename from app/auth/controller.test.ts rename to app/controllers/auth/controller.test.ts index 2aef7d6..0a0bed5 100644 --- a/app/auth/controller.test.ts +++ b/app/controllers/auth/controller.test.ts @@ -1,10 +1,10 @@ import { describe, expect, it, vi } from "vite-plus/test" -import { assertContains, getSessionCookie } from "../../test/helpers.ts" -import { router } from "../router.ts" +import { assertContains, getSessionCookie } from "../../../test/helpers.ts" +import { router } from "../../router.ts" vi.mock("./models/user.ts", async (importActual) => { - let actual = await importActual() + let actual = await importActual() return { ...actual, authenticateUser: vi.fn().mockImplementation(async (email: string, password: string) => { diff --git a/app/controllers/auth/controller.tsx b/app/controllers/auth/controller.tsx new file mode 100644 index 0000000..01b3b31 --- /dev/null +++ b/app/controllers/auth/controller.tsx @@ -0,0 +1,29 @@ +import type { Controller } from "remix/fetch-router" +import { createRedirectResponse as redirect } from "remix/response/redirect" +import { Session } from "remix/session" + +import { loadAuth } from "../../middleware/auth.ts" +import { routes } from "../../routes.ts" +import { forgotPasswordController } from "./forgot-password/controller.tsx" +import { loginController } from "./login/controller.tsx" +import { registerController } from "./register/controller.tsx" +import { resetPasswordController } from "./reset-password/controller.tsx" + +export const auth = { + middleware: [loadAuth()], + actions: { + login: loginController, + + register: registerController, + + logout({ get }) { + let session = get(Session) + session.destroy() + return redirect(routes.home.index.href()) + }, + + forgotPassword: forgotPasswordController, + + resetPassword: resetPasswordController, + }, +} satisfies Controller diff --git a/app/controllers/auth/forgot-password/controller.tsx b/app/controllers/auth/forgot-password/controller.tsx new file mode 100644 index 0000000..d10d5e0 --- /dev/null +++ b/app/controllers/auth/forgot-password/controller.tsx @@ -0,0 +1,72 @@ +import type { Controller } from "remix/fetch-router" + +import { Document } from "../../../components/document" +import { createPasswordResetToken } from "../../../models/user" +import { routes } from "../../../routes" +import { render } from "../../../utils/render" + +export const forgotPasswordController = { + actions: { + index({ url }) { + return render( + Login - Remix Wordle}> +
    +

    Forgot Password

    +

    Enter your email address and we'll send you a link to reset your password.

    + +
    +
    + + +
    + + +
    + +

    + Back to Login +

    +
    +
    , + ) + }, + + async action({ get, url }) { + let formData = get(FormData) + let email = formData.get("email")?.toString() ?? "" + let token = createPasswordResetToken(email) + + return render( + Login - Remix Wordle}> +
    +
    Password reset link sent! Check your email.
    + + {token ? ( +
    +

    + Demo Mode: Click the link below to reset your password +

    +

    + + Reset Password + +

    +
    + ) : null} + +

    + + Back to Login + +

    +
    +
    , + ) + }, + }, +} satisfies Controller diff --git a/app/controllers/auth/login/controller.tsx b/app/controllers/auth/login/controller.tsx new file mode 100644 index 0000000..91677a3 --- /dev/null +++ b/app/controllers/auth/login/controller.tsx @@ -0,0 +1,102 @@ +import type { Controller } from "remix/fetch-router" +import { redirect } from "remix/response/redirect" +import { Session } from "remix/session" + +import { Document } from "../../../components/document" +import { authenticateUser } from "../../../models/user" +import { routes } from "../../../routes" +import { render } from "../../../utils/render" +import * as localSchema from "../../home/local-schema" + +const { parse } = localSchema + +const loginSchema = localSchema.object({ + email: localSchema.string(), + password: localSchema.string(), +}) + +export const loginController = { + actions: { + async action({ get, url }) { + let session = get(Session) + let formData = get(FormData) + let result = parse(loginSchema, formData) + let returnTo = url.searchParams.get("returnTo") + + let user = await authenticateUser(result.email, result.password) + if (!user) { + session.flash("error", "Invalid email or password. Please try again.") + return redirect(routes.auth.login.index.href(undefined, { returnTo })) + } + + session.set("auth", { userId: user.id }) + + return redirect(returnTo ?? routes.home.index.href()) + }, + index({ get, url }) { + let session = get(Session) + let error = session.get("error") + let formAction = routes.auth.login.action.href(undefined, { + returnTo: url.searchParams.get("returnTo"), + }) + + return render( + Login - Remix Wordle}> +
    + {error && typeof error === "string" ?
    {error}
    : null} +
    +
    + +
    + +
    +
    + +
    + +
    + +
    +
    + + +
    + Don't have an account?{" "} + + Sign up + +
    +
    +
    +
    , + ) + }, + }, +} satisfies Controller diff --git a/app/controllers/auth/register/controller.tsx b/app/controllers/auth/register/controller.tsx new file mode 100644 index 0000000..fe1e50d --- /dev/null +++ b/app/controllers/auth/register/controller.tsx @@ -0,0 +1,123 @@ +import type { Controller } from "remix/fetch-router" +import { redirect } from "remix/response/redirect" +import { Session } from "remix/session" + +import { Document } from "../../../components/document" +import { joinSchema, getUserByEmail, createUser } from "../../../models/user" +import { routes } from "../../../routes" +import { render } from "../../../utils/render" +import { parse } from "../../home/local-schema" + +export const registerController = { + actions: { + index({ url }) { + return render( + Login - Remix Wordle}> +
    +
    +
    + + +
    + +
    + + +
    + +
    + + +
    + + + +

    + Already have an account?{" "} + + Login here + +

    +
    +
    +
    , + ) + }, + + async action({ get, url }) { + let session = get(Session) + let formData = get(FormData) + let result = parse(joinSchema, formData) + + // Check if user already exists + if (await getUserByEmail(result.email)) { + return render( + Login - Remix Wordle}> +
    +
    An account with this email already exists.
    +

    + + Back to Register + + + Login + +

    +
    +
    , + { status: 400 }, + ) + } + + let user = await createUser({ + email: result.email, + username: result.username, + password: result.password, + }) + + session.set("auth", { auth: user.id }) + + return redirect(routes.home.index.href()) + }, + }, +} satisfies Controller diff --git a/app/controllers/auth/reset-password/controller.tsx b/app/controllers/auth/reset-password/controller.tsx new file mode 100644 index 0000000..0129974 --- /dev/null +++ b/app/controllers/auth/reset-password/controller.tsx @@ -0,0 +1,95 @@ +import type { Controller } from "remix/fetch-router" +import { redirect } from "remix/response/redirect" +import { Session } from "remix/session" + +import { Document } from "../../../components/document" +import { resetPassword } from "../../../models/user" +import { routes } from "../../../routes" +import { render } from "../../../utils/render" + +export const resetPasswordController = { + actions: { + index({ get, params, url }) { + let session = get(Session) + let token = params.token + let error = session.get("error") + + return render( + Login - Remix Wordle}> +
    +

    Reset Password

    +

    Enter your new password below.

    + + {typeof error === "string" ? ( +
    + {error} +
    + ) : null} + +
    +
    + + +
    + +
    + + +
    + + +
    +
    +
    , + ) + }, + + async action({ get, params, url }) { + let session = get(Session) + let formData = get(FormData) + let password = formData.get("password")?.toString() ?? "" + let confirmPassword = formData.get("confirmPassword")?.toString() ?? "" + + if (password !== confirmPassword) { + session.flash("error", "Passwords do not match.") + return redirect(routes.auth.resetPassword.index.href({ token: params.token })) + } + + let success = resetPassword(params.token, password) + + if (!success) { + session.flash("error", "Invalid or expired reset token.") + return redirect(routes.auth.resetPassword.index.href({ token: params.token })) + } + + return render( + Login - Remix Wordle}> +
    +
    + Password reset successfully! You can now login with your new password. +
    +

    + + Login + +

    +
    +
    , + ) + }, + }, +} satisfies Controller diff --git a/app/health/controller.tsx b/app/controllers/health.tsx similarity index 100% rename from app/health/controller.tsx rename to app/controllers/health.tsx diff --git a/app/history/controller.tsx b/app/controllers/history/controller.tsx similarity index 80% rename from app/history/controller.tsx rename to app/controllers/history/controller.tsx index 453a9ec..2dab682 100644 --- a/app/history/controller.tsx +++ b/app/controllers/history/controller.tsx @@ -2,12 +2,12 @@ import { Auth, type BadAuth, type GoodAuth } from "remix/auth-middleware" import type { Controller } from "remix/fetch-router" import { redirect } from "remix/response/redirect" -import { getReturnToQuery, requireAuth } from "../middleware/auth.ts" -import { getGameById, isGameComplete } from "../models/game.ts" -import { routes } from "../routes.ts" -import type { AuthIdentity } from "../utils/auth-session.ts" -import { db } from "../utils/db.ts" -import { render } from "../utils/render.ts" +import { getReturnToQuery, requireAuth } from "../../middleware/auth.ts" +import { getGameById, isGameComplete } from "../../models/game.ts" +import { routes } from "../../routes.ts" +import type { AuthIdentity } from "../../utils/auth-session.ts" +import { db } from "../../utils/db.ts" +import { render } from "../../utils/render.ts" import { HistoricalGame } from "./game.tsx" import { createHistoricalGameListItem, diff --git a/app/history/game.tsx b/app/controllers/history/game.tsx similarity index 86% rename from app/history/game.tsx rename to app/controllers/history/game.tsx index 62a0bab..ef10099 100644 --- a/app/history/game.tsx +++ b/app/controllers/history/game.tsx @@ -1,12 +1,12 @@ import { clsx } from "clsx" import type { Handle } from "remix/component" -import { Document } from "../components/document" -import { GameOverModal } from "../components/game-over-modal" -import { Keyboard } from "../components/keyboard" -import { TOTAL_GUESSES } from "../constants" -import type { GameBoard } from "../models/game" -import { LetterState } from "../utils/game" +import { Document } from "../../components/document" +import { GameOverModal } from "../../components/game-over-modal" +import { Keyboard } from "../../components/keyboard" +import { TOTAL_GUESSES } from "../../constants" +import type { GameBoard } from "../../models/game" +import { LetterState } from "../../utils/game" export function HistoricalGame(_handle: Handle, { url }: { url: URL }) { return ({ game, showModal }: { game: GameBoard; showModal: boolean }) => { diff --git a/app/history/history-page.tsx b/app/controllers/history/history-page.tsx similarity index 96% rename from app/history/history-page.tsx rename to app/controllers/history/history-page.tsx index 4fe275b..00df106 100644 --- a/app/history/history-page.tsx +++ b/app/controllers/history/history-page.tsx @@ -1,9 +1,9 @@ import { clsx } from "clsx" import type { Handle } from "remix/component" -import { Document } from "../components/document" -import type { Prisma } from "../generated/prisma/client" -import { routes } from "../routes" +import { Document } from "../../components/document" +import type { Prisma } from "../../generated/prisma/client" +import { routes } from "../../routes" let shortDateFormatter = new Intl.DateTimeFormat("en-US", { dateStyle: "short", diff --git a/app/history/not-found-page.tsx b/app/controllers/history/not-found-page.tsx similarity index 91% rename from app/history/not-found-page.tsx rename to app/controllers/history/not-found-page.tsx index 99b8918..2942a3f 100644 --- a/app/history/not-found-page.tsx +++ b/app/controllers/history/not-found-page.tsx @@ -1,6 +1,6 @@ import type { Handle } from "remix/component" -import { Document } from "../components/document" +import { Document } from "../../components/document" export function GameNotFound(_handle: Handle, { url }: { url: URL }) { return () => { diff --git a/app/home/controller.test.ts b/app/controllers/home/controller.test.ts similarity index 93% rename from app/home/controller.test.ts rename to app/controllers/home/controller.test.ts index a44374f..6d4d4c2 100644 --- a/app/home/controller.test.ts +++ b/app/controllers/home/controller.test.ts @@ -1,13 +1,13 @@ import { parse, parseSafe } from "remix/data-schema" import { describe, expect, it, vi } from "vite-plus/test" -import { assertContains, loginAsCustomer, requestWithSession } from "../../test/helpers.ts" -import type { FullGame } from "../models/game.ts" -import { router } from "../router.ts" +import { assertContains, loginAsCustomer, requestWithSession } from "../../../test/helpers.ts" +import type { FullGame } from "../../models/game.ts" +import { router } from "../../router.ts" import { guessWordSchema } from "./controller.tsx" vi.mock("./models/game.ts", async (importActual) => { - let actual = await importActual() + let actual = await importActual() return { ...actual, @@ -35,7 +35,7 @@ vi.mock("./models/game.ts", async (importActual) => { }) vi.mock("./models/user.ts", async (importActual) => { - let actual = await importActual() + let actual = await importActual() return { ...actual, diff --git a/app/home/controller.tsx b/app/controllers/home/controller.tsx similarity index 86% rename from app/home/controller.tsx rename to app/controllers/home/controller.tsx index 9f181bd..8a04953 100644 --- a/app/home/controller.tsx +++ b/app/controllers/home/controller.tsx @@ -3,14 +3,14 @@ import type { Controller } from "remix/fetch-router" import { createRedirectResponse, redirect } from "remix/response/redirect" import { Session } from "remix/session" -import { REVEAL_WORD, WORD_LENGTH } from "../constants.ts" -import { getReturnToQuery, requireAuth } from "../middleware/auth.ts" -import { createGuess, getFullBoard, getTodaysGame, isGameComplete } from "../models/game.ts" -import { routes } from "../routes.ts" -import type { AuthIdentity } from "../utils/auth-session.ts" -import * as f from "../utils/local-form-schema.ts" -import * as s from "../utils/local-schema.ts" -import { render } from "../utils/render.ts" +import { REVEAL_WORD, WORD_LENGTH } from "../../constants.ts" +import { getReturnToQuery, requireAuth } from "../../middleware/auth.ts" +import { createGuess, getFullBoard, getTodaysGame, isGameComplete } from "../../models/game.ts" +import { routes } from "../../routes.ts" +import type { AuthIdentity } from "../../utils/auth-session.ts" +import { render } from "../../utils/render.ts" +import * as f from "./local-form-schema.ts" +import * as s from "./local-schema.ts" import { Page } from "./page.tsx" export function validLength(length: number): s.Check> { diff --git a/app/controllers/home/local-form-schema.ts b/app/controllers/home/local-form-schema.ts new file mode 100644 index 0000000..fcf6d57 --- /dev/null +++ b/app/controllers/home/local-form-schema.ts @@ -0,0 +1,310 @@ +import type { InferOutput, Issue, ParseOptions, Schema } from "./local-schema.ts" +import { createIssue, createSchema, fail } from "./local-schema.ts" + +type FormDataEntryKind = "field" | "fields" | "file" | "files" + +/** + * A Standard Schema-compatible input type for form-like data containers. + */ +export type FormDataSource = FormData | URLSearchParams + +type FormDataParseResult = { value: output } | { issues: ReadonlyArray } + +type FormDataValidationContext = { + path: NonNullable + options?: ParseOptions +} + +/** + * A schema entry that reads one or more values from `FormData` or `URLSearchParams` and validates + * them. + */ +export interface FormDataEntrySchema { + /** The parsing mode used to read values from the input object. */ + kind: FormDataEntryKind + /** The form field name to read. Defaults to the object key passed to `object()`. */ + name?: string + /** The schema used to validate the parsed value or values. */ + schema: Schema +} + +/** + * Options for parsing a single text field from `FormData` or `URLSearchParams`. + */ +export interface FormDataFieldOptions { + /** The form field name to read. Defaults to the object key passed to `object()`. */ + name?: string +} + +/** + * Options for parsing repeated text fields from `FormData` or `URLSearchParams`. + */ +export interface FormDataFieldsOptions { + /** The form field name to read. Defaults to the object key passed to `object()`. */ + name?: string +} + +/** + * Options for parsing a single file field from `FormData`. + */ +export interface FormDataFileOptions { + /** The form field name to read. Defaults to the object key passed to `object()`. */ + name?: string +} + +/** + * Options for parsing repeated file fields from `FormData`. + */ +export interface FormDataFilesOptions { + /** The form field name to read. Defaults to the object key passed to `object()`. */ + name?: string +} + +/** + * A schema-like object that describes the fields to parse from `FormData` or `URLSearchParams`. + */ +export type FormDataSchema = Record> + +/** + * A Standard Schema-compatible schema that validates a `FormData` or `URLSearchParams` object. + */ +export type FormDataObjectSchema = Schema< + FormDataSource, + ParsedFormData +> + +/** + * The typed result produced by `object()` for a given form-data shape. + */ +export type ParsedFormData = { + [key in keyof schema]: schema[key] extends FormDataEntrySchema ? output : never +} + +/** + * Creates a Standard Schema-compatible schema that reads typed values from a `FormData` or + * `URLSearchParams` object. + * + * Use the returned schema with `parse()` or `parseSafe()` from `@remix-run/data-schema`. + * + * @param schema The form-data shape describing the fields to read and validate. + * @returns A schema that validates a `FormData` or `URLSearchParams` object and produces typed + * output. + */ +export function object( + schema: schema, +): FormDataObjectSchema { + return createSchema(function validate(value, context) { + if (!isFormDataSource(value)) { + return fail("Expected FormData or URLSearchParams", context.path, { + code: "type.form_data_source", + input: value, + parseOptions: context.options, + }) + } + + let abortEarly = shouldAbortEarly(context.options) + let issues: Issue[] = [] + let output: Partial> = {} + + for (let [key, entrySchema] of Object.entries(schema) as [ + keyof schema & string, + FormDataEntrySchema, + ][]) { + let result = parseField(value, key, entrySchema, context) + + if ("issues" in result) { + if (abortEarly) { + return { issues: result.issues } + } + + issues.push(...result.issues) + continue + } + + output[key] = result.value + } + + if (issues.length > 0) { + return { issues } + } + + return { value: output as ParsedFormData } + }) +} + +/** + * Creates a schema entry for a single text field from `FormData` or `URLSearchParams`. + * + * @param schema The schema used to validate the parsed field value. + * @param options Parsing options for the field. + * @returns A field schema entry for use with `object()`. + */ +export function field>( + schema: schema, + options?: FormDataFieldOptions, +): FormDataEntrySchema> { + return { + kind: "field", + name: options?.name, + schema, + } +} + +/** + * Creates a schema entry for repeated text fields from `FormData` or `URLSearchParams`. + * + * @param schema The schema used to validate the parsed field values. + * @param options Parsing options for the field. + * @returns A field schema entry for use with `object()`. + */ +export function fields>( + schema: schema, + options?: FormDataFieldsOptions, +): FormDataEntrySchema> { + return { + kind: "fields", + name: options?.name, + schema, + } +} + +/** + * Creates a schema entry for a single file field from `FormData`. + * + * @param schema The schema used to validate the parsed file value. + * @param options Parsing options for the field. + * @returns A file schema entry for use with `object()`. + */ +export function file>( + schema: schema, + options?: FormDataFileOptions, +): FormDataEntrySchema> { + return { + kind: "file", + name: options?.name, + schema, + } +} + +/** + * Creates a schema entry for repeated file fields from `FormData`. + * + * @param schema The schema used to validate the parsed file values. + * @param options Parsing options for the field. + * @returns A file schema entry for use with `object()`. + */ +export function files>( + schema: schema, + options?: FormDataFilesOptions, +): FormDataEntrySchema> { + return { + kind: "files", + name: options?.name, + schema, + } +} + +function parseField( + formData: FormDataSource, + key: string, + entrySchema: FormDataEntrySchema, + context: FormDataValidationContext, +): FormDataParseResult { + let fieldName = entrySchema.name ?? key + let keyPath = withPath(context.path, key) + + switch (entrySchema.kind) { + case "field": { + let value = formData.get(fieldName) + + if (value instanceof Blob) { + return { + issues: [createIssue(`Expected text field "${fieldName}"`, keyPath)], + } + } + + return validateParsedValue(keyPath, entrySchema.schema, value ?? undefined, context.options) + } + case "fields": { + let values = formData.getAll(fieldName) + let parsedValues: string[] = [] + let issues: Issue[] = [] + + values.forEach((value, index) => { + if (value instanceof Blob) { + issues.push(createIssue(`Expected text field "${fieldName}"`, withPath(keyPath, index))) + } else { + parsedValues.push(value) + } + }) + + if (issues.length > 0) { + return { issues } + } + + return validateParsedValue(keyPath, entrySchema.schema, parsedValues, context.options) + } + case "file": { + let value = formData.get(fieldName) + + if (value != null && !(value instanceof Blob)) { + return { + issues: [createIssue(`Expected file field "${fieldName}"`, keyPath)], + } + } + + return validateParsedValue(keyPath, entrySchema.schema, value ?? undefined, context.options) + } + case "files": { + let values = formData.getAll(fieldName) + let parsedValues: Blob[] = [] + let issues: Issue[] = [] + + values.forEach((value, index) => { + if (!(value instanceof Blob)) { + issues.push(createIssue(`Expected file field "${fieldName}"`, withPath(keyPath, index))) + } else { + parsedValues.push(value) + } + }) + + if (issues.length > 0) { + return { issues } + } + + return validateParsedValue(keyPath, entrySchema.schema, parsedValues, context.options) + } + } +} + +function validateParsedValue( + path: NonNullable, + schema: Schema, + value: unknown, + options?: ParseOptions, +): FormDataParseResult { + let result = schema["~run"](value, { path, options }) + + if (result.issues) { + return { issues: result.issues } + } + + return { + value: result.value, + } +} + +function shouldAbortEarly(options?: ParseOptions): boolean { + let libraryAbortEarly = (options?.libraryOptions as { abortEarly?: unknown } | undefined) + ?.abortEarly + + return Boolean(options?.abortEarly ?? libraryAbortEarly) +} + +function withPath(path: NonNullable, key: PropertyKey): NonNullable { + return path.length === 0 ? [key] : [...path, key] +} + +function isFormDataSource(value: unknown): value is FormDataSource { + return value instanceof FormData || value instanceof URLSearchParams +} diff --git a/app/controllers/home/local-schema.ts b/app/controllers/home/local-schema.ts new file mode 100644 index 0000000..00baa4e --- /dev/null +++ b/app/controllers/home/local-schema.ts @@ -0,0 +1,1184 @@ +import type { StandardSchemaV1 } from "@standard-schema/spec" + +/** + * A validation issue returned by a schema, compatible with Standard Schema v1. + * + * Issues include a human-readable `message` and an optional `path` that points to the + * failing location in the input (e.g. `['user', 'email']` or `[0, 'id']`). + */ +export type Issue = StandardSchemaV1.Issue + +/** + * The result of schema validation. + * + * On success, `value` is present and `issues` is absent. On failure, `issues` is present. + */ +export type ValidationResult = StandardSchemaV1.Result + +/** + * Options passed to `~standard.validate`. + */ +export type ValidationOptions = StandardSchemaV1.Options + +/** + * Context passed to `errorMap` to customize issue messages. + */ +export type ErrorMapContext = { + code: string + defaultMessage: string + path?: Issue["path"] + values?: Record + input: unknown + locale?: string +} + +/** + * Function used to customize issue messages. + * + * Return `undefined` to use the default message. + */ +export type ErrorMap = (context: ErrorMapContext) => string | undefined + +/** + * Options passed to {@link parse} and {@link parseSafe}. + * + * This mirrors {@link ValidationOptions}, but also supports a convenience `abortEarly` + * option at the top level. + */ +export type ParseOptions = StandardSchemaV1.Options & { + abortEarly?: boolean + errorMap?: ErrorMap + locale?: string +} + +type SyncStandardSchemaProps = Omit< + StandardSchemaV1.Props, + "validate" +> & { + // data-schema is sync-first; keep validate sync. + validate: (value: unknown, options?: ValidationOptions) => ValidationResult + // Preserve Standard Schema's compile-time type channel. + types?: StandardSchemaV1.Types | undefined +} + +type SyncStandardSchema = { + "~standard": SyncStandardSchemaProps +} + +/** + * A reusable check for use with `schema.pipe(...)`. + */ +export type Check = { + check: (value: output) => boolean + message?: string + code?: string + values?: Record +} + +/** + * A sync, Standard Schema v1-compatible schema with a small chainable API. + */ +export type Schema = SyncStandardSchema & { + /** + * Compose one or more reusable checks onto this schema. + * + * Checks run after the underlying schema has validated and produced an `output` value. + * If any check fails, validation fails with an issue (using the check's `message` if provided). + * + * @param checks One or more `Check`s to apply in order + * @returns A new schema with the checks applied + */ + pipe: (...checks: Check[]) => Schema + /** + * Add an inline predicate check onto this schema. + * + * The predicate runs after the underlying schema has validated and produced an `output` value. + * If the predicate returns `false`, validation fails with an issue. + * + * @param predicate A function that returns `true` for valid values + * @param message Optional issue message when the predicate fails + * @returns A new schema with the refinement applied + */ + refine: (predicate: (value: output) => boolean, message?: string) => Schema + /** + * Transform the output of this schema with a function. + * + * The transform function runs after the underlying schema has validated and produced an `output` value. + * The returned schema has a different output type. + * + * @param transform A function that transforms the validated output + * @returns A new schema with the transformation applied + */ + transform: (transform: (value: output) => newOutput) => Schema + /** + * Internal validator used to validate nested values while preserving `path`/`options`. + */ + "~run": (value: unknown, context: ValidationContext) => ValidationResult +} + +/** + * Infers the input type of a schema-like value. + */ +export type InferInput = schema extends StandardSchemaV1 ? input : never + +/** + * Infers the output type of a schema-like value. + */ +export type InferOutput = + schema extends StandardSchemaV1 ? output : never + +type ValidationContext = { + path: NonNullable + options?: ParseOptions +} + +type IssueDescriptor = { + code: string + defaultMessage: string + input: unknown + path?: Issue["path"] + values?: Record +} + +/** + * Creates a sync Standard Schema-compatible schema from a validation function. + * + * @param validator Validator that returns either a parsed value or validation issues. + * @returns A chainable schema object. + */ +export function createSchema( + validator: ( + value: unknown, + context: { path: NonNullable; options?: ParseOptions }, + ) => ValidationResult, +): Schema { + let schema: Schema = { + "~standard": { + version: 1, + vendor: "data-schema", + validate(value: unknown, options?: ValidationOptions) { + return validator(value, { path: [], options }) + }, + }, + "~run"(value: unknown, context: ValidationContext) { + return validator(value, context) + }, + pipe(...checks: Check[]) { + if (checks.length === 0) { + return schema + } + + return createSchema(function validate(value, context) { + let result = schema["~run"](value, context) + + if (result.issues) { + return result + } + + for (let check of checks) { + if (!check.check(result.value)) { + if (!check.code) { + return { issues: [createIssue(check.message ?? "Check failed", context.path)] } + } + + return { + issues: [ + createIssueFromContext(context, { + code: check.code, + defaultMessage: check.message ?? "Check failed", + input: result.value, + values: check.values, + }), + ], + } + } + } + + return result + }) + }, + refine(predicate: (value: output) => boolean, message?: string) { + return createSchema(function validate(value, context) { + let result = schema["~run"](value, context) + + if (result.issues) { + return result + } + + if (!predicate(result.value)) { + if (message !== undefined) { + return { issues: [createIssue(message, context.path)] } + } + + return { + issues: [ + createIssueFromContext(context, { + code: "refine.failed", + defaultMessage: "Refinement failed", + input: result.value, + }), + ], + } + } + + return result + }) + }, + transform(fn: (value: output) => newOutput): Schema { + return createSchema(function validate(value, context) { + let result = schema["~run"](value, context) + + if (result.issues) { + return result + } + + return { value: fn(result.value) } + }) + }, + } + + return schema +} + +function shouldAbortEarly(options?: ParseOptions): boolean { + let libraryAbortEarly = (options?.libraryOptions as { abortEarly?: unknown } | undefined) + ?.abortEarly + let abortEarly = options?.abortEarly ?? libraryAbortEarly + return Boolean(abortEarly) +} + +function withPath(path: NonNullable, key: PropertyKey): NonNullable { + return path.length === 0 ? [key] : [...path, key] +} + +function getErrorMap(options?: ParseOptions): ErrorMap | undefined { + let libraryErrorMap = (options?.libraryOptions as { errorMap?: unknown } | undefined)?.errorMap + + if (typeof options?.errorMap === "function") { + return options.errorMap + } + + if (typeof libraryErrorMap === "function") { + return libraryErrorMap as ErrorMap + } +} + +function getLocale(options?: ParseOptions): string | undefined { + let libraryLocale = (options?.libraryOptions as { locale?: unknown } | undefined)?.locale + + if (typeof options?.locale === "string") { + return options.locale + } + + if (typeof libraryLocale === "string") { + return libraryLocale + } +} + +function resolveIssueMessage(options: ParseOptions | undefined, context: ErrorMapContext): string { + let errorMap = getErrorMap(options) + + if (!errorMap) { + return context.defaultMessage + } + + let message = errorMap(context) + return message ?? context.defaultMessage +} + +function createIssueFromContext(context: ValidationContext, descriptor: IssueDescriptor): Issue { + let path = descriptor.path ?? context.path + let message = resolveIssueMessage(context.options, { + code: descriptor.code, + defaultMessage: descriptor.defaultMessage, + path, + values: descriptor.values, + input: descriptor.input, + locale: getLocale(context.options), + }) + + return createIssue(message, path) +} + +/** + * Creates a Standard Schema issue object. + * + * @param message Human-readable validation message. + * @param path Optional issue path within the input value. + * @returns A Standard Schema issue. + */ +export function createIssue(message: string, path: Issue["path"]): Issue { + return !path || path.length === 0 ? { message } : { message, path } +} + +/** + * Creates a Standard Schema failure result with a single issue. + * + * @param message Human-readable validation message. + * @param path Optional issue path within the input value. + * @param options Optional issue metadata used for localized error mapping. + * @param options.code Optional error code passed to the error map. + * @param options.values Optional values passed to the error map. + * @param options.input Optional input value passed to the error map. + * @param options.parseOptions Optional parse options used for localization and error mapping. + * @returns A failure result containing one issue. + */ +export function fail( + message: string, + path: Issue["path"], + options?: { + code?: string + values?: Record + input?: unknown + parseOptions?: ParseOptions + }, +): StandardSchemaV1.FailureResult { + if (!options?.code) { + return { issues: [createIssue(message, path)] } + } + + let resolvedMessage = resolveIssueMessage(options.parseOptions, { + code: options.code, + defaultMessage: message, + path, + values: options.values, + input: options.input, + locale: getLocale(options.parseOptions), + }) + + return { issues: [createIssue(resolvedMessage, path)] } +} + +/** + * Create a schema that accepts any value without validation. + * + * @returns A schema that produces `unknown` + */ +export function any(): Schema { + return createSchema(function validate(value) { + return { value } + }) +} + +/** + * Create a schema that validates an array by validating each element with `elementSchema`. + * + * @param elementSchema The schema to validate each element + * @returns A schema that produces an array of validated outputs + */ +export function array( + elementSchema: Schema, +): Schema { + return createSchema(function validate(value, context) { + if (!Array.isArray(value)) { + return fail("Expected array", context.path, { + code: "type.array", + input: value, + parseOptions: context.options, + }) + } + + let abortEarly = shouldAbortEarly(context.options) + let issues: Issue[] = [] + let outputValues: output[] = [] + let index = 0 + + for (let item of value) { + let result = elementSchema["~run"](item, { + path: withPath(context.path, index), + options: context.options, + }) + + if (result.issues) { + if (abortEarly) { + return result + } + + issues.push(...result.issues) + } else { + outputValues.push(result.value) + } + + index += 1 + } + + if (issues.length > 0) { + return { issues } + } + + return { value: outputValues } + }) +} + +/** + * Create a schema that accepts bigints. + * + * @returns A schema that produces a `bigint` + */ +export function bigint(): Schema { + return createSchema(function validate(value, context) { + if (typeof value !== "bigint") { + return fail("Expected bigint", context.path, { + code: "type.bigint", + input: value, + parseOptions: context.options, + }) + } + + return { value } + }) +} + +/** + * Create a schema that accepts booleans. + * + * @returns A schema that produces a `boolean` + */ +export function boolean(): Schema { + return createSchema(function validate(value, context) { + if (typeof value !== "boolean") { + return fail("Expected boolean", context.path, { + code: "type.boolean", + input: value, + parseOptions: context.options, + }) + } + + return { value } + }) +} + +/** + * Provide a default when the input is `undefined`. + * + * @param schema The wrapped schema + * @param defaultValue A value or function used to produce the default + * @returns A schema that produces the default when the input is `undefined` + */ +export function defaulted( + schema: Schema, + defaultValue: output | (() => output), +): Schema { + return createSchema(function validate(value, context) { + if (value === undefined) { + let resolved = + typeof defaultValue === "function" ? (defaultValue as () => output)() : defaultValue + + return { value: resolved } + } + + return schema["~run"](value, context) + }) +} + +/** + * Create a schema that accepts one of the given values using strict equality (`===`). + * + * @param values The allowed values + * @returns A schema that produces the union of allowed value types + */ +export function enum_( + values: values, +): Schema { + return createSchema(function validate(value, context) { + for (let allowed of values) { + if (value === allowed) { + return { value: value as values[number] } + } + } + + return fail("Expected one of: " + values.map(String).join(", "), context.path, { + code: "enum.invalid_value", + input: value, + values: { values: [...values] }, + parseOptions: context.options, + }) + }) +} + +/** + * Create a schema that validates a value is an instance of a class. + * + * @param constructor The class constructor to check against + * @returns A schema that produces the instance type + */ +export function instanceof_ any>( + constructor: constructor, +): Schema> { + return createSchema(function validate(value, context) { + if (!(value instanceof constructor)) { + return fail("Expected instance of " + constructor.name, context.path, { + code: "instanceof.invalid_type", + input: value, + values: { constructorName: constructor.name }, + parseOptions: context.options, + }) + } + + return { value: value as InstanceType } + }) +} + +/** + * Create a schema that accepts a single literal value using strict equality (`===`). + * + * @param literalValue The literal value to match + * @returns A schema that produces the literal type + */ +export function literal(literalValue: value): Schema { + return createSchema(function validate(value, context) { + if (value !== literalValue) { + return fail("Expected literal value", context.path, { + code: "literal.invalid_value", + input: value, + values: { expected: literalValue }, + parseOptions: context.options, + }) + } + + return { value: literalValue } + }) +} + +/** + * Create a schema that validates a Map with typed keys and values. + * + * @param keySchema Schema for Map keys + * @param valueSchema Schema for Map values + * @returns A schema that produces a `Map` + */ +export function map( + keySchema: Schema, + valueSchema: Schema, +): Schema> { + return createSchema(function validate(value, context) { + if (!(value instanceof Map)) { + return fail("Expected Map", context.path, { + code: "type.map", + input: value, + parseOptions: context.options, + }) + } + + let abortEarly = shouldAbortEarly(context.options) + let issues: Issue[] = [] + let outputMap = new Map() + + for (let [key, val] of value) { + let keyResult = keySchema["~run"](key, { + path: withPath(context.path, key), + options: context.options, + }) + + if (keyResult.issues) { + if (abortEarly) { + return keyResult + } + + issues.push(...keyResult.issues) + continue + } + + let valueResult = valueSchema["~run"](val, { + path: withPath(context.path, key), + options: context.options, + }) + + if (valueResult.issues) { + if (abortEarly) { + return valueResult + } + + issues.push(...valueResult.issues) + continue + } + + outputMap.set(keyResult.value, valueResult.value) + } + + if (issues.length > 0) { + return { issues } + } + + return { value: outputMap } + }) +} + +/** + * Create a schema that accepts `null`. + * + * @returns A schema that produces `null` + */ +export function null_(): Schema { + return createSchema(function validate(value, context) { + if (value !== null) { + return fail("Expected null", context.path, { + code: "type.null", + input: value, + parseOptions: context.options, + }) + } + + return { value: null } + }) +} + +/** + * Allow `null` as an input value, short-circuiting validation when `null` is provided. + * + * @param schema The wrapped schema + * @returns A schema that accepts `null` in addition to the wrapped schema + */ +export function nullable( + schema: Schema, +): Schema { + return createSchema(function validate(value, context) { + if (value === null) { + return { value: null } + } + + return schema["~run"](value, context) + }) +} + +/** + * Create a schema that accepts finite numbers (excluding `NaN` and `Infinity`). + * + * @returns A schema that produces a `number` + */ +export function number(): Schema { + return createSchema(function validate(value, context) { + if (typeof value !== "number" || !Number.isFinite(value)) { + return fail("Expected number", context.path, { + code: "type.number", + input: value, + parseOptions: context.options, + }) + } + + return { value } + }) +} + +type ObjectShape = Record> + +type ObjectOptions = { + unknownKeys?: "strip" | "passthrough" | "error" +} + +/** + * Create a schema that validates an object with a fixed shape. + * + * By default, unknown keys are stripped. You can change this via `options.unknownKeys`. + * + * @param shape A mapping of keys to schemas + * @param options Controls unknown key behavior + * @returns A schema that produces a typed object matching the shape + */ +export function object( + shape: shape, + options?: ObjectOptions, +): Schema }> { + return createSchema(function validate(value, context) { + if (typeof value !== "object" || value === null || Array.isArray(value)) { + return fail("Expected object", context.path, { + code: "type.object", + input: value, + parseOptions: context.options, + }) + } + + let abortEarly = shouldAbortEarly(context.options) + let issues: Issue[] = [] + let outputValues: Record = {} + let input = value as Record + let unknownKeys = options?.unknownKeys ?? "strip" + + for (let key of Object.keys(shape)) { + // @ts-expect-error + let result = shape[key]["~run"](input[key], { + path: withPath(context.path, key), + options: context.options, + }) + + if (result.issues) { + if (abortEarly) { + return result + } + + issues.push(...result.issues) + } else { + if (Object.prototype.hasOwnProperty.call(input, key) || result.value !== undefined) { + outputValues[key] = result.value + } + } + } + + if (unknownKeys === "passthrough" || unknownKeys === "error") { + for (let key in input) { + if (!Object.prototype.hasOwnProperty.call(input, key)) { + continue + } + + if (Object.prototype.hasOwnProperty.call(shape, key)) { + continue + } + + if (unknownKeys === "passthrough") { + outputValues[key] = input[key] + } else { + let issue = createIssueFromContext(context, { + code: "object.unknown_key", + defaultMessage: "Unknown key", + input: input[key], + path: withPath(context.path, key), + values: { key }, + }) + + if (abortEarly) { + return { issues: [issue] } + } + + issues.push(issue) + } + } + } + + if (issues.length > 0) { + return { issues } + } + + return { value: outputValues as { [key in keyof shape]: InferOutput } } + }) +} + +/** + * Allow `undefined` as an input value, short-circuiting validation when `undefined` is provided. + * + * @param schema The wrapped schema + * @returns A schema that accepts `undefined` in addition to the wrapped schema + */ +export function optional( + schema: Schema, +): Schema { + return createSchema(function validate(value, context) { + if (value === undefined) { + return { value: undefined } + } + + return schema["~run"](value, context) + }) +} + +/** + * Create a schema that validates a record (object map) by validating each key and value. + * + * @param keySchema Schema used to validate and transform each key + * @param valueSchema Schema used to validate and transform each value + * @returns A schema that produces a record of validated keys and values + */ +export function record( + keySchema: Schema, + valueSchema: Schema, +): Schema> { + return createSchema(function validate(value, context) { + if (typeof value !== "object" || value === null || Array.isArray(value)) { + return fail("Expected object", context.path, { + code: "type.object", + input: value, + parseOptions: context.options, + }) + } + + let abortEarly = shouldAbortEarly(context.options) + let issues: Issue[] = [] + let outputValues: Record = {} + let input = value as Record + + for (let key in input) { + if (!Object.prototype.hasOwnProperty.call(input, key)) { + continue + } + + let keyResult = keySchema["~run"](key, { + path: withPath(context.path, key), + options: context.options, + }) + + if (keyResult.issues) { + if (abortEarly) { + return keyResult + } + + issues.push(...keyResult.issues) + continue + } + + let valueResult = valueSchema["~run"](input[key], { + path: withPath(context.path, key), + options: context.options, + }) + + if (valueResult.issues) { + if (abortEarly) { + return valueResult + } + + issues.push(...valueResult.issues) + continue + } + + outputValues[keyResult.value] = valueResult.value + } + + if (issues.length > 0) { + return { issues } + } + + return { value: outputValues as Record } + }) +} + +/** + * Create a schema that validates a Set with typed values. + * + * @param valueSchema Schema for Set values + * @returns A schema that produces a `Set` + */ +export function set( + valueSchema: Schema, +): Schema> { + return createSchema(function validate(value, context) { + if (!(value instanceof Set)) { + return fail("Expected Set", context.path, { + code: "type.set", + input: value, + parseOptions: context.options, + }) + } + + let abortEarly = shouldAbortEarly(context.options) + let issues: Issue[] = [] + let outputSet = new Set() + let index = 0 + + for (let item of value) { + let result = valueSchema["~run"](item, { + path: withPath(context.path, index), + options: context.options, + }) + + if (result.issues) { + if (abortEarly) { + return result + } + + issues.push(...result.issues) + } else { + outputSet.add(result.value) + } + + index++ + } + + if (issues.length > 0) { + return { issues } + } + + return { value: outputSet } + }) +} + +/** + * Create a schema that accepts strings. + * + * @returns A schema that produces a `string` + */ +export function string(): Schema { + return createSchema(function validate(value, context) { + if (typeof value !== "string") { + return fail("Expected string", context.path, { + code: "type.string", + input: value, + parseOptions: context.options, + }) + } + + return { value } + }) +} + +/** + * Create a schema that accepts symbols. + * + * @returns A schema that produces a `symbol` + */ +export function symbol(): Schema { + return createSchema(function validate(value, context) { + if (typeof value !== "symbol") { + return fail("Expected symbol", context.path, { + code: "type.symbol", + input: value, + parseOptions: context.options, + }) + } + + return { value } + }) +} + +/** + * Create a schema that validates a fixed-length tuple. + * + * @param items Schemas for each tuple position + * @returns A schema that produces a typed tuple + */ +export function tuple[]>( + items: items, +): Schema }> { + return createSchema(function validate(value, context) { + if (!Array.isArray(value)) { + return fail("Expected array", context.path, { + code: "type.array", + input: value, + parseOptions: context.options, + }) + } + + let abortEarly = shouldAbortEarly(context.options) + let issues: Issue[] = [] + let outputValues: unknown[] = [] + + if (value.length !== items.length) { + let issue = createIssueFromContext(context, { + code: "tuple.length", + defaultMessage: "Expected tuple length " + String(items.length), + input: value, + values: { length: items.length }, + }) + + if (abortEarly) { + return { issues: [issue] } + } + + issues.push(issue) + } + + let index = 0 + let max = Math.min(value.length, items.length) + + while (index < max) { + // @ts-expect-error + let result = items[index]["~run"](value[index], { + path: withPath(context.path, index), + options: context.options, + }) + + if (result.issues) { + if (abortEarly) { + return result + } + + issues.push(...result.issues) + } else { + outputValues[index] = result.value + } + + index += 1 + } + + if (issues.length > 0) { + return { issues } + } + + return { value: outputValues as { [index in keyof items]: InferOutput } } + }) +} + +/** + * Create a schema that accepts `undefined`. + * + * @returns A schema that produces `undefined` + */ +export function undefined_(): Schema { + return createSchema(function validate(value, context) { + if (value !== undefined) { + return fail("Expected undefined", context.path, { + code: "type.undefined", + input: value, + parseOptions: context.options, + }) + } + + return { value: undefined } + }) +} + +/** + * Create a discriminated-union schema. + * + * The returned schema expects an object with a `discriminator` property and selects a variant schema + * based on that value. + * + * @param discriminator The property name used to select a variant + * @param variants A mapping from discriminator value to schema + * @returns A schema that produces the selected variant output type + */ +export function variant< + key extends PropertyKey, + variants extends Record>, +>(discriminator: key, variants: variants): Schema> { + return createSchema(function validate(value, context) { + if (typeof value !== "object" || value === null || Array.isArray(value)) { + return fail("Expected object", context.path, { + code: "type.object", + input: value, + parseOptions: context.options, + }) + } + + let input = value as Record + let tag = input[discriminator] + + if (tag === undefined) { + return fail("Expected discriminator", [...context.path, discriminator], { + code: "variant.missing_discriminator", + input: value, + values: { discriminator: String(discriminator) }, + parseOptions: context.options, + }) + } + + if (typeof tag !== "string" && typeof tag !== "number" && typeof tag !== "symbol") { + return fail("Unknown discriminator", [...context.path, discriminator], { + code: "variant.unknown_discriminator", + input: tag, + values: { discriminator: String(discriminator) }, + parseOptions: context.options, + }) + } + + if (!Object.prototype.hasOwnProperty.call(variants, tag)) { + return fail("Unknown discriminator", [...context.path, discriminator], { + code: "variant.unknown_discriminator", + input: tag, + values: { discriminator: String(discriminator) }, + parseOptions: context.options, + }) + } + + let schema = variants[tag as keyof variants] + // @ts-expect-error + return schema["~run"](value, context) + }) +} + +/** + * Create a schema that tries multiple schemas in order and returns the first success. + * + * When `abortEarly` is disabled (default), issues are collected from all failing variants. + * + * @param schemas Candidate schemas to try + * @returns A schema that produces the first successful variant output + */ +export function union[]>( + schemas: schemas, +): Schema> { + return createSchema(function validate(value, context) { + if (schemas.length === 0) { + return fail("No union variant matched", context.path, { + code: "union.no_variants", + input: value, + parseOptions: context.options, + }) + } + + let abortEarly = shouldAbortEarly(context.options) + let issues: Issue[] = [] + + for (let schema of schemas) { + let result = schema["~run"](value, context) + + if (result.issues) { + if (abortEarly) { + return { issues: result.issues } + } + + issues.push(...result.issues) + + continue + } + + return result + } + + return { issues } + }) +} + +/** + * Error thrown by {@link parse} when validation fails. + */ +export class ValidationError extends Error { + /** + * The validation issues produced by the schema. + */ + issues: ReadonlyArray + + /** + * @param issues The issues produced by schema validation + * @param message Optional error message (defaults to "Validation failed") + */ + constructor(issues: ReadonlyArray, message = "Validation failed") { + super(message) + this.name = "ValidationError" + this.issues = issues + } +} + +/** + * Validate a value and return the typed output or throw a {@link ValidationError}. + * + * @param schema The schema to validate against + * @param value The value to validate + * @param options Validation options + * @returns The validated output value + * @throws {ValidationError} If validation fails + */ +export function parse( + schema: StandardSchemaV1, + value: unknown, + options?: ParseOptions, +): output { + let result = schema["~standard"].validate(value, options) as ValidationResult + + if (result.issues) { + throw new ValidationError(result.issues) + } + + return result.value +} + +/** + * Validate a value without throwing. + * + * @param schema The schema to validate against + * @param value The value to validate + * @param options Validation options + * @returns A success result with the value, or a failure result with issues + */ +export function parseSafe( + schema: StandardSchemaV1, + value: unknown, + options?: ParseOptions, +): + | { success: true; value: output } + | { success: false; issues: ReadonlyArray } { + let result = schema["~standard"].validate(value, options) as ValidationResult + + if (result.issues) { + return { success: false, issues: result.issues } + } + + return { success: true, value: result.value } +} diff --git a/app/home/page.tsx b/app/controllers/home/page.tsx similarity index 88% rename from app/home/page.tsx rename to app/controllers/home/page.tsx index 2b9af1a..ff0a579 100644 --- a/app/home/page.tsx +++ b/app/controllers/home/page.tsx @@ -1,12 +1,12 @@ import type { Handle } from "remix/component" -import { Document } from "../components/document" -import { GuessForm } from "../components/form" -import { GameOverModal } from "../components/game-over-modal" -import { Keyboard } from "../components/keyboard" -import { LetterInput } from "../components/letter-input" -import { LETTER_INPUTS, TOTAL_GUESSES } from "../constants" -import type { GameBoard } from "../models/game" +import { Document } from "../../components/document" +import { GuessForm } from "../../components/form" +import { GameOverModal } from "../../components/game-over-modal" +import { Keyboard } from "../../components/keyboard" +import { LetterInput } from "../../components/letter-input" +import { LETTER_INPUTS, TOTAL_GUESSES } from "../../constants" +import type { GameBoard } from "../../models/game" export function Page(_handle: Handle, { url }: { url: URL }) { return ({ showModal, diff --git a/app/middleware/auth.ts b/app/middleware/auth.ts index 38fb427..6ee1adf 100644 --- a/app/middleware/auth.ts +++ b/app/middleware/auth.ts @@ -31,7 +31,10 @@ export function loadAuth() { return parseAuthSession(session.get("auth")) }, async verify(value) { - let user = await db.user.findFirst({ where: { id: value.userId }, omit: { password: true } }) + let user = await db.user.findFirst({ + where: { id: value.userId }, + omit: { password: true }, + }) if (user == null) { return null diff --git a/app/router.ts b/app/router.ts index 5f153a1..1e5a899 100644 --- a/app/router.ts +++ b/app/router.ts @@ -8,10 +8,10 @@ import { methodOverride } from "remix/method-override-middleware" import { session } from "remix/session-middleware" import { staticFiles } from "remix/static-middleware" -import { auth } from "./auth/controller.tsx" -import { health } from "./health/controller.tsx" -import { history } from "./history/controller.tsx" -import { home } from "./home/controller.tsx" +import { auth } from "./controllers/auth/controller.tsx" +import { health } from "./controllers/health.tsx" +import { history } from "./controllers/history/controller.tsx" +import { home } from "./controllers/home/controller.tsx" import { loadAuth } from "./middleware/auth.ts" import { securityHeaders } from "./middleware/security.ts" import { routes } from "./routes.ts" diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 1937eeb..cb711bd 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -6,7 +6,7 @@ catalog: "@mswjs/http-middleware": ^0.10.3 "@prisma/adapter-pg": ^7.6.0 "@prisma/client": ^7.6.0 - '@remix-run/cli': github:remix-run/remix#preview/pr-11204&path:packages/cli + "@remix-run/cli": github:remix-run/remix#preview/pr-11204&path:packages/cli "@standard-schema/spec": ^1.1.0 "@tailwindcss/vite": ^4.2.2 "@total-typescript/tsconfig": ^1.0.4 diff --git a/vite.config.ts b/vite.config.ts index 49f48b0..fc617c0 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -11,6 +11,9 @@ try { } catch {} export default defineConfig({ + staged: { + "*": "vp check --fix", + }, fmt: { ignorePatterns: ["app/generated/**"], experimentalSortImports: {}, From becd2e5d344f96869bd5959331d8d7777cc649b8 Mon Sep 17 00:00:00 2001 From: Logan McAnsh Date: Sat, 4 Apr 2026 01:38:03 -0400 Subject: [PATCH 05/10] chore: use local version of remix/data-schema Signed-off-by: Logan McAnsh --- app/constants.ts | 5 +++-- app/controllers/home/controller.tsx | 4 ++-- app/models/user.ts | 2 +- 3 files changed, 6 insertions(+), 5 deletions(-) diff --git a/app/constants.ts b/app/constants.ts index 4ede1c5..b6b5f03 100644 --- a/app/constants.ts +++ b/app/constants.ts @@ -1,7 +1,8 @@ -import * as s from "remix/data-schema" import { minLength } from "remix/data-schema/checks" -let envSchema = s.object({ +import * as s from "./utils/local-schema.ts" + +const envSchema = s.object({ SESSION_SECRET: s.string().pipe(minLength(1)), DATABASE_URL: s.string().pipe(minLength(1)), REDIS_URL: s.string().pipe(minLength(1)), diff --git a/app/controllers/home/controller.tsx b/app/controllers/home/controller.tsx index 8a04953..18f1e74 100644 --- a/app/controllers/home/controller.tsx +++ b/app/controllers/home/controller.tsx @@ -8,9 +8,9 @@ import { getReturnToQuery, requireAuth } from "../../middleware/auth.ts" import { createGuess, getFullBoard, getTodaysGame, isGameComplete } from "../../models/game.ts" import { routes } from "../../routes.ts" import type { AuthIdentity } from "../../utils/auth-session.ts" +import * as f from "../../utils/local-form-schema.ts" +import * as s from "../../utils/local-schema.ts" import { render } from "../../utils/render.ts" -import * as f from "./local-form-schema.ts" -import * as s from "./local-schema.ts" import { Page } from "./page.tsx" export function validLength(length: number): s.Check> { diff --git a/app/models/user.ts b/app/models/user.ts index 25e24b3..7f2c5d8 100644 --- a/app/models/user.ts +++ b/app/models/user.ts @@ -1,10 +1,10 @@ import bcrypt from "bcryptjs" -import * as s from "remix/data-schema" import { email, minLength } from "remix/data-schema/checks" import * as f from "remix/data-schema/form-data" import type { User } from "../generated/prisma/client" import { db } from "../utils/db" +import * as s from "../utils/local-schema" export const joinSchema = f.object({ email: f.field(s.string().pipe(email())), From 74f95dad51802a9d409cd065392e281309f27587 Mon Sep 17 00:00:00 2001 From: Logan McAnsh Date: Sat, 4 Apr 2026 01:38:42 -0400 Subject: [PATCH 06/10] chore: prefer let locals and const modules Signed-off-by: Logan McAnsh --- app/components/document.tsx | 2 +- app/controllers/history/controller.tsx | 2 +- app/controllers/history/history-page.tsx | 4 +- app/entry.browser.ts | 2 +- app/models/game.ts | 4 +- app/router.ts | 4 +- app/routes.ts | 2 +- app/utils/board-to-emoji.test.ts | 38 +++---- app/utils/db.ts | 2 +- app/utils/game.test.ts | 80 +++++++-------- app/utils/game.ts | 18 ++-- app/utils/redirect.ts | 2 +- app/utils/session.ts | 4 +- mocks/handlers.ts | 2 +- mocks/test.ts | 2 +- oxlint-plugins/prefer-let-locals-plugin.ts | 110 +++++++++++++++++++++ package.json | 1 + pnpm-lock.yaml | 12 +++ pnpm-workspace.yaml | 1 + server.ts | 4 +- vite.config.ts | 5 +- 21 files changed, 214 insertions(+), 87 deletions(-) create mode 100644 oxlint-plugins/prefer-let-locals-plugin.ts diff --git a/app/components/document.tsx b/app/components/document.tsx index 658adea..0f77759 100644 --- a/app/components/document.tsx +++ b/app/components/document.tsx @@ -6,7 +6,7 @@ import serverAssets from "../entry.server.tsx?assets=ssr" import appStylesHref from "../app.css?url" -let assets = clientAssets.merge(serverAssets) +const assets = clientAssets.merge(serverAssets) export function Document() { return ({ children, url, head }: { children: RemixNode; head?: RemixNode; url: URL }) => { diff --git a/app/controllers/history/controller.tsx b/app/controllers/history/controller.tsx index 2dab682..4fa10f7 100644 --- a/app/controllers/history/controller.tsx +++ b/app/controllers/history/controller.tsx @@ -16,7 +16,7 @@ import { } from "./history-page.tsx" import { GameNotFound } from "./not-found-page.tsx" -export let history = { +export const history = { middleware: [requireAuth], actions: { async index(context) { diff --git a/app/controllers/history/history-page.tsx b/app/controllers/history/history-page.tsx index 00df106..76bede9 100644 --- a/app/controllers/history/history-page.tsx +++ b/app/controllers/history/history-page.tsx @@ -5,11 +5,11 @@ import { Document } from "../../components/document" import type { Prisma } from "../../generated/prisma/client" import { routes } from "../../routes" -let shortDateFormatter = new Intl.DateTimeFormat("en-US", { +const shortDateFormatter = new Intl.DateTimeFormat("en-US", { dateStyle: "short", }) -export let HISTORICAL_GAME_SELECT = { +export const HISTORICAL_GAME_SELECT = { id: true, createdAt: true, updatedAt: true, diff --git a/app/entry.browser.ts b/app/entry.browser.ts index c6005d5..f44f28b 100644 --- a/app/entry.browser.ts +++ b/app/entry.browser.ts @@ -1,6 +1,6 @@ import { run } from "remix/component" -let app = run({ +const app = run({ async loadModule(moduleUrl, exportName) { let chunks = JSON.parse(moduleUrl) as string[] let [mod] = await Promise.all(chunks.map((chunk) => import(/* @vite-ignore */ chunk))) diff --git a/app/models/game.ts b/app/models/game.ts index 65e7191..6dac0fa 100644 --- a/app/models/game.ts +++ b/app/models/game.ts @@ -14,9 +14,9 @@ import { LetterState, } from "../utils/game" -let TOTAL_GUESSES = 6 +const TOTAL_GUESSES = 6 -let FULL_GAME_SELECT = { +const FULL_GAME_SELECT = { id: true, createdAt: true, updatedAt: true, diff --git a/app/router.ts b/app/router.ts index 1e5a899..f15fd1b 100644 --- a/app/router.ts +++ b/app/router.ts @@ -17,7 +17,7 @@ import { securityHeaders } from "./middleware/security.ts" import { routes } from "./routes.ts" import { sessionCookie, sessionStorage } from "./utils/session.ts" -let middleware = [] +const middleware = [] if (process.env.NODE_ENV === "development") { middleware.push(logger()) @@ -57,7 +57,7 @@ middleware.push( ) middleware.push(loadAuth()) -export let router = createRouter({ middleware }) +export const router = createRouter({ middleware }) router.map(routes.home, home) router.map(routes.history, history) diff --git a/app/routes.ts b/app/routes.ts index 26cda24..aef282e 100644 --- a/app/routes.ts +++ b/app/routes.ts @@ -1,6 +1,6 @@ import { form, get, post, route } from "remix/fetch-router/routes" -export let routes = route({ +export const routes = route({ home: form("/"), health: get("health"), diff --git a/app/utils/board-to-emoji.test.ts b/app/utils/board-to-emoji.test.ts index 5ce6dbf..7184747 100644 --- a/app/utils/board-to-emoji.test.ts +++ b/app/utils/board-to-emoji.test.ts @@ -6,7 +6,7 @@ import { LetterState } from "./game" describe("boardToEmoji", () => { it("converts all Match states to green squares", () => { - const guesses = [ + let guesses = [ { letters: [ { id: "1", letter: "t", state: LetterState.Match }, @@ -18,12 +18,12 @@ describe("boardToEmoji", () => { }, ] - const result = boardToEmoji(guesses) + let result = boardToEmoji(guesses) expect(result).toBe("🟩 🟩 🟩 🟩 🟩") }) it("converts all Miss states to red squares", () => { - const guesses = [ + let guesses = [ { letters: [ { id: "1", letter: "w", state: LetterState.Miss }, @@ -35,12 +35,12 @@ describe("boardToEmoji", () => { }, ] - const result = boardToEmoji(guesses) + let result = boardToEmoji(guesses) expect(result).toBe("🟥 🟥 🟥 🟥 🟥") }) it("converts all Present states to yellow squares", () => { - const guesses = [ + let guesses = [ { letters: [ { id: "1", letter: "c", state: LetterState.Present }, @@ -52,12 +52,12 @@ describe("boardToEmoji", () => { }, ] - const result = boardToEmoji(guesses) + let result = boardToEmoji(guesses) expect(result).toBe("🟨 🟨 🟨 🟨 🟨") }) it("converts all Blank states to white squares", () => { - const guesses = [ + let guesses = [ { letters: [ { id: "1", letter: "", state: LetterState.Blank }, @@ -69,12 +69,12 @@ describe("boardToEmoji", () => { }, ] - const result = boardToEmoji(guesses) + let result = boardToEmoji(guesses) expect(result).toBe("⬜️ ⬜️ ⬜️ ⬜️ ⬜️") }) it("handles mixed letter states correctly", () => { - const guesses = [ + let guesses = [ { letters: [ { id: "1", letter: "b", state: LetterState.Match }, @@ -86,12 +86,12 @@ describe("boardToEmoji", () => { }, ] - const result = boardToEmoji(guesses) + let result = boardToEmoji(guesses) expect(result).toBe("🟩 🟥 🟨 🟥 🟩") }) it("handles multiple rows with newline separation", () => { - const guesses = [ + let guesses = [ { letters: [ { id: "1", letter: "f", state: LetterState.Miss }, @@ -112,18 +112,18 @@ describe("boardToEmoji", () => { }, ] - const result = boardToEmoji(guesses) + let result = boardToEmoji(guesses) expect(result).toBe("🟥 🟨 🟥 🟩 🟥\n🟩 🟩 🟩 🟩 🟥") }) it("handles empty guesses array", () => { - const guesses: Array<{ letters: Array }> = [] - const result = boardToEmoji(guesses) + let guesses: Array<{ letters: Array }> = [] + let result = boardToEmoji(guesses) expect(result).toBe("") }) it("handles typical game progression (6 rows)", () => { - const guesses = [ + let guesses = [ { letters: [ { id: "1", letter: "s", state: LetterState.Present }, @@ -153,12 +153,12 @@ describe("boardToEmoji", () => { }, ] - const result = boardToEmoji(guesses) + let result = boardToEmoji(guesses) expect(result).toBe("🟨 🟥 🟥 🟨 🟥\n🟩 🟥 🟥 🟥 🟨\n🟩 🟩 🟩 🟩 🟩") }) it("handles rows with blank letters (incomplete guesses)", () => { - const guesses = [ + let guesses = [ { letters: [ { id: "1", letter: "t", state: LetterState.Match }, @@ -179,12 +179,12 @@ describe("boardToEmoji", () => { }, ] - const result = boardToEmoji(guesses) + let result = boardToEmoji(guesses) expect(result).toBe("🟩 🟨 🟥 🟥 🟨\n⬜️ ⬜️ ⬜️ ⬜️ ⬜️") }) it("throws error for unknown letter state", () => { - const guesses = [ + let guesses = [ { letters: [{ id: "1", letter: "x", state: "Invalid" as any }] satisfies Array, }, diff --git a/app/utils/db.ts b/app/utils/db.ts index 12f635a..f9f56fa 100644 --- a/app/utils/db.ts +++ b/app/utils/db.ts @@ -4,7 +4,7 @@ import Redis from "ioredis" import { env } from "../constants" import { PrismaClient } from "../generated/prisma/client" -let adapter = new PrismaPg({ connectionString: process.env.DATABASE_URL }) +const adapter = new PrismaPg({ connectionString: process.env.DATABASE_URL }) export const db = new PrismaClient({ adapter }) export const redis = new Redis(env.REDIS_URL, { diff --git a/app/utils/game.test.ts b/app/utils/game.test.ts index 2bfff83..220771e 100644 --- a/app/utils/game.test.ts +++ b/app/utils/game.test.ts @@ -88,17 +88,17 @@ describe("isValidWord", () => { describe("getRandomWord", () => { it("returns a string", () => { - const word = getRandomWord() + let word = getRandomWord() expect(typeof word).toBe("string") }) it("returns a word of expected length", () => { - const word = getRandomWord() + let word = getRandomWord() expect(word.length).toBeGreaterThan(0) }) it("returns different words on multiple calls (probabilistic)", () => { - const words = new Set() + let words = new Set() // Generate 100 words - very likely to get at least 2 different ones for (let i = 0; i < 100; i++) { words.add(getRandomWord()) @@ -107,32 +107,32 @@ describe("getRandomWord", () => { }) it("returns a valid word from the word bank", () => { - const word = getRandomWord() + let word = getRandomWord() expect(isValidWord(word)).toBe(true) }) }) describe("createEmptyLetter", () => { it("creates a letter with Blank state", () => { - const letter = createEmptyLetter() + let letter = createEmptyLetter() expect(letter.state).toBe(LetterState.Blank) }) it("creates a letter with empty string", () => { - const letter = createEmptyLetter() + let letter = createEmptyLetter() expect(letter.letter).toBe("") }) it("creates a letter with a unique id", () => { - const letter1 = createEmptyLetter() - const letter2 = createEmptyLetter() + let letter1 = createEmptyLetter() + let letter2 = createEmptyLetter() expect(letter1.id).toBeTruthy() expect(letter2.id).toBeTruthy() expect(letter1.id).not.toBe(letter2.id) }) it("generates different ids for multiple calls", () => { - const ids = new Set() + let ids = new Set() for (let i = 0; i < 10; i++) { ids.add(createEmptyLetter().id) } @@ -142,8 +142,8 @@ describe("createEmptyLetter", () => { describe("keyboardWithStatus", () => { it("returns keyboard with all blank states for no guesses", () => { - const guesses: Array<{ letters: Array }> = [] - const keyboard = keyboardWithStatus(guesses) + let guesses: Array<{ letters: Array }> = [] + let keyboard = keyboardWithStatus(guesses) expect(keyboard).toHaveLength(3) // Three rows for (let row of keyboard) { @@ -154,7 +154,7 @@ describe("keyboardWithStatus", () => { }) it("marks correctly guessed letters as Match", () => { - const guesses = [ + let guesses = [ { letters: [ { id: "1", letter: "h", state: LetterState.Match }, @@ -166,13 +166,13 @@ describe("keyboardWithStatus", () => { }, ] - const keyboard = keyboardWithStatus(guesses) - const hKey = keyboard.flat().find((k) => k.letter === "h") + let keyboard = keyboardWithStatus(guesses) + let hKey = keyboard.flat().find((k) => k.letter === "h") expect(hKey?.state).toBe(LetterState.Match) }) it("marks present letters as Present", () => { - const guesses = [ + let guesses = [ { letters: [ { id: "1", letter: "t", state: LetterState.Present }, @@ -184,13 +184,13 @@ describe("keyboardWithStatus", () => { }, ] - const keyboard = keyboardWithStatus(guesses) - const tKey = keyboard.flat().find((k) => k.letter === "t") + let keyboard = keyboardWithStatus(guesses) + let tKey = keyboard.flat().find((k) => k.letter === "t") expect(tKey?.state).toBe(LetterState.Present) }) it("prioritizes Match over Present for same letter", () => { - const guesses = [ + let guesses = [ { letters: [ { id: "1", letter: "a", state: LetterState.Present }, @@ -211,13 +211,13 @@ describe("keyboardWithStatus", () => { }, ] - const keyboard = keyboardWithStatus(guesses) - const aKey = keyboard.flat().find((k) => k.letter === "a") + let keyboard = keyboardWithStatus(guesses) + let aKey = keyboard.flat().find((k) => k.letter === "a") expect(aKey?.state).toBe(LetterState.Match) }) it("marks missed letters as Miss", () => { - const guesses = [ + let guesses = [ { letters: [ { id: "1", letter: "x", state: LetterState.Miss }, @@ -229,10 +229,10 @@ describe("keyboardWithStatus", () => { }, ] - const keyboard = keyboardWithStatus(guesses) - const xKey = keyboard.flat().find((k) => k.letter === "x") - const yKey = keyboard.flat().find((k) => k.letter === "y") - const zKey = keyboard.flat().find((k) => k.letter === "z") + let keyboard = keyboardWithStatus(guesses) + let xKey = keyboard.flat().find((k) => k.letter === "x") + let yKey = keyboard.flat().find((k) => k.letter === "y") + let zKey = keyboard.flat().find((k) => k.letter === "z") expect(xKey?.state).toBe(LetterState.Miss) expect(yKey?.state).toBe(LetterState.Miss) @@ -240,7 +240,7 @@ describe("keyboardWithStatus", () => { }) it("ignores blank letter states", () => { - const guesses = [ + let guesses = [ { letters: [ { id: "1", letter: "", state: LetterState.Blank }, @@ -252,7 +252,7 @@ describe("keyboardWithStatus", () => { }, ] - const keyboard = keyboardWithStatus(guesses) + let keyboard = keyboardWithStatus(guesses) for (let row of keyboard) { for (let key of row) { expect(key.state).toBe(LetterState.Blank) @@ -261,7 +261,7 @@ describe("keyboardWithStatus", () => { }) it("handles multiple guesses with mixed states", () => { - const guesses = [ + let guesses = [ { letters: [ { id: "1", letter: "s", state: LetterState.Present }, @@ -282,13 +282,13 @@ describe("keyboardWithStatus", () => { }, ] - const keyboard = keyboardWithStatus(guesses) - const sKey = keyboard.flat().find((k) => k.letter === "s") - const hKey = keyboard.flat().find((k) => k.letter === "h") - const aKey = keyboard.flat().find((k) => k.letter === "a") - const rKey = keyboard.flat().find((k) => k.letter === "r") - const tKey = keyboard.flat().find((k) => k.letter === "t") - const eKey = keyboard.flat().find((k) => k.letter === "e") + let keyboard = keyboardWithStatus(guesses) + let sKey = keyboard.flat().find((k) => k.letter === "s") + let hKey = keyboard.flat().find((k) => k.letter === "h") + let aKey = keyboard.flat().find((k) => k.letter === "a") + let rKey = keyboard.flat().find((k) => k.letter === "r") + let tKey = keyboard.flat().find((k) => k.letter === "t") + let eKey = keyboard.flat().find((k) => k.letter === "e") expect(sKey?.state).toBe(LetterState.Match) // Upgraded from Present to Match expect(hKey?.state).toBe(LetterState.Match) @@ -299,8 +299,8 @@ describe("keyboardWithStatus", () => { }) it("returns correct keyboard structure (3 rows with specific letters)", () => { - const guesses: Array<{ letters: Array }> = [] - const keyboard = keyboardWithStatus(guesses) + let guesses: Array<{ letters: Array }> = [] + let keyboard = keyboardWithStatus(guesses) let [row1, row2, row3] = keyboard @@ -363,9 +363,9 @@ describe("computeGuess - additional edge cases", () => { }) it("generates unique IDs for each letter", () => { - const result = computeGuess("hello", "world") - const ids = result.map((r) => r.id) - const uniqueIds = new Set(ids) + let result = computeGuess("hello", "world") + let ids = result.map((r) => r.id) + let uniqueIds = new Set(ids) expect(uniqueIds.size).toBe(ids.length) }) }) diff --git a/app/utils/game.ts b/app/utils/game.ts index 9031077..80afa1f 100644 --- a/app/utils/game.ts +++ b/app/utils/game.ts @@ -35,15 +35,15 @@ export function computeGuess(guess: string, answer: string): Array s.segment) let answerLetterCount: Record = {} - for (const letter of answerLetters) { + for (let letter of answerLetters) { answerLetterCount[letter] = (answerLetterCount[letter] ?? 0) + 1 } - for (const [index, guessLetter] of guessLetters.entries()) { - const answerLetter = answerLetters.at(index) + for (let [index, guessLetter] of guessLetters.entries()) { + let answerLetter = answerLetters.at(index) if (!answerLetter) continue - const id = genId() + let id = genId() if (guessLetter === answerLetter) { result.push({ id, letter: guessLetter, state: LetterState.Match }) @@ -53,12 +53,12 @@ export function computeGuess(guess: string, answer: string): Array 0) { @@ -72,10 +72,10 @@ export function computeGuess(guess: string, answer: string): Array { return new Promise((resolve) => setTimeout(resolve, delay)) } -export let handlers = [ +export const handlers = [ // Intercept all HTTP requests. http.all("*", async () => { // Apply random delay to them. diff --git a/mocks/test.ts b/mocks/test.ts index 9c838c0..429d03d 100644 --- a/mocks/test.ts +++ b/mocks/test.ts @@ -2,7 +2,7 @@ import { setupServer } from "msw/node" import { handlers } from "./handlers.ts" -let server = setupServer(...handlers) +const server = setupServer(...handlers) server.listen({ onUnhandledRequest: "warn" }) console.info("🔶 Mock server running") diff --git a/oxlint-plugins/prefer-let-locals-plugin.ts b/oxlint-plugins/prefer-let-locals-plugin.ts new file mode 100644 index 0000000..4af09a5 --- /dev/null +++ b/oxlint-plugins/prefer-let-locals-plugin.ts @@ -0,0 +1,110 @@ +import { definePlugin, defineRule } from "@oxlint/plugins" +import type { Context, ESTree, Fixer, Variable } from "@oxlint/plugins" + +function isModuleScopeDeclaration(node: ESTree.VariableDeclaration) { + let parent = node.parent + + if (parent.type === "Program") { + return true + } + + if (parent.type !== "ExportNamedDeclaration" && parent.type !== "ExportDefaultDeclaration") { + return false + } + + return parent.parent.type === "Program" +} + +function hasInitializers(node: ESTree.VariableDeclaration) { + return node.declarations.every((declaration) => declaration.init != null) +} + +function isConstEligibleModuleBinding(variable: Variable) { + return variable.references.every((reference) => !reference.isWrite() || reference.init) +} + +const preferLetLocalsRule = defineRule({ + meta: { + type: "suggestion", + fixable: "code", + }, + create(context: Context) { + return { + VariableDeclaration(node: ESTree.VariableDeclaration) { + if (node.kind !== "const") { + return + } + + if (isModuleScopeDeclaration(node)) { + return + } + + context.report({ + node, + message: "Use let for local bindings; reserve const for module scope.", + fix(fixer: Fixer) { + return fixer.replaceTextRange([node.range[0], node.range[0] + 5], "let") + }, + }) + }, + } + }, +}) + +const preferConstModuleScopeRule = defineRule({ + meta: { + type: "suggestion", + fixable: "code", + }, + create(context: Context) { + return { + VariableDeclaration(node: ESTree.VariableDeclaration) { + if (node.kind !== "let") { + return + } + + if (!isModuleScopeDeclaration(node)) { + return + } + + if (!hasInitializers(node)) { + return + } + + let declaredVariables = context.sourceCode.getDeclaredVariables(node) + + if (declaredVariables.length === 0) { + return + } + + if (!declaredVariables.every(isConstEligibleModuleBinding)) { + return + } + + context.report({ + node, + message: "Use const for module-scope bindings that are never reassigned.", + fix(fixer: Fixer) { + return fixer.replaceTextRange([node.range[0], node.range[0] + 3], "const") + }, + }) + }, + } + }, +}) + +/** + * Enforces the repo convention that `const` is reserved for module scope and + * local bindings should use `let` instead. Safe module-scope `let` declarations + * are rewritten to `const`, and local `const` declarations are rewritten to + * `let`. + */ +export default definePlugin({ + meta: { + name: "remix-style", + }, + rules: { + "prefer-const-module-scope": preferConstModuleScopeRule, + "prefer-let-locals": preferLetLocalsRule, + }, +}) diff --git a/package.json b/package.json index 5b93365..2a7a122 100644 --- a/package.json +++ b/package.json @@ -31,6 +31,7 @@ "@mcansh/vite-plugin-remix": "catalog:", "@mcansh/vite-plugin-svg-sprite": "catalog:", "@mswjs/http-middleware": "catalog:", + "@oxlint/plugins": "catalog:", "@remix-run/cli": "catalog:", "@tailwindcss/vite": "catalog:", "@total-typescript/tsconfig": "catalog:", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9a318f0..0009b6c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -21,6 +21,9 @@ catalogs: '@mswjs/http-middleware': specifier: ^0.10.3 version: 0.10.3 + '@oxlint/plugins': + specifier: ^1.58.0 + version: 1.58.0 '@prisma/adapter-pg': specifier: ^7.6.0 version: 7.6.0 @@ -136,6 +139,9 @@ importers: '@mswjs/http-middleware': specifier: 'catalog:' version: 0.10.3(msw@2.12.14(@types/node@25.5.2)(typescript@6.0.2)) + '@oxlint/plugins': + specifier: 'catalog:' + version: 1.58.0 '@remix-run/cli': specifier: 'catalog:' version: https://codeload.github.com/remix-run/remix/tar.gz/5c20da47a677c3c3b1ccf4bce7b942bd8dc5ad14#path:packages/cli @@ -814,6 +820,10 @@ packages: cpu: [x64] os: [win32] + '@oxlint/plugins@1.58.0': + resolution: {integrity: sha512-lW4/uOVf0O9qp/niWS+0dTi45SNsLn2e6J2s9r0gl7Uy19T/kWcMIgZjOE5579WPjStYX58Vjw41H/Yi/6Q8xQ==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + '@polka/url@1.0.0-next.29': resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==} @@ -3244,6 +3254,8 @@ snapshots: '@oxlint/binding-win32-x64-msvc@1.58.0': optional: true + '@oxlint/plugins@1.58.0': {} + '@polka/url@1.0.0-next.29': {} '@prisma/adapter-pg@7.6.0': diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index cb711bd..c73c4e7 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -4,6 +4,7 @@ catalog: "@mcansh/vite-plugin-remix": ^0.0.1 "@mcansh/vite-plugin-svg-sprite": ^0.7.1 "@mswjs/http-middleware": ^0.10.3 + "@oxlint/plugins": ^1.58.0 "@prisma/adapter-pg": ^7.6.0 "@prisma/client": ^7.6.0 "@remix-run/cli": github:remix-run/remix#preview/pr-11204&path:packages/cli diff --git a/server.ts b/server.ts index 024ae00..683e9f8 100644 --- a/server.ts +++ b/server.ts @@ -5,7 +5,7 @@ import { createRequestListener } from "remix/node-fetch-server" // @ts-ignore - no types for this import ssr from "./dist/ssr/entry.server.js" -let server = http.createServer( +const server = http.createServer( createRequestListener(async (request) => { try { return await ssr.fetch(request) @@ -16,7 +16,7 @@ let server = http.createServer( }), ) -let port = process.env.PORT ? parseInt(process.env.PORT, 10) : 44100 +const port = process.env.PORT ? parseInt(process.env.PORT, 10) : 44100 server.listen(port, () => { console.log(`✅ App is running on http://localhost:${port}`) diff --git a/vite.config.ts b/vite.config.ts index fc617c0..1f8abec 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -28,7 +28,7 @@ export default defineConfig({ useTabs: true, overrides: [ { - files: ["./app/home/local-schema.ts", "./app/home/local-form-schema.ts"], + files: ["./app/utils/local-schema.ts", "./app/utils/local-form-schema.ts"], options: { singleQuote: true, }, @@ -37,8 +37,11 @@ export default defineConfig({ }, lint: { plugins: ["unicorn", "typescript", "oxc"], + jsPlugins: ["./oxlint-plugins/prefer-let-locals-plugin.ts"], categories: {}, rules: { + "remix-style/prefer-const-module-scope": "error", + "remix-style/prefer-let-locals": "error", "constructor-super": "warn", "for-direction": "warn", "no-async-promise-executor": "warn", From 1870b0ccbc88896175bef0847a792d5aa0ea5acd Mon Sep 17 00:00:00 2001 From: Logan McAnsh Date: Sat, 4 Apr 2026 02:31:58 -0400 Subject: [PATCH 07/10] feat: import aliases Signed-off-by: Logan McAnsh --- .vscode/settings.json | 9 + app/components/document.tsx | 6 +- app/components/form.tsx | 2 +- app/components/game-over-modal.tsx | 12 +- app/components/keyboard.tsx | 2 +- app/controllers/auth/controller.test.ts | 4 +- app/controllers/auth/controller.tsx | 5 +- .../auth/forgot-password/controller.tsx | 25 +- app/controllers/auth/login/controller.tsx | 20 +- app/controllers/auth/register/controller.tsx | 10 +- .../auth/reset-password/controller.tsx | 23 +- app/controllers/health.tsx | 4 +- app/controllers/history/controller.tsx | 13 +- app/controllers/history/game.tsx | 12 +- app/controllers/history/history-page.tsx | 6 +- app/controllers/history/not-found-page.tsx | 2 +- app/controllers/home/controller.test.ts | 7 +- app/controllers/home/controller.tsx | 17 +- app/controllers/home/local-form-schema.ts | 310 ----- app/controllers/home/local-schema.ts | 1184 ----------------- app/controllers/home/page.tsx | 15 +- app/middleware/auth.ts | 10 +- app/models/game.ts | 12 +- app/models/user.ts | 6 +- app/utils/auth-session.ts | 4 +- app/utils/db.ts | 4 +- app/utils/queue.ts | 5 +- app/utils/render.ts | 2 +- app/utils/session.ts | 2 +- oxlint-plugins/prefer-import-alias-plugin.ts | 60 + package.json | 3 + vite.config.ts | 11 +- 32 files changed, 206 insertions(+), 1601 deletions(-) create mode 100644 .vscode/settings.json delete mode 100644 app/controllers/home/local-form-schema.ts delete mode 100644 app/controllers/home/local-schema.ts create mode 100644 oxlint-plugins/prefer-import-alias-plugin.ts diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..7d37639 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,9 @@ +{ + "editor.defaultFormatter": "oxc.oxc-vscode", + "oxc.fmt.configPath": "./vite.config.ts", + "editor.formatOnSave": true, + "editor.formatOnSaveMode": "file", + "editor.codeActionsOnSave": { + "source.fixAll.oxc": "explicit" + } +} diff --git a/app/components/document.tsx b/app/components/document.tsx index 0f77759..5c3b071 100644 --- a/app/components/document.tsx +++ b/app/components/document.tsx @@ -1,10 +1,10 @@ import "@mcansh/vite-plugin-remix/types" import type { RemixNode } from "remix/component" -import clientAssets from "../entry.browser.ts?assets=client" -import serverAssets from "../entry.server.tsx?assets=ssr" +import clientAssets from "#app/entry.browser.ts?assets=client" +import serverAssets from "#app/entry.server.tsx?assets=ssr" -import appStylesHref from "../app.css?url" +import appStylesHref from "#app/app.css?url" const assets = clientAssets.merge(serverAssets) diff --git a/app/components/form.tsx b/app/components/form.tsx index 743284d..c54de02 100644 --- a/app/components/form.tsx +++ b/app/components/form.tsx @@ -3,7 +3,7 @@ import type { RemixNode } from "remix/component" import { on } from "remix/component" -import { routes } from "../routes" +import { routes } from "#app/routes.ts" export function GuessForm() { return ({ currentGuess, children }: { currentGuess: number; children: RemixNode }) => { diff --git a/app/components/game-over-modal.tsx b/app/components/game-over-modal.tsx index 63ce3ce..1d94fbb 100644 --- a/app/components/game-over-modal.tsx +++ b/app/components/game-over-modal.tsx @@ -1,14 +1,14 @@ "use client" import clsx from "clsx" -import { on, pressEvents } from "remix/component" import type { Handle } from "remix/component" +import { on, pressEvents } from "remix/component" -import checkIconUrl from "../icons/check.svg" -import xIconUrl from "../icons/x.svg" -import { routes } from "../routes" -import { boardToEmoji } from "../utils/board-to-emoji" -import type { ComputedGuess } from "../utils/game" +import checkIconUrl from "#app/icons/check.svg" +import xIconUrl from "#app/icons/x.svg" +import { routes } from "#app/routes.ts" +import { boardToEmoji } from "#app/utils/board-to-emoji.ts" +import type { ComputedGuess } from "#app/utils/game.ts" type GameOverModalProps = { currentGuess: number diff --git a/app/components/keyboard.tsx b/app/components/keyboard.tsx index 0562c95..72ac8bd 100644 --- a/app/components/keyboard.tsx +++ b/app/components/keyboard.tsx @@ -1,6 +1,6 @@ import { clsx } from "clsx" -import type { keyboardWithStatus } from "../utils/game" +import type { keyboardWithStatus } from "#app/utils/game.ts" export function Keyboard() { return ({ board }: { board: ReturnType }) => { diff --git a/app/controllers/auth/controller.test.ts b/app/controllers/auth/controller.test.ts index 0a0bed5..b23cd4a 100644 --- a/app/controllers/auth/controller.test.ts +++ b/app/controllers/auth/controller.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it, vi } from "vite-plus/test" -import { assertContains, getSessionCookie } from "../../../test/helpers.ts" -import { router } from "../../router.ts" +import { router } from "#app/router.ts" +import { assertContains, getSessionCookie } from "#test/helpers.ts" vi.mock("./models/user.ts", async (importActual) => { let actual = await importActual() diff --git a/app/controllers/auth/controller.tsx b/app/controllers/auth/controller.tsx index 01b3b31..c8f6314 100644 --- a/app/controllers/auth/controller.tsx +++ b/app/controllers/auth/controller.tsx @@ -2,8 +2,9 @@ import type { Controller } from "remix/fetch-router" import { createRedirectResponse as redirect } from "remix/response/redirect" import { Session } from "remix/session" -import { loadAuth } from "../../middleware/auth.ts" -import { routes } from "../../routes.ts" +import { loadAuth } from "#app/middleware/auth.ts" +import { routes } from "#app/routes.ts" + import { forgotPasswordController } from "./forgot-password/controller.tsx" import { loginController } from "./login/controller.tsx" import { registerController } from "./register/controller.tsx" diff --git a/app/controllers/auth/forgot-password/controller.tsx b/app/controllers/auth/forgot-password/controller.tsx index d10d5e0..ee7db79 100644 --- a/app/controllers/auth/forgot-password/controller.tsx +++ b/app/controllers/auth/forgot-password/controller.tsx @@ -1,9 +1,11 @@ +import * as s from "remix/data-schema" +import * as f from "remix/data-schema/form-data" import type { Controller } from "remix/fetch-router" -import { Document } from "../../../components/document" -import { createPasswordResetToken } from "../../../models/user" -import { routes } from "../../../routes" -import { render } from "../../../utils/render" +import { Document } from "#app/components/document.tsx" +import { createPasswordResetToken } from "#app/models/user.ts" +import { routes } from "#app/routes.ts" +import { render } from "#app/utils/render.ts" export const forgotPasswordController = { actions: { @@ -35,13 +37,22 @@ export const forgotPasswordController = { async action({ get, url }) { let formData = get(FormData) - let email = formData.get("email")?.toString() ?? "" - let token = createPasswordResetToken(email) + + let forgotPasswordSchema = f.object({ + email: f.field(s.defaulted(s.string(), "")), + }) + + let parsed = s.parse(forgotPasswordSchema, formData) + + let token = createPasswordResetToken(parsed.email) return render( Login - Remix Wordle}>
    -
    Password reset link sent! Check your email.
    +
    + Password reset link sent! If we have found a user with that email, you will receive a + link to reset your password. +
    {token ? (
    diff --git a/app/controllers/auth/login/controller.tsx b/app/controllers/auth/login/controller.tsx index 91677a3..93a8d32 100644 --- a/app/controllers/auth/login/controller.tsx +++ b/app/controllers/auth/login/controller.tsx @@ -2,17 +2,15 @@ import type { Controller } from "remix/fetch-router" import { redirect } from "remix/response/redirect" import { Session } from "remix/session" -import { Document } from "../../../components/document" -import { authenticateUser } from "../../../models/user" -import { routes } from "../../../routes" -import { render } from "../../../utils/render" -import * as localSchema from "../../home/local-schema" +import { Document } from "#app/components/document.tsx" +import { authenticateUser } from "#app/models/user.ts" +import { routes } from "#app/routes.ts" +import * as s from "#app/utils/local-schema.ts" +import { render } from "#app/utils/render.ts" -const { parse } = localSchema - -const loginSchema = localSchema.object({ - email: localSchema.string(), - password: localSchema.string(), +const loginSchema = s.object({ + email: s.string(), + password: s.string(), }) export const loginController = { @@ -20,7 +18,7 @@ export const loginController = { async action({ get, url }) { let session = get(Session) let formData = get(FormData) - let result = parse(loginSchema, formData) + let result = s.parse(loginSchema, formData) let returnTo = url.searchParams.get("returnTo") let user = await authenticateUser(result.email, result.password) diff --git a/app/controllers/auth/register/controller.tsx b/app/controllers/auth/register/controller.tsx index fe1e50d..7a6e967 100644 --- a/app/controllers/auth/register/controller.tsx +++ b/app/controllers/auth/register/controller.tsx @@ -2,11 +2,11 @@ import type { Controller } from "remix/fetch-router" import { redirect } from "remix/response/redirect" import { Session } from "remix/session" -import { Document } from "../../../components/document" -import { joinSchema, getUserByEmail, createUser } from "../../../models/user" -import { routes } from "../../../routes" -import { render } from "../../../utils/render" -import { parse } from "../../home/local-schema" +import { Document } from "#app/components/document.tsx" +import { createUser, getUserByEmail, joinSchema } from "#app/models/user.ts" +import { routes } from "#app/routes.ts" +import { parse } from "#app/utils/local-schema.ts" +import { render } from "#app/utils/render.ts" export const registerController = { actions: { diff --git a/app/controllers/auth/reset-password/controller.tsx b/app/controllers/auth/reset-password/controller.tsx index 0129974..5c209fe 100644 --- a/app/controllers/auth/reset-password/controller.tsx +++ b/app/controllers/auth/reset-password/controller.tsx @@ -1,11 +1,13 @@ +import * as s from "remix/data-schema" +import * as f from "remix/data-schema/form-data" import type { Controller } from "remix/fetch-router" import { redirect } from "remix/response/redirect" import { Session } from "remix/session" -import { Document } from "../../../components/document" -import { resetPassword } from "../../../models/user" -import { routes } from "../../../routes" -import { render } from "../../../utils/render" +import { Document } from "#app/components/document.tsx" +import { resetPassword } from "#app/models/user.ts" +import { routes } from "#app/routes.ts" +import { render } from "#app/utils/render.ts" export const resetPasswordController = { actions: { @@ -61,15 +63,20 @@ export const resetPasswordController = { async action({ get, params, url }) { let session = get(Session) let formData = get(FormData) - let password = formData.get("password")?.toString() ?? "" - let confirmPassword = formData.get("confirmPassword")?.toString() ?? "" - if (password !== confirmPassword) { + let resetPasswordSchema = f.object({ + password: f.field(s.string()), + confirmPassword: f.field(s.string()), + }) + + let parsed = s.parse(resetPasswordSchema, formData) + + if (parsed.password !== parsed.confirmPassword) { session.flash("error", "Passwords do not match.") return redirect(routes.auth.resetPassword.index.href({ token: params.token })) } - let success = resetPassword(params.token, password) + let success = resetPassword(params.token, parsed.password) if (!success) { session.flash("error", "Invalid or expired reset token.") diff --git a/app/controllers/health.tsx b/app/controllers/health.tsx index c6deaf6..8a7bda1 100644 --- a/app/controllers/health.tsx +++ b/app/controllers/health.tsx @@ -1,7 +1,7 @@ import type { BuildAction } from "remix/fetch-router" -import { routes } from "../routes.ts" -import { db } from "../utils/db.ts" +import { routes } from "#app/routes.ts" +import { db } from "#app/utils/db.ts" export const health = { middleware: [], diff --git a/app/controllers/history/controller.tsx b/app/controllers/history/controller.tsx index 4fa10f7..b68db88 100644 --- a/app/controllers/history/controller.tsx +++ b/app/controllers/history/controller.tsx @@ -2,12 +2,13 @@ import { Auth, type BadAuth, type GoodAuth } from "remix/auth-middleware" import type { Controller } from "remix/fetch-router" import { redirect } from "remix/response/redirect" -import { getReturnToQuery, requireAuth } from "../../middleware/auth.ts" -import { getGameById, isGameComplete } from "../../models/game.ts" -import { routes } from "../../routes.ts" -import type { AuthIdentity } from "../../utils/auth-session.ts" -import { db } from "../../utils/db.ts" -import { render } from "../../utils/render.ts" +import { getReturnToQuery, requireAuth } from "#app/middleware/auth.ts" +import { getGameById, isGameComplete } from "#app/models/game.ts" +import { routes } from "#app/routes.ts" +import type { AuthIdentity } from "#app/utils/auth-session.ts" +import { db } from "#app/utils/db.ts" +import { render } from "#app/utils/render.ts" + import { HistoricalGame } from "./game.tsx" import { createHistoricalGameListItem, diff --git a/app/controllers/history/game.tsx b/app/controllers/history/game.tsx index ef10099..18f6f9b 100644 --- a/app/controllers/history/game.tsx +++ b/app/controllers/history/game.tsx @@ -1,12 +1,12 @@ import { clsx } from "clsx" import type { Handle } from "remix/component" -import { Document } from "../../components/document" -import { GameOverModal } from "../../components/game-over-modal" -import { Keyboard } from "../../components/keyboard" -import { TOTAL_GUESSES } from "../../constants" -import type { GameBoard } from "../../models/game" -import { LetterState } from "../../utils/game" +import { Document } from "#app/components/document.tsx" +import { GameOverModal } from "#app/components/game-over-modal.tsx" +import { Keyboard } from "#app/components/keyboard.tsx" +import { TOTAL_GUESSES } from "#app/constants.ts" +import type { GameBoard } from "#app/models/game.ts" +import { LetterState } from "#app/utils/game.ts" export function HistoricalGame(_handle: Handle, { url }: { url: URL }) { return ({ game, showModal }: { game: GameBoard; showModal: boolean }) => { diff --git a/app/controllers/history/history-page.tsx b/app/controllers/history/history-page.tsx index 76bede9..ce38ce4 100644 --- a/app/controllers/history/history-page.tsx +++ b/app/controllers/history/history-page.tsx @@ -1,9 +1,9 @@ import { clsx } from "clsx" import type { Handle } from "remix/component" -import { Document } from "../../components/document" -import type { Prisma } from "../../generated/prisma/client" -import { routes } from "../../routes" +import { Document } from "#app/components/document.tsx" +import type { Prisma } from "#app/generated/prisma/client.ts" +import { routes } from "#app/routes.ts" const shortDateFormatter = new Intl.DateTimeFormat("en-US", { dateStyle: "short", diff --git a/app/controllers/history/not-found-page.tsx b/app/controllers/history/not-found-page.tsx index 2942a3f..368d588 100644 --- a/app/controllers/history/not-found-page.tsx +++ b/app/controllers/history/not-found-page.tsx @@ -1,6 +1,6 @@ import type { Handle } from "remix/component" -import { Document } from "../../components/document" +import { Document } from "#app/components/document.tsx" export function GameNotFound(_handle: Handle, { url }: { url: URL }) { return () => { diff --git a/app/controllers/home/controller.test.ts b/app/controllers/home/controller.test.ts index 6d4d4c2..8d706ad 100644 --- a/app/controllers/home/controller.test.ts +++ b/app/controllers/home/controller.test.ts @@ -1,9 +1,10 @@ import { parse, parseSafe } from "remix/data-schema" import { describe, expect, it, vi } from "vite-plus/test" -import { assertContains, loginAsCustomer, requestWithSession } from "../../../test/helpers.ts" -import type { FullGame } from "../../models/game.ts" -import { router } from "../../router.ts" +import type { FullGame } from "#app/models/game.ts" +import { router } from "#app/router.ts" +import { assertContains, loginAsCustomer, requestWithSession } from "#test/helpers.ts" + import { guessWordSchema } from "./controller.tsx" vi.mock("./models/game.ts", async (importActual) => { diff --git a/app/controllers/home/controller.tsx b/app/controllers/home/controller.tsx index 18f1e74..01c1372 100644 --- a/app/controllers/home/controller.tsx +++ b/app/controllers/home/controller.tsx @@ -3,14 +3,15 @@ import type { Controller } from "remix/fetch-router" import { createRedirectResponse, redirect } from "remix/response/redirect" import { Session } from "remix/session" -import { REVEAL_WORD, WORD_LENGTH } from "../../constants.ts" -import { getReturnToQuery, requireAuth } from "../../middleware/auth.ts" -import { createGuess, getFullBoard, getTodaysGame, isGameComplete } from "../../models/game.ts" -import { routes } from "../../routes.ts" -import type { AuthIdentity } from "../../utils/auth-session.ts" -import * as f from "../../utils/local-form-schema.ts" -import * as s from "../../utils/local-schema.ts" -import { render } from "../../utils/render.ts" +import { REVEAL_WORD, WORD_LENGTH } from "#app/constants.ts" +import { getReturnToQuery, requireAuth } from "#app/middleware/auth.ts" +import { createGuess, getFullBoard, getTodaysGame, isGameComplete } from "#app/models/game.ts" +import { routes } from "#app/routes.ts" +import type { AuthIdentity } from "#app/utils/auth-session.ts" +import * as f from "#app/utils/local-form-schema.ts" +import * as s from "#app/utils/local-schema.ts" +import { render } from "#app/utils/render.ts" + import { Page } from "./page.tsx" export function validLength(length: number): s.Check> { diff --git a/app/controllers/home/local-form-schema.ts b/app/controllers/home/local-form-schema.ts deleted file mode 100644 index fcf6d57..0000000 --- a/app/controllers/home/local-form-schema.ts +++ /dev/null @@ -1,310 +0,0 @@ -import type { InferOutput, Issue, ParseOptions, Schema } from "./local-schema.ts" -import { createIssue, createSchema, fail } from "./local-schema.ts" - -type FormDataEntryKind = "field" | "fields" | "file" | "files" - -/** - * A Standard Schema-compatible input type for form-like data containers. - */ -export type FormDataSource = FormData | URLSearchParams - -type FormDataParseResult = { value: output } | { issues: ReadonlyArray } - -type FormDataValidationContext = { - path: NonNullable - options?: ParseOptions -} - -/** - * A schema entry that reads one or more values from `FormData` or `URLSearchParams` and validates - * them. - */ -export interface FormDataEntrySchema { - /** The parsing mode used to read values from the input object. */ - kind: FormDataEntryKind - /** The form field name to read. Defaults to the object key passed to `object()`. */ - name?: string - /** The schema used to validate the parsed value or values. */ - schema: Schema -} - -/** - * Options for parsing a single text field from `FormData` or `URLSearchParams`. - */ -export interface FormDataFieldOptions { - /** The form field name to read. Defaults to the object key passed to `object()`. */ - name?: string -} - -/** - * Options for parsing repeated text fields from `FormData` or `URLSearchParams`. - */ -export interface FormDataFieldsOptions { - /** The form field name to read. Defaults to the object key passed to `object()`. */ - name?: string -} - -/** - * Options for parsing a single file field from `FormData`. - */ -export interface FormDataFileOptions { - /** The form field name to read. Defaults to the object key passed to `object()`. */ - name?: string -} - -/** - * Options for parsing repeated file fields from `FormData`. - */ -export interface FormDataFilesOptions { - /** The form field name to read. Defaults to the object key passed to `object()`. */ - name?: string -} - -/** - * A schema-like object that describes the fields to parse from `FormData` or `URLSearchParams`. - */ -export type FormDataSchema = Record> - -/** - * A Standard Schema-compatible schema that validates a `FormData` or `URLSearchParams` object. - */ -export type FormDataObjectSchema = Schema< - FormDataSource, - ParsedFormData -> - -/** - * The typed result produced by `object()` for a given form-data shape. - */ -export type ParsedFormData = { - [key in keyof schema]: schema[key] extends FormDataEntrySchema ? output : never -} - -/** - * Creates a Standard Schema-compatible schema that reads typed values from a `FormData` or - * `URLSearchParams` object. - * - * Use the returned schema with `parse()` or `parseSafe()` from `@remix-run/data-schema`. - * - * @param schema The form-data shape describing the fields to read and validate. - * @returns A schema that validates a `FormData` or `URLSearchParams` object and produces typed - * output. - */ -export function object( - schema: schema, -): FormDataObjectSchema { - return createSchema(function validate(value, context) { - if (!isFormDataSource(value)) { - return fail("Expected FormData or URLSearchParams", context.path, { - code: "type.form_data_source", - input: value, - parseOptions: context.options, - }) - } - - let abortEarly = shouldAbortEarly(context.options) - let issues: Issue[] = [] - let output: Partial> = {} - - for (let [key, entrySchema] of Object.entries(schema) as [ - keyof schema & string, - FormDataEntrySchema, - ][]) { - let result = parseField(value, key, entrySchema, context) - - if ("issues" in result) { - if (abortEarly) { - return { issues: result.issues } - } - - issues.push(...result.issues) - continue - } - - output[key] = result.value - } - - if (issues.length > 0) { - return { issues } - } - - return { value: output as ParsedFormData } - }) -} - -/** - * Creates a schema entry for a single text field from `FormData` or `URLSearchParams`. - * - * @param schema The schema used to validate the parsed field value. - * @param options Parsing options for the field. - * @returns A field schema entry for use with `object()`. - */ -export function field>( - schema: schema, - options?: FormDataFieldOptions, -): FormDataEntrySchema> { - return { - kind: "field", - name: options?.name, - schema, - } -} - -/** - * Creates a schema entry for repeated text fields from `FormData` or `URLSearchParams`. - * - * @param schema The schema used to validate the parsed field values. - * @param options Parsing options for the field. - * @returns A field schema entry for use with `object()`. - */ -export function fields>( - schema: schema, - options?: FormDataFieldsOptions, -): FormDataEntrySchema> { - return { - kind: "fields", - name: options?.name, - schema, - } -} - -/** - * Creates a schema entry for a single file field from `FormData`. - * - * @param schema The schema used to validate the parsed file value. - * @param options Parsing options for the field. - * @returns A file schema entry for use with `object()`. - */ -export function file>( - schema: schema, - options?: FormDataFileOptions, -): FormDataEntrySchema> { - return { - kind: "file", - name: options?.name, - schema, - } -} - -/** - * Creates a schema entry for repeated file fields from `FormData`. - * - * @param schema The schema used to validate the parsed file values. - * @param options Parsing options for the field. - * @returns A file schema entry for use with `object()`. - */ -export function files>( - schema: schema, - options?: FormDataFilesOptions, -): FormDataEntrySchema> { - return { - kind: "files", - name: options?.name, - schema, - } -} - -function parseField( - formData: FormDataSource, - key: string, - entrySchema: FormDataEntrySchema, - context: FormDataValidationContext, -): FormDataParseResult { - let fieldName = entrySchema.name ?? key - let keyPath = withPath(context.path, key) - - switch (entrySchema.kind) { - case "field": { - let value = formData.get(fieldName) - - if (value instanceof Blob) { - return { - issues: [createIssue(`Expected text field "${fieldName}"`, keyPath)], - } - } - - return validateParsedValue(keyPath, entrySchema.schema, value ?? undefined, context.options) - } - case "fields": { - let values = formData.getAll(fieldName) - let parsedValues: string[] = [] - let issues: Issue[] = [] - - values.forEach((value, index) => { - if (value instanceof Blob) { - issues.push(createIssue(`Expected text field "${fieldName}"`, withPath(keyPath, index))) - } else { - parsedValues.push(value) - } - }) - - if (issues.length > 0) { - return { issues } - } - - return validateParsedValue(keyPath, entrySchema.schema, parsedValues, context.options) - } - case "file": { - let value = formData.get(fieldName) - - if (value != null && !(value instanceof Blob)) { - return { - issues: [createIssue(`Expected file field "${fieldName}"`, keyPath)], - } - } - - return validateParsedValue(keyPath, entrySchema.schema, value ?? undefined, context.options) - } - case "files": { - let values = formData.getAll(fieldName) - let parsedValues: Blob[] = [] - let issues: Issue[] = [] - - values.forEach((value, index) => { - if (!(value instanceof Blob)) { - issues.push(createIssue(`Expected file field "${fieldName}"`, withPath(keyPath, index))) - } else { - parsedValues.push(value) - } - }) - - if (issues.length > 0) { - return { issues } - } - - return validateParsedValue(keyPath, entrySchema.schema, parsedValues, context.options) - } - } -} - -function validateParsedValue( - path: NonNullable, - schema: Schema, - value: unknown, - options?: ParseOptions, -): FormDataParseResult { - let result = schema["~run"](value, { path, options }) - - if (result.issues) { - return { issues: result.issues } - } - - return { - value: result.value, - } -} - -function shouldAbortEarly(options?: ParseOptions): boolean { - let libraryAbortEarly = (options?.libraryOptions as { abortEarly?: unknown } | undefined) - ?.abortEarly - - return Boolean(options?.abortEarly ?? libraryAbortEarly) -} - -function withPath(path: NonNullable, key: PropertyKey): NonNullable { - return path.length === 0 ? [key] : [...path, key] -} - -function isFormDataSource(value: unknown): value is FormDataSource { - return value instanceof FormData || value instanceof URLSearchParams -} diff --git a/app/controllers/home/local-schema.ts b/app/controllers/home/local-schema.ts deleted file mode 100644 index 00baa4e..0000000 --- a/app/controllers/home/local-schema.ts +++ /dev/null @@ -1,1184 +0,0 @@ -import type { StandardSchemaV1 } from "@standard-schema/spec" - -/** - * A validation issue returned by a schema, compatible with Standard Schema v1. - * - * Issues include a human-readable `message` and an optional `path` that points to the - * failing location in the input (e.g. `['user', 'email']` or `[0, 'id']`). - */ -export type Issue = StandardSchemaV1.Issue - -/** - * The result of schema validation. - * - * On success, `value` is present and `issues` is absent. On failure, `issues` is present. - */ -export type ValidationResult = StandardSchemaV1.Result - -/** - * Options passed to `~standard.validate`. - */ -export type ValidationOptions = StandardSchemaV1.Options - -/** - * Context passed to `errorMap` to customize issue messages. - */ -export type ErrorMapContext = { - code: string - defaultMessage: string - path?: Issue["path"] - values?: Record - input: unknown - locale?: string -} - -/** - * Function used to customize issue messages. - * - * Return `undefined` to use the default message. - */ -export type ErrorMap = (context: ErrorMapContext) => string | undefined - -/** - * Options passed to {@link parse} and {@link parseSafe}. - * - * This mirrors {@link ValidationOptions}, but also supports a convenience `abortEarly` - * option at the top level. - */ -export type ParseOptions = StandardSchemaV1.Options & { - abortEarly?: boolean - errorMap?: ErrorMap - locale?: string -} - -type SyncStandardSchemaProps = Omit< - StandardSchemaV1.Props, - "validate" -> & { - // data-schema is sync-first; keep validate sync. - validate: (value: unknown, options?: ValidationOptions) => ValidationResult - // Preserve Standard Schema's compile-time type channel. - types?: StandardSchemaV1.Types | undefined -} - -type SyncStandardSchema = { - "~standard": SyncStandardSchemaProps -} - -/** - * A reusable check for use with `schema.pipe(...)`. - */ -export type Check = { - check: (value: output) => boolean - message?: string - code?: string - values?: Record -} - -/** - * A sync, Standard Schema v1-compatible schema with a small chainable API. - */ -export type Schema = SyncStandardSchema & { - /** - * Compose one or more reusable checks onto this schema. - * - * Checks run after the underlying schema has validated and produced an `output` value. - * If any check fails, validation fails with an issue (using the check's `message` if provided). - * - * @param checks One or more `Check`s to apply in order - * @returns A new schema with the checks applied - */ - pipe: (...checks: Check[]) => Schema - /** - * Add an inline predicate check onto this schema. - * - * The predicate runs after the underlying schema has validated and produced an `output` value. - * If the predicate returns `false`, validation fails with an issue. - * - * @param predicate A function that returns `true` for valid values - * @param message Optional issue message when the predicate fails - * @returns A new schema with the refinement applied - */ - refine: (predicate: (value: output) => boolean, message?: string) => Schema - /** - * Transform the output of this schema with a function. - * - * The transform function runs after the underlying schema has validated and produced an `output` value. - * The returned schema has a different output type. - * - * @param transform A function that transforms the validated output - * @returns A new schema with the transformation applied - */ - transform: (transform: (value: output) => newOutput) => Schema - /** - * Internal validator used to validate nested values while preserving `path`/`options`. - */ - "~run": (value: unknown, context: ValidationContext) => ValidationResult -} - -/** - * Infers the input type of a schema-like value. - */ -export type InferInput = schema extends StandardSchemaV1 ? input : never - -/** - * Infers the output type of a schema-like value. - */ -export type InferOutput = - schema extends StandardSchemaV1 ? output : never - -type ValidationContext = { - path: NonNullable - options?: ParseOptions -} - -type IssueDescriptor = { - code: string - defaultMessage: string - input: unknown - path?: Issue["path"] - values?: Record -} - -/** - * Creates a sync Standard Schema-compatible schema from a validation function. - * - * @param validator Validator that returns either a parsed value or validation issues. - * @returns A chainable schema object. - */ -export function createSchema( - validator: ( - value: unknown, - context: { path: NonNullable; options?: ParseOptions }, - ) => ValidationResult, -): Schema { - let schema: Schema = { - "~standard": { - version: 1, - vendor: "data-schema", - validate(value: unknown, options?: ValidationOptions) { - return validator(value, { path: [], options }) - }, - }, - "~run"(value: unknown, context: ValidationContext) { - return validator(value, context) - }, - pipe(...checks: Check[]) { - if (checks.length === 0) { - return schema - } - - return createSchema(function validate(value, context) { - let result = schema["~run"](value, context) - - if (result.issues) { - return result - } - - for (let check of checks) { - if (!check.check(result.value)) { - if (!check.code) { - return { issues: [createIssue(check.message ?? "Check failed", context.path)] } - } - - return { - issues: [ - createIssueFromContext(context, { - code: check.code, - defaultMessage: check.message ?? "Check failed", - input: result.value, - values: check.values, - }), - ], - } - } - } - - return result - }) - }, - refine(predicate: (value: output) => boolean, message?: string) { - return createSchema(function validate(value, context) { - let result = schema["~run"](value, context) - - if (result.issues) { - return result - } - - if (!predicate(result.value)) { - if (message !== undefined) { - return { issues: [createIssue(message, context.path)] } - } - - return { - issues: [ - createIssueFromContext(context, { - code: "refine.failed", - defaultMessage: "Refinement failed", - input: result.value, - }), - ], - } - } - - return result - }) - }, - transform(fn: (value: output) => newOutput): Schema { - return createSchema(function validate(value, context) { - let result = schema["~run"](value, context) - - if (result.issues) { - return result - } - - return { value: fn(result.value) } - }) - }, - } - - return schema -} - -function shouldAbortEarly(options?: ParseOptions): boolean { - let libraryAbortEarly = (options?.libraryOptions as { abortEarly?: unknown } | undefined) - ?.abortEarly - let abortEarly = options?.abortEarly ?? libraryAbortEarly - return Boolean(abortEarly) -} - -function withPath(path: NonNullable, key: PropertyKey): NonNullable { - return path.length === 0 ? [key] : [...path, key] -} - -function getErrorMap(options?: ParseOptions): ErrorMap | undefined { - let libraryErrorMap = (options?.libraryOptions as { errorMap?: unknown } | undefined)?.errorMap - - if (typeof options?.errorMap === "function") { - return options.errorMap - } - - if (typeof libraryErrorMap === "function") { - return libraryErrorMap as ErrorMap - } -} - -function getLocale(options?: ParseOptions): string | undefined { - let libraryLocale = (options?.libraryOptions as { locale?: unknown } | undefined)?.locale - - if (typeof options?.locale === "string") { - return options.locale - } - - if (typeof libraryLocale === "string") { - return libraryLocale - } -} - -function resolveIssueMessage(options: ParseOptions | undefined, context: ErrorMapContext): string { - let errorMap = getErrorMap(options) - - if (!errorMap) { - return context.defaultMessage - } - - let message = errorMap(context) - return message ?? context.defaultMessage -} - -function createIssueFromContext(context: ValidationContext, descriptor: IssueDescriptor): Issue { - let path = descriptor.path ?? context.path - let message = resolveIssueMessage(context.options, { - code: descriptor.code, - defaultMessage: descriptor.defaultMessage, - path, - values: descriptor.values, - input: descriptor.input, - locale: getLocale(context.options), - }) - - return createIssue(message, path) -} - -/** - * Creates a Standard Schema issue object. - * - * @param message Human-readable validation message. - * @param path Optional issue path within the input value. - * @returns A Standard Schema issue. - */ -export function createIssue(message: string, path: Issue["path"]): Issue { - return !path || path.length === 0 ? { message } : { message, path } -} - -/** - * Creates a Standard Schema failure result with a single issue. - * - * @param message Human-readable validation message. - * @param path Optional issue path within the input value. - * @param options Optional issue metadata used for localized error mapping. - * @param options.code Optional error code passed to the error map. - * @param options.values Optional values passed to the error map. - * @param options.input Optional input value passed to the error map. - * @param options.parseOptions Optional parse options used for localization and error mapping. - * @returns A failure result containing one issue. - */ -export function fail( - message: string, - path: Issue["path"], - options?: { - code?: string - values?: Record - input?: unknown - parseOptions?: ParseOptions - }, -): StandardSchemaV1.FailureResult { - if (!options?.code) { - return { issues: [createIssue(message, path)] } - } - - let resolvedMessage = resolveIssueMessage(options.parseOptions, { - code: options.code, - defaultMessage: message, - path, - values: options.values, - input: options.input, - locale: getLocale(options.parseOptions), - }) - - return { issues: [createIssue(resolvedMessage, path)] } -} - -/** - * Create a schema that accepts any value without validation. - * - * @returns A schema that produces `unknown` - */ -export function any(): Schema { - return createSchema(function validate(value) { - return { value } - }) -} - -/** - * Create a schema that validates an array by validating each element with `elementSchema`. - * - * @param elementSchema The schema to validate each element - * @returns A schema that produces an array of validated outputs - */ -export function array( - elementSchema: Schema, -): Schema { - return createSchema(function validate(value, context) { - if (!Array.isArray(value)) { - return fail("Expected array", context.path, { - code: "type.array", - input: value, - parseOptions: context.options, - }) - } - - let abortEarly = shouldAbortEarly(context.options) - let issues: Issue[] = [] - let outputValues: output[] = [] - let index = 0 - - for (let item of value) { - let result = elementSchema["~run"](item, { - path: withPath(context.path, index), - options: context.options, - }) - - if (result.issues) { - if (abortEarly) { - return result - } - - issues.push(...result.issues) - } else { - outputValues.push(result.value) - } - - index += 1 - } - - if (issues.length > 0) { - return { issues } - } - - return { value: outputValues } - }) -} - -/** - * Create a schema that accepts bigints. - * - * @returns A schema that produces a `bigint` - */ -export function bigint(): Schema { - return createSchema(function validate(value, context) { - if (typeof value !== "bigint") { - return fail("Expected bigint", context.path, { - code: "type.bigint", - input: value, - parseOptions: context.options, - }) - } - - return { value } - }) -} - -/** - * Create a schema that accepts booleans. - * - * @returns A schema that produces a `boolean` - */ -export function boolean(): Schema { - return createSchema(function validate(value, context) { - if (typeof value !== "boolean") { - return fail("Expected boolean", context.path, { - code: "type.boolean", - input: value, - parseOptions: context.options, - }) - } - - return { value } - }) -} - -/** - * Provide a default when the input is `undefined`. - * - * @param schema The wrapped schema - * @param defaultValue A value or function used to produce the default - * @returns A schema that produces the default when the input is `undefined` - */ -export function defaulted( - schema: Schema, - defaultValue: output | (() => output), -): Schema { - return createSchema(function validate(value, context) { - if (value === undefined) { - let resolved = - typeof defaultValue === "function" ? (defaultValue as () => output)() : defaultValue - - return { value: resolved } - } - - return schema["~run"](value, context) - }) -} - -/** - * Create a schema that accepts one of the given values using strict equality (`===`). - * - * @param values The allowed values - * @returns A schema that produces the union of allowed value types - */ -export function enum_( - values: values, -): Schema { - return createSchema(function validate(value, context) { - for (let allowed of values) { - if (value === allowed) { - return { value: value as values[number] } - } - } - - return fail("Expected one of: " + values.map(String).join(", "), context.path, { - code: "enum.invalid_value", - input: value, - values: { values: [...values] }, - parseOptions: context.options, - }) - }) -} - -/** - * Create a schema that validates a value is an instance of a class. - * - * @param constructor The class constructor to check against - * @returns A schema that produces the instance type - */ -export function instanceof_ any>( - constructor: constructor, -): Schema> { - return createSchema(function validate(value, context) { - if (!(value instanceof constructor)) { - return fail("Expected instance of " + constructor.name, context.path, { - code: "instanceof.invalid_type", - input: value, - values: { constructorName: constructor.name }, - parseOptions: context.options, - }) - } - - return { value: value as InstanceType } - }) -} - -/** - * Create a schema that accepts a single literal value using strict equality (`===`). - * - * @param literalValue The literal value to match - * @returns A schema that produces the literal type - */ -export function literal(literalValue: value): Schema { - return createSchema(function validate(value, context) { - if (value !== literalValue) { - return fail("Expected literal value", context.path, { - code: "literal.invalid_value", - input: value, - values: { expected: literalValue }, - parseOptions: context.options, - }) - } - - return { value: literalValue } - }) -} - -/** - * Create a schema that validates a Map with typed keys and values. - * - * @param keySchema Schema for Map keys - * @param valueSchema Schema for Map values - * @returns A schema that produces a `Map` - */ -export function map( - keySchema: Schema, - valueSchema: Schema, -): Schema> { - return createSchema(function validate(value, context) { - if (!(value instanceof Map)) { - return fail("Expected Map", context.path, { - code: "type.map", - input: value, - parseOptions: context.options, - }) - } - - let abortEarly = shouldAbortEarly(context.options) - let issues: Issue[] = [] - let outputMap = new Map() - - for (let [key, val] of value) { - let keyResult = keySchema["~run"](key, { - path: withPath(context.path, key), - options: context.options, - }) - - if (keyResult.issues) { - if (abortEarly) { - return keyResult - } - - issues.push(...keyResult.issues) - continue - } - - let valueResult = valueSchema["~run"](val, { - path: withPath(context.path, key), - options: context.options, - }) - - if (valueResult.issues) { - if (abortEarly) { - return valueResult - } - - issues.push(...valueResult.issues) - continue - } - - outputMap.set(keyResult.value, valueResult.value) - } - - if (issues.length > 0) { - return { issues } - } - - return { value: outputMap } - }) -} - -/** - * Create a schema that accepts `null`. - * - * @returns A schema that produces `null` - */ -export function null_(): Schema { - return createSchema(function validate(value, context) { - if (value !== null) { - return fail("Expected null", context.path, { - code: "type.null", - input: value, - parseOptions: context.options, - }) - } - - return { value: null } - }) -} - -/** - * Allow `null` as an input value, short-circuiting validation when `null` is provided. - * - * @param schema The wrapped schema - * @returns A schema that accepts `null` in addition to the wrapped schema - */ -export function nullable( - schema: Schema, -): Schema { - return createSchema(function validate(value, context) { - if (value === null) { - return { value: null } - } - - return schema["~run"](value, context) - }) -} - -/** - * Create a schema that accepts finite numbers (excluding `NaN` and `Infinity`). - * - * @returns A schema that produces a `number` - */ -export function number(): Schema { - return createSchema(function validate(value, context) { - if (typeof value !== "number" || !Number.isFinite(value)) { - return fail("Expected number", context.path, { - code: "type.number", - input: value, - parseOptions: context.options, - }) - } - - return { value } - }) -} - -type ObjectShape = Record> - -type ObjectOptions = { - unknownKeys?: "strip" | "passthrough" | "error" -} - -/** - * Create a schema that validates an object with a fixed shape. - * - * By default, unknown keys are stripped. You can change this via `options.unknownKeys`. - * - * @param shape A mapping of keys to schemas - * @param options Controls unknown key behavior - * @returns A schema that produces a typed object matching the shape - */ -export function object( - shape: shape, - options?: ObjectOptions, -): Schema }> { - return createSchema(function validate(value, context) { - if (typeof value !== "object" || value === null || Array.isArray(value)) { - return fail("Expected object", context.path, { - code: "type.object", - input: value, - parseOptions: context.options, - }) - } - - let abortEarly = shouldAbortEarly(context.options) - let issues: Issue[] = [] - let outputValues: Record = {} - let input = value as Record - let unknownKeys = options?.unknownKeys ?? "strip" - - for (let key of Object.keys(shape)) { - // @ts-expect-error - let result = shape[key]["~run"](input[key], { - path: withPath(context.path, key), - options: context.options, - }) - - if (result.issues) { - if (abortEarly) { - return result - } - - issues.push(...result.issues) - } else { - if (Object.prototype.hasOwnProperty.call(input, key) || result.value !== undefined) { - outputValues[key] = result.value - } - } - } - - if (unknownKeys === "passthrough" || unknownKeys === "error") { - for (let key in input) { - if (!Object.prototype.hasOwnProperty.call(input, key)) { - continue - } - - if (Object.prototype.hasOwnProperty.call(shape, key)) { - continue - } - - if (unknownKeys === "passthrough") { - outputValues[key] = input[key] - } else { - let issue = createIssueFromContext(context, { - code: "object.unknown_key", - defaultMessage: "Unknown key", - input: input[key], - path: withPath(context.path, key), - values: { key }, - }) - - if (abortEarly) { - return { issues: [issue] } - } - - issues.push(issue) - } - } - } - - if (issues.length > 0) { - return { issues } - } - - return { value: outputValues as { [key in keyof shape]: InferOutput } } - }) -} - -/** - * Allow `undefined` as an input value, short-circuiting validation when `undefined` is provided. - * - * @param schema The wrapped schema - * @returns A schema that accepts `undefined` in addition to the wrapped schema - */ -export function optional( - schema: Schema, -): Schema { - return createSchema(function validate(value, context) { - if (value === undefined) { - return { value: undefined } - } - - return schema["~run"](value, context) - }) -} - -/** - * Create a schema that validates a record (object map) by validating each key and value. - * - * @param keySchema Schema used to validate and transform each key - * @param valueSchema Schema used to validate and transform each value - * @returns A schema that produces a record of validated keys and values - */ -export function record( - keySchema: Schema, - valueSchema: Schema, -): Schema> { - return createSchema(function validate(value, context) { - if (typeof value !== "object" || value === null || Array.isArray(value)) { - return fail("Expected object", context.path, { - code: "type.object", - input: value, - parseOptions: context.options, - }) - } - - let abortEarly = shouldAbortEarly(context.options) - let issues: Issue[] = [] - let outputValues: Record = {} - let input = value as Record - - for (let key in input) { - if (!Object.prototype.hasOwnProperty.call(input, key)) { - continue - } - - let keyResult = keySchema["~run"](key, { - path: withPath(context.path, key), - options: context.options, - }) - - if (keyResult.issues) { - if (abortEarly) { - return keyResult - } - - issues.push(...keyResult.issues) - continue - } - - let valueResult = valueSchema["~run"](input[key], { - path: withPath(context.path, key), - options: context.options, - }) - - if (valueResult.issues) { - if (abortEarly) { - return valueResult - } - - issues.push(...valueResult.issues) - continue - } - - outputValues[keyResult.value] = valueResult.value - } - - if (issues.length > 0) { - return { issues } - } - - return { value: outputValues as Record } - }) -} - -/** - * Create a schema that validates a Set with typed values. - * - * @param valueSchema Schema for Set values - * @returns A schema that produces a `Set` - */ -export function set( - valueSchema: Schema, -): Schema> { - return createSchema(function validate(value, context) { - if (!(value instanceof Set)) { - return fail("Expected Set", context.path, { - code: "type.set", - input: value, - parseOptions: context.options, - }) - } - - let abortEarly = shouldAbortEarly(context.options) - let issues: Issue[] = [] - let outputSet = new Set() - let index = 0 - - for (let item of value) { - let result = valueSchema["~run"](item, { - path: withPath(context.path, index), - options: context.options, - }) - - if (result.issues) { - if (abortEarly) { - return result - } - - issues.push(...result.issues) - } else { - outputSet.add(result.value) - } - - index++ - } - - if (issues.length > 0) { - return { issues } - } - - return { value: outputSet } - }) -} - -/** - * Create a schema that accepts strings. - * - * @returns A schema that produces a `string` - */ -export function string(): Schema { - return createSchema(function validate(value, context) { - if (typeof value !== "string") { - return fail("Expected string", context.path, { - code: "type.string", - input: value, - parseOptions: context.options, - }) - } - - return { value } - }) -} - -/** - * Create a schema that accepts symbols. - * - * @returns A schema that produces a `symbol` - */ -export function symbol(): Schema { - return createSchema(function validate(value, context) { - if (typeof value !== "symbol") { - return fail("Expected symbol", context.path, { - code: "type.symbol", - input: value, - parseOptions: context.options, - }) - } - - return { value } - }) -} - -/** - * Create a schema that validates a fixed-length tuple. - * - * @param items Schemas for each tuple position - * @returns A schema that produces a typed tuple - */ -export function tuple[]>( - items: items, -): Schema }> { - return createSchema(function validate(value, context) { - if (!Array.isArray(value)) { - return fail("Expected array", context.path, { - code: "type.array", - input: value, - parseOptions: context.options, - }) - } - - let abortEarly = shouldAbortEarly(context.options) - let issues: Issue[] = [] - let outputValues: unknown[] = [] - - if (value.length !== items.length) { - let issue = createIssueFromContext(context, { - code: "tuple.length", - defaultMessage: "Expected tuple length " + String(items.length), - input: value, - values: { length: items.length }, - }) - - if (abortEarly) { - return { issues: [issue] } - } - - issues.push(issue) - } - - let index = 0 - let max = Math.min(value.length, items.length) - - while (index < max) { - // @ts-expect-error - let result = items[index]["~run"](value[index], { - path: withPath(context.path, index), - options: context.options, - }) - - if (result.issues) { - if (abortEarly) { - return result - } - - issues.push(...result.issues) - } else { - outputValues[index] = result.value - } - - index += 1 - } - - if (issues.length > 0) { - return { issues } - } - - return { value: outputValues as { [index in keyof items]: InferOutput } } - }) -} - -/** - * Create a schema that accepts `undefined`. - * - * @returns A schema that produces `undefined` - */ -export function undefined_(): Schema { - return createSchema(function validate(value, context) { - if (value !== undefined) { - return fail("Expected undefined", context.path, { - code: "type.undefined", - input: value, - parseOptions: context.options, - }) - } - - return { value: undefined } - }) -} - -/** - * Create a discriminated-union schema. - * - * The returned schema expects an object with a `discriminator` property and selects a variant schema - * based on that value. - * - * @param discriminator The property name used to select a variant - * @param variants A mapping from discriminator value to schema - * @returns A schema that produces the selected variant output type - */ -export function variant< - key extends PropertyKey, - variants extends Record>, ->(discriminator: key, variants: variants): Schema> { - return createSchema(function validate(value, context) { - if (typeof value !== "object" || value === null || Array.isArray(value)) { - return fail("Expected object", context.path, { - code: "type.object", - input: value, - parseOptions: context.options, - }) - } - - let input = value as Record - let tag = input[discriminator] - - if (tag === undefined) { - return fail("Expected discriminator", [...context.path, discriminator], { - code: "variant.missing_discriminator", - input: value, - values: { discriminator: String(discriminator) }, - parseOptions: context.options, - }) - } - - if (typeof tag !== "string" && typeof tag !== "number" && typeof tag !== "symbol") { - return fail("Unknown discriminator", [...context.path, discriminator], { - code: "variant.unknown_discriminator", - input: tag, - values: { discriminator: String(discriminator) }, - parseOptions: context.options, - }) - } - - if (!Object.prototype.hasOwnProperty.call(variants, tag)) { - return fail("Unknown discriminator", [...context.path, discriminator], { - code: "variant.unknown_discriminator", - input: tag, - values: { discriminator: String(discriminator) }, - parseOptions: context.options, - }) - } - - let schema = variants[tag as keyof variants] - // @ts-expect-error - return schema["~run"](value, context) - }) -} - -/** - * Create a schema that tries multiple schemas in order and returns the first success. - * - * When `abortEarly` is disabled (default), issues are collected from all failing variants. - * - * @param schemas Candidate schemas to try - * @returns A schema that produces the first successful variant output - */ -export function union[]>( - schemas: schemas, -): Schema> { - return createSchema(function validate(value, context) { - if (schemas.length === 0) { - return fail("No union variant matched", context.path, { - code: "union.no_variants", - input: value, - parseOptions: context.options, - }) - } - - let abortEarly = shouldAbortEarly(context.options) - let issues: Issue[] = [] - - for (let schema of schemas) { - let result = schema["~run"](value, context) - - if (result.issues) { - if (abortEarly) { - return { issues: result.issues } - } - - issues.push(...result.issues) - - continue - } - - return result - } - - return { issues } - }) -} - -/** - * Error thrown by {@link parse} when validation fails. - */ -export class ValidationError extends Error { - /** - * The validation issues produced by the schema. - */ - issues: ReadonlyArray - - /** - * @param issues The issues produced by schema validation - * @param message Optional error message (defaults to "Validation failed") - */ - constructor(issues: ReadonlyArray, message = "Validation failed") { - super(message) - this.name = "ValidationError" - this.issues = issues - } -} - -/** - * Validate a value and return the typed output or throw a {@link ValidationError}. - * - * @param schema The schema to validate against - * @param value The value to validate - * @param options Validation options - * @returns The validated output value - * @throws {ValidationError} If validation fails - */ -export function parse( - schema: StandardSchemaV1, - value: unknown, - options?: ParseOptions, -): output { - let result = schema["~standard"].validate(value, options) as ValidationResult - - if (result.issues) { - throw new ValidationError(result.issues) - } - - return result.value -} - -/** - * Validate a value without throwing. - * - * @param schema The schema to validate against - * @param value The value to validate - * @param options Validation options - * @returns A success result with the value, or a failure result with issues - */ -export function parseSafe( - schema: StandardSchemaV1, - value: unknown, - options?: ParseOptions, -): - | { success: true; value: output } - | { success: false; issues: ReadonlyArray } { - let result = schema["~standard"].validate(value, options) as ValidationResult - - if (result.issues) { - return { success: false, issues: result.issues } - } - - return { success: true, value: result.value } -} diff --git a/app/controllers/home/page.tsx b/app/controllers/home/page.tsx index ff0a579..cc46709 100644 --- a/app/controllers/home/page.tsx +++ b/app/controllers/home/page.tsx @@ -1,12 +1,13 @@ import type { Handle } from "remix/component" -import { Document } from "../../components/document" -import { GuessForm } from "../../components/form" -import { GameOverModal } from "../../components/game-over-modal" -import { Keyboard } from "../../components/keyboard" -import { LetterInput } from "../../components/letter-input" -import { LETTER_INPUTS, TOTAL_GUESSES } from "../../constants" -import type { GameBoard } from "../../models/game" +import { Document } from "#app/components/document.tsx" +import { GuessForm } from "#app/components/form.tsx" +import { GameOverModal } from "#app/components/game-over-modal.tsx" +import { Keyboard } from "#app/components/keyboard.tsx" +import { LetterInput } from "#app/components/letter-input.tsx" +import { LETTER_INPUTS, TOTAL_GUESSES } from "#app/constants.ts" +import type { GameBoard } from "#app/models/game.ts" + export function Page(_handle: Handle, { url }: { url: URL }) { return ({ showModal, diff --git a/app/middleware/auth.ts b/app/middleware/auth.ts index 6ee1adf..77dc82f 100644 --- a/app/middleware/auth.ts +++ b/app/middleware/auth.ts @@ -7,16 +7,16 @@ import { } from "remix/auth-middleware" import { redirect } from "remix/response/redirect" -import { routes } from "../routes" +import { routes } from "#app/routes.ts" import { normalizeEmail, parseAuthSession, type AuthIdentity, type AuthSession, -} from "../utils/auth-session.ts" -import { db } from "../utils/db" -import * as f from "../utils/local-form-schema.ts" -import * as s from "../utils/local-schema.ts" +} from "#app/utils/auth-session.ts" +import { db } from "#app/utils/db.ts" +import * as f from "#app/utils/local-form-schema.ts" +import * as s from "#app/utils/local-schema.ts" const loginSchema = f.object({ email: f.field(s.defaulted(s.string(), "")), diff --git a/app/models/game.ts b/app/models/game.ts index 6dac0fa..4def916 100644 --- a/app/models/game.ts +++ b/app/models/game.ts @@ -1,10 +1,10 @@ import { endOfDay, startOfDay } from "date-fns" -import { WORD_LENGTH } from "../constants" -import type { Game, User } from "../generated/prisma/client" -import { GameStatus, Prisma } from "../generated/prisma/client" -import { db } from "../utils/db" -import type { ComputedGuess } from "../utils/game" +import { WORD_LENGTH } from "#app/constants.ts" +import type { Game, User } from "#app/generated/prisma/client.ts" +import { GameStatus, Prisma } from "#app/generated/prisma/client.ts" +import { db } from "#app/utils/db.ts" +import type { ComputedGuess } from "#app/utils/game.ts" import { computeGuess, createEmptyLetter, @@ -12,7 +12,7 @@ import { isValidWord, keyboardWithStatus, LetterState, -} from "../utils/game" +} from "#app/utils/game.ts" const TOTAL_GUESSES = 6 diff --git a/app/models/user.ts b/app/models/user.ts index 7f2c5d8..529f068 100644 --- a/app/models/user.ts +++ b/app/models/user.ts @@ -2,9 +2,9 @@ import bcrypt from "bcryptjs" import { email, minLength } from "remix/data-schema/checks" import * as f from "remix/data-schema/form-data" -import type { User } from "../generated/prisma/client" -import { db } from "../utils/db" -import * as s from "../utils/local-schema" +import type { User } from "#app/generated/prisma/client.ts" +import { db } from "#app/utils/db.ts" +import * as s from "#app/utils/local-schema.ts" export const joinSchema = f.object({ email: f.field(s.string().pipe(email())), diff --git a/app/utils/auth-session.ts b/app/utils/auth-session.ts index 5310008..f9a42d4 100644 --- a/app/utils/auth-session.ts +++ b/app/utils/auth-session.ts @@ -1,5 +1,5 @@ -import type { User } from "../generated/prisma/client.ts" -import * as s from "../utils/local-schema.ts" +import type { User } from "#app/generated/prisma/client.ts" +import * as s from "#app/utils/local-schema.ts" export function normalizeEmail(email: string): string { return email.trim().toLowerCase() diff --git a/app/utils/db.ts b/app/utils/db.ts index f9f56fa..050d5f6 100644 --- a/app/utils/db.ts +++ b/app/utils/db.ts @@ -1,8 +1,8 @@ import { PrismaPg } from "@prisma/adapter-pg" import Redis from "ioredis" -import { env } from "../constants" -import { PrismaClient } from "../generated/prisma/client" +import { env } from "#app/constants.ts" +import { PrismaClient } from "#app/generated/prisma/client.ts" const adapter = new PrismaPg({ connectionString: process.env.DATABASE_URL }) export const db = new PrismaClient({ adapter }) diff --git a/app/utils/queue.ts b/app/utils/queue.ts index f2a7b17..fa535cb 100644 --- a/app/utils/queue.ts +++ b/app/utils/queue.ts @@ -2,8 +2,9 @@ import type { ConnectionOptions } from "bullmq" import { Queue, Worker } from "bullmq" import { startOfDay } from "date-fns" -import { env } from "../constants" -import { db } from "./db" +import { env } from "#app/constants.ts" + +import { db } from "./db.ts" const connection = { url: env.REDIS_URL } satisfies ConnectionOptions diff --git a/app/utils/render.ts b/app/utils/render.ts index 2a892d2..b3f9c99 100644 --- a/app/utils/render.ts +++ b/app/utils/render.ts @@ -3,7 +3,7 @@ import type * as Remix from "remix/component" import { renderToStream } from "remix/component/server" import { createHtmlResponse } from "remix/response/html" -import { router } from "../router.ts" +import { router } from "#app/router.ts" export function render(node: Remix.RemixNode, init?: ResponseInit) { let context = getContext() diff --git a/app/utils/session.ts b/app/utils/session.ts index 8dffc71..029b1ec 100644 --- a/app/utils/session.ts +++ b/app/utils/session.ts @@ -1,7 +1,7 @@ import { createCookie } from "remix/cookie" import { createCookieSessionStorage } from "remix/session/cookie-storage" -import { env } from "../constants" +import { env } from "#app/constants.ts" export const sessionCookie = createCookie("session", { secrets: [env.SESSION_SECRET], diff --git a/oxlint-plugins/prefer-import-alias-plugin.ts b/oxlint-plugins/prefer-import-alias-plugin.ts new file mode 100644 index 0000000..23eb1d7 --- /dev/null +++ b/oxlint-plugins/prefer-import-alias-plugin.ts @@ -0,0 +1,60 @@ +import type { Context, ESTree, Fixer } from "@oxlint/plugins" +import { definePlugin, defineRule } from "@oxlint/plugins" + +function replacer(fixer: Fixer, node: ESTree.ImportDeclaration) { + let source = node.source.value + if (typeof source !== "string") { + return null + } + + let newSource = source.replace(/^\.\/(.*)/, "#$1").replace(/^\.\.\/(.*)/, "#$1") + + return fixer.replaceText(node.source, `"${newSource}"`) +} + +const preferImportAliasRule = defineRule({ + meta: { + type: "suggestion", + fixable: "code", + hasSuggestions: true, + }, + create(context: Context) { + return { + ImportDeclaration(node: ESTree.ImportDeclaration) { + if (node.specifiers.length === 0) { + return + } + + if (!node.source.value.startsWith("../")) { + return + } + + context.report({ + node, + message: "Use import aliases for deep imports", + data: {}, + suggest: [ + { + fix(fixer: Fixer) { + return replacer(fixer, node) + }, + desc: "Replace with import alias", + }, + ], + fix(fixer: Fixer) { + return replacer(fixer, node) + }, + }) + }, + } + }, +}) + +export default definePlugin({ + meta: { + name: "prefer-import-alias", + }, + rules: { + "prefer-import-alias": preferImportAliasRule, + }, +}) diff --git a/package.json b/package.json index 2a7a122..a54bbaa 100644 --- a/package.json +++ b/package.json @@ -5,6 +5,9 @@ "license": "MIT", "type": "module", "sideEffects": false, + "imports": { + "#*": "./*" + }, "scripts": { "build": "vp build", "dev": "vp dev", diff --git a/vite.config.ts b/vite.config.ts index 1f8abec..6663fe0 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -37,11 +37,12 @@ export default defineConfig({ }, lint: { plugins: ["unicorn", "typescript", "oxc"], - jsPlugins: ["./oxlint-plugins/prefer-let-locals-plugin.ts"], + jsPlugins: [ + "./oxlint-plugins/prefer-let-locals-plugin.ts", + "./oxlint-plugins/prefer-import-alias-plugin.ts", + ], categories: {}, rules: { - "remix-style/prefer-const-module-scope": "error", - "remix-style/prefer-let-locals": "error", "constructor-super": "warn", "for-direction": "warn", "no-async-promise-executor": "warn", @@ -146,6 +147,10 @@ export default defineConfig({ "unicorn/no-useless-spread": "warn", "unicorn/prefer-set-size": "warn", "unicorn/prefer-string-starts-ends-with": "warn", + "prefer-import-alias/prefer-import-alias": "error", + "remix-style/prefer-const-module-scope": "error", + "remix-style/prefer-let-locals": "error", + "import/extensions": ["error", "ignorePackages"], }, settings: { "jsx-a11y": { From 5142e8befe8b01338223e6bdb910248795c231b6 Mon Sep 17 00:00:00 2001 From: Logan McAnsh Date: Sat, 4 Apr 2026 02:34:40 -0400 Subject: [PATCH 08/10] fix: update document titles for register and reset password actions Signed-off-by: Logan McAnsh --- app/controllers/auth/register/controller.tsx | 4 ++-- app/controllers/auth/reset-password/controller.tsx | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/app/controllers/auth/register/controller.tsx b/app/controllers/auth/register/controller.tsx index 7a6e967..cc4ee42 100644 --- a/app/controllers/auth/register/controller.tsx +++ b/app/controllers/auth/register/controller.tsx @@ -12,7 +12,7 @@ export const registerController = { actions: { index({ url }) { return render( - Login - Remix Wordle}> + Register - Remix Wordle}>
    Login - Remix Wordle}> + Register - Remix Wordle}>
    An account with this email already exists.

    diff --git a/app/controllers/auth/reset-password/controller.tsx b/app/controllers/auth/reset-password/controller.tsx index 5c209fe..4ad87bb 100644 --- a/app/controllers/auth/reset-password/controller.tsx +++ b/app/controllers/auth/reset-password/controller.tsx @@ -17,7 +17,7 @@ export const resetPasswordController = { let error = session.get("error") return render( - Login - Remix Wordle}> + Reset Password - Remix Wordle}>

    Reset Password

    Enter your new password below.

    @@ -84,7 +84,7 @@ export const resetPasswordController = { } return render( - Login - Remix Wordle}> + Reset Password - Remix Wordle}>
    Password reset successfully! You can now login with your new password. From c46681d2e3e2e522d854264bd301572b41c8ada7 Mon Sep 17 00:00:00 2001 From: Logan McAnsh Date: Sat, 4 Apr 2026 02:35:58 -0400 Subject: [PATCH 09/10] fix: update model import paths in auth and home controller tests Signed-off-by: Logan McAnsh --- app/controllers/auth/controller.test.ts | 2 +- app/controllers/home/controller.test.ts | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/app/controllers/auth/controller.test.ts b/app/controllers/auth/controller.test.ts index b23cd4a..a72748e 100644 --- a/app/controllers/auth/controller.test.ts +++ b/app/controllers/auth/controller.test.ts @@ -3,7 +3,7 @@ import { describe, expect, it, vi } from "vite-plus/test" import { router } from "#app/router.ts" import { assertContains, getSessionCookie } from "#test/helpers.ts" -vi.mock("./models/user.ts", async (importActual) => { +vi.mock("#app/models/user.ts", async (importActual) => { let actual = await importActual() return { ...actual, diff --git a/app/controllers/home/controller.test.ts b/app/controllers/home/controller.test.ts index 8d706ad..42637dd 100644 --- a/app/controllers/home/controller.test.ts +++ b/app/controllers/home/controller.test.ts @@ -7,8 +7,8 @@ import { assertContains, loginAsCustomer, requestWithSession } from "#test/helpe import { guessWordSchema } from "./controller.tsx" -vi.mock("./models/game.ts", async (importActual) => { - let actual = await importActual() +vi.mock("#app/models/game.ts", async (importActual) => { + let actual = await importActual() return { ...actual, @@ -35,8 +35,8 @@ vi.mock("./models/game.ts", async (importActual) => { } }) -vi.mock("./models/user.ts", async (importActual) => { - let actual = await importActual() +vi.mock("#app/models/user.ts", async (importActual) => { + let actual = await importActual() return { ...actual, From c0cd4873b65c5358d6110922a6e8009a50f1ea6a Mon Sep 17 00:00:00 2001 From: Logan McAnsh Date: Sat, 4 Apr 2026 02:39:21 -0400 Subject: [PATCH 10/10] feat: streamline login authentication process and remove unused user model functions Signed-off-by: Logan McAnsh --- app/controllers/auth/login/controller.tsx | 40 ++++++++++++----------- app/models/user.ts | 20 ------------ 2 files changed, 21 insertions(+), 39 deletions(-) diff --git a/app/controllers/auth/login/controller.tsx b/app/controllers/auth/login/controller.tsx index 93a8d32..4307411 100644 --- a/app/controllers/auth/login/controller.tsx +++ b/app/controllers/auth/login/controller.tsx @@ -1,35 +1,37 @@ +import { completeAuth, verifyCredentials } from "remix/auth" import type { Controller } from "remix/fetch-router" import { redirect } from "remix/response/redirect" import { Session } from "remix/session" import { Document } from "#app/components/document.tsx" -import { authenticateUser } from "#app/models/user.ts" +import { getPostAuthRedirect, getReturnToQuery, passwordProvider } from "#app/middleware/auth.ts" import { routes } from "#app/routes.ts" -import * as s from "#app/utils/local-schema.ts" import { render } from "#app/utils/render.ts" -const loginSchema = s.object({ - email: s.string(), - password: s.string(), -}) - export const loginController = { actions: { - async action({ get, url }) { - let session = get(Session) - let formData = get(FormData) - let result = s.parse(loginSchema, formData) - let returnTo = url.searchParams.get("returnTo") + async action(context) { + try { + let user = await verifyCredentials(passwordProvider, context) - let user = await authenticateUser(result.email, result.password) - if (!user) { - session.flash("error", "Invalid email or password. Please try again.") - return redirect(routes.auth.login.index.href(undefined, { returnTo })) - } + if (user == null) { + let session = context.get(Session) + session.flash("error", "Invalid email or password. Please try again.") + return redirect(routes.auth.login.index.href(undefined, getReturnToQuery(context.url))) + } - session.set("auth", { userId: user.id }) + let session = completeAuth(context) + session.set("auth", { + userId: user.id, + loginMethod: "credentials", + }) - return redirect(returnTo ?? routes.home.index.href()) + return redirect(getPostAuthRedirect(context.url)) + } catch { + let session = context.get(Session) + session.flash("error", "We could not complete that sign-in request.") + return redirect(routes.auth.login.index.href(undefined, getReturnToQuery(context.url))) + } }, index({ get, url }) { let session = get(Session) diff --git a/app/models/user.ts b/app/models/user.ts index 529f068..27a8447 100644 --- a/app/models/user.ts +++ b/app/models/user.ts @@ -32,26 +32,6 @@ export async function createUser(user: { }) } -export async function deleteUserByEmail(email: User["email"]) { - return db.user.delete({ where: { email } }) -} - -export async function authenticateUser(email: User["email"], password: User["password"]) { - let user = await db.user.findUnique({ - where: { email }, - }) - - if (!user || !user.password) return null - - let isValid = await bcrypt.compare(password, user.password) - - if (!isValid) return null - - let { password: _password, ...userWithoutPassword } = user - - return userWithoutPassword -} - export function createPasswordResetToken(email: string): string | undefined { let user = getUserByEmail(email) if (!user) return undefined