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..4550b86 --- /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..56d72ee --- /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..936821b --- /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..558bf2c --- /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..cd19530 --- /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..b9b97a2 --- /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..9305f8d --- /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 + -
    - 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/components/document.tsx b/app/components/document.tsx index 658adea..5c3b071 100644 --- a/app/components/document.tsx +++ b/app/components/document.tsx @@ -1,12 +1,12 @@ 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" -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/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/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/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..a72748e 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 { router } from "#app/router.ts" +import { assertContains, getSessionCookie } from "#test/helpers.ts" -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, 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..c8f6314 --- /dev/null +++ b/app/controllers/auth/controller.tsx @@ -0,0 +1,30 @@ +import type { Controller } from "remix/fetch-router" +import { createRedirectResponse as redirect } from "remix/response/redirect" +import { Session } from "remix/session" + +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" +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..ee7db79 --- /dev/null +++ b/app/controllers/auth/forgot-password/controller.tsx @@ -0,0 +1,83 @@ +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 "#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: { + 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 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! If we have found a user with that email, you will receive a + link to reset your password. +
    + + {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..4307411 --- /dev/null +++ b/app/controllers/auth/login/controller.tsx @@ -0,0 +1,102 @@ +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 { getPostAuthRedirect, getReturnToQuery, passwordProvider } from "#app/middleware/auth.ts" +import { routes } from "#app/routes.ts" +import { render } from "#app/utils/render.ts" + +export const loginController = { + actions: { + 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, + loginMethod: "credentials", + }) + + 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) + 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..cc4ee42 --- /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 "#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: { + index({ url }) { + return render( + Register - 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( + Register - 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..4ad87bb --- /dev/null +++ b/app/controllers/auth/reset-password/controller.tsx @@ -0,0 +1,102 @@ +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 "#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: { + index({ get, params, url }) { + let session = get(Session) + let token = params.token + let error = session.get("error") + + return render( + Reset Password - 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 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, parsed.password) + + if (!success) { + session.flash("error", "Invalid or expired reset token.") + return redirect(routes.auth.resetPassword.index.href({ token: params.token })) + } + + return render( + Reset Password - 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 90% rename from app/health/controller.tsx rename to app/controllers/health.tsx index c6deaf6..8a7bda1 100644 --- a/app/health/controller.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/history/controller.tsx b/app/controllers/history/controller.tsx similarity index 79% rename from app/history/controller.tsx rename to app/controllers/history/controller.tsx index 453a9ec..b68db88 100644 --- a/app/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, @@ -16,7 +17,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/history/game.tsx b/app/controllers/history/game.tsx similarity index 85% rename from app/history/game.tsx rename to app/controllers/history/game.tsx index 62a0bab..18f6f9b 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 "#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/history/history-page.tsx b/app/controllers/history/history-page.tsx similarity index 94% rename from app/history/history-page.tsx rename to app/controllers/history/history-page.tsx index 4fe275b..ce38ce4 100644 --- a/app/history/history-page.tsx +++ b/app/controllers/history/history-page.tsx @@ -1,15 +1,15 @@ 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" -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/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..368d588 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 "#app/components/document.tsx" 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 91% rename from app/home/controller.test.ts rename to app/controllers/home/controller.test.ts index a44374f..42637dd 100644 --- a/app/home/controller.test.ts +++ b/app/controllers/home/controller.test.ts @@ -1,13 +1,14 @@ 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) => { - let actual = await importActual() +vi.mock("#app/models/game.ts", async (importActual) => { + let actual = await importActual() return { ...actual, @@ -34,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, 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..01c1372 100644 --- a/app/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/home/page.tsx b/app/controllers/home/page.tsx similarity index 87% rename from app/home/page.tsx rename to app/controllers/home/page.tsx index 2b9af1a..cc46709 100644 --- a/app/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/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/middleware/auth.ts b/app/middleware/auth.ts index 38fb427..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(), "")), @@ -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/models/game.ts b/app/models/game.ts index 65e7191..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,11 +12,11 @@ import { isValidWord, keyboardWithStatus, LetterState, -} from "../utils/game" +} from "#app/utils/game.ts" -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/models/user.ts b/app/models/user.ts index 25e24b3..27a8447 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 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())), @@ -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 diff --git a/app/router.ts b/app/router.ts index 5f153a1..f15fd1b 100644 --- a/app/router.ts +++ b/app/router.ts @@ -8,16 +8,16 @@ 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" 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/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/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..050d5f6 100644 --- a/app/utils/db.ts +++ b/app/utils/db.ts @@ -1,10 +1,10 @@ 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" -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-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/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 7f803b1..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", @@ -31,6 +34,8 @@ "@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:", "@types/node": "catalog:", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f097c7a..0009b6c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -21,12 +21,18 @@ 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 '@prisma/client': specifier: ^7.6.0 version: 7.6.0 + '@remix-run/cli': + specifier: github:remix-run/remix#preview/pr-11204&path:packages/cli + version: 0.0.0 '@standard-schema/spec': specifier: ^1.1.0 version: 1.1.0 @@ -133,6 +139,12 @@ 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 '@tailwindcss/vite': specifier: 'catalog:' version: 4.2.2(@voidzero-dev/vite-plus-core@0.1.15(@types/node@25.5.2)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.2)) @@ -808,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==} @@ -964,6 +980,11 @@ packages: resolution: {path: packages/auth, tarball: https://codeload.github.com/remix-run/remix/tar.gz/edcf0c00fc38c2767a954bdfdff6248943279211} version: 0.1.0 + '@remix-run/cli@https://codeload.github.com/remix-run/remix/tar.gz/5c20da47a677c3c3b1ccf4bce7b942bd8dc5ad14#path:packages/cli': + resolution: {path: packages/cli, tarball: https://codeload.github.com/remix-run/remix/tar.gz/5c20da47a677c3c3b1ccf4bce7b942bd8dc5ad14} + version: 0.0.0 + hasBin: true + '@remix-run/component@0.6.0': resolution: {integrity: sha512-hmIUdPnBtONeOH8bbLuOVGSgFD/MAUNvpH0Xwgo27GByBIjQhOm25Tt9R04HQslDhHAzwNVnWeQkr3j9eyXYhQ==} @@ -1119,6 +1140,10 @@ packages: resolution: {path: packages/static-middleware, tarball: https://codeload.github.com/remix-run/remix/tar.gz/edcf0c00fc38c2767a954bdfdff6248943279211} version: 0.4.5 + '@remix-run/tar-parser@https://codeload.github.com/remix-run/remix/tar.gz/5c20da47a677c3c3b1ccf4bce7b942bd8dc5ad14#path:packages/tar-parser': + resolution: {path: packages/tar-parser, tarball: https://codeload.github.com/remix-run/remix/tar.gz/5c20da47a677c3c3b1ccf4bce7b942bd8dc5ad14} + version: 0.7.0 + '@remix-run/tar-parser@https://codeload.github.com/remix-run/remix/tar.gz/edcf0c00fc38c2767a954bdfdff6248943279211#path:packages/tar-parser': resolution: {path: packages/tar-parser, tarball: https://codeload.github.com/remix-run/remix/tar.gz/edcf0c00fc38c2767a954bdfdff6248943279211} version: 0.7.0 @@ -3229,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': @@ -3400,6 +3427,11 @@ snapshots: '@remix-run/fetch-router': https://codeload.github.com/remix-run/remix/tar.gz/edcf0c00fc38c2767a954bdfdff6248943279211#path:packages/fetch-router '@remix-run/session': https://codeload.github.com/remix-run/remix/tar.gz/edcf0c00fc38c2767a954bdfdff6248943279211#path:packages/session + '@remix-run/cli@https://codeload.github.com/remix-run/remix/tar.gz/5c20da47a677c3c3b1ccf4bce7b942bd8dc5ad14#path:packages/cli': + dependencies: + '@remix-run/tar-parser': https://codeload.github.com/remix-run/remix/tar.gz/5c20da47a677c3c3b1ccf4bce7b942bd8dc5ad14#path:packages/tar-parser + semver: 7.7.4 + '@remix-run/component@0.6.0': dependencies: '@types/dom-navigation': 1.0.7 @@ -3542,6 +3574,8 @@ snapshots: '@remix-run/mime': https://codeload.github.com/remix-run/remix/tar.gz/edcf0c00fc38c2767a954bdfdff6248943279211#path:packages/mime '@remix-run/response': https://codeload.github.com/remix-run/remix/tar.gz/edcf0c00fc38c2767a954bdfdff6248943279211#path:packages/response + '@remix-run/tar-parser@https://codeload.github.com/remix-run/remix/tar.gz/5c20da47a677c3c3b1ccf4bce7b942bd8dc5ad14#path:packages/tar-parser': {} + '@remix-run/tar-parser@https://codeload.github.com/remix-run/remix/tar.gz/edcf0c00fc38c2767a954bdfdff6248943279211#path:packages/tar-parser': {} '@remix-run/test@https://codeload.github.com/remix-run/remix/tar.gz/edcf0c00fc38c2767a954bdfdff6248943279211#path:packages/test': diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index ebce76d..c73c4e7 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -4,8 +4,10 @@ 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 "@standard-schema/spec": ^1.1.0 "@tailwindcss/vite": ^4.2.2 "@total-typescript/tsconfig": ^1.0.4 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/tsconfig.json b/tsconfig.json index 22047bf..5ef6a13 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -4,6 +4,7 @@ "jsx": "react-jsx", "jsxImportSource": "remix/component", "strict": true, + "types": ["node", "vite/client"], "allowImportingTsExtensions": true } } diff --git a/vite.config.ts b/vite.config.ts index 49f48b0..6663fe0 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: {}, @@ -25,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, }, @@ -34,6 +37,10 @@ export default defineConfig({ }, lint: { plugins: ["unicorn", "typescript", "oxc"], + jsPlugins: [ + "./oxlint-plugins/prefer-let-locals-plugin.ts", + "./oxlint-plugins/prefer-import-alias-plugin.ts", + ], categories: {}, rules: { "constructor-super": "warn", @@ -140,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": {