Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
node_modules

.react-router/
/.cache
/build
/public/build
Expand Down
18 changes: 4 additions & 14 deletions src/root.tsx → app/root.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,6 @@
import { cssBundleHref } from '@remix-run/css-bundle'
import type { LinksFunction, MetaFunction } from '@remix-run/node'
import {
Links,
LiveReload,
Meta,
Outlet,
Scripts,
ScrollRestoration,
} from '@remix-run/react'
import styles from './styles/index.css'
import type { LinksFunction, MetaFunction } from 'react-router'
import { Links, Meta, Outlet, Scripts, ScrollRestoration } from 'react-router'
import stylesUrl from './styles/tailwind.css?url'
import { getSiteUrl } from './services/url.ts'
import logoBlackIcoPath from '~/private/images/logo-black.ico'
import logoWhiteIcoPath from '~/private/images/logo-white.ico'
Expand All @@ -30,7 +22,6 @@ const App = () => (
<Outlet />
<ScrollRestoration />
<Scripts />
<LiveReload />
</body>
</html>
)
Expand Down Expand Up @@ -72,8 +63,7 @@ export const meta: MetaFunction = ({ location }) => {
}

export const links: LinksFunction = () => [
{ rel: `stylesheet`, href: styles },
...(cssBundleHref ? [{ rel: `stylesheet`, href: cssBundleHref }] : []),
{ rel: `stylesheet`, href: stylesUrl },
{ rel: `preconnect`, href: `https://fonts.googleapis.com` },
{
rel: `preconnect`,
Expand Down
4 changes: 4 additions & 0 deletions app/routes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
import type { RouteConfig } from '@react-router/dev/routes'
import { flatRoutes } from '@react-router/fs-routes'

export default flatRoutes() satisfies RouteConfig
File renamed without changes
48 changes: 27 additions & 21 deletions src/routes/_index/route.tsx → app/routes/_index/route.tsx
Original file line number Diff line number Diff line change
@@ -1,15 +1,14 @@
import { json } from '@remix-run/node'
import type { ActionFunctionArgs, LinksFunction } from '@remix-run/node'
import { z } from 'zod'
import { getFormProps, getTextareaProps, useForm } from '@conform-to/react'
import { getZodConstraint, parseWithZod } from '@conform-to/zod'
import { map, pipe, reduce, toArray } from 'lfi'
import {
Form,
useActionData,
useNavigation,
useRevalidator,
} from '@remix-run/react'
} from 'react-router'
import type { ActionFunctionArgs, LinksFunction } from 'react-router'
import { z } from 'zod'
import { getFormProps, getTextareaProps, useForm } from '@conform-to/react'
import { getZodConstraint, parseWithZod } from '@conform-to/zod/v4'
import { map, pipe, reduce, toArray } from 'lfi'
import { useCallback, useEffect, useId, useRef, useState } from 'react'
import type { ReactNode } from 'react'
import { useSpinDelay } from 'spin-delay'
Expand Down Expand Up @@ -91,7 +90,7 @@ const Solver = () => {
type='submit'
onClick={preventSolvingIfSubmitting}
aria-disabled={isSubmitting}
className='rounded-md bg-gradient-to-br from-neutral-600 to-neutral-700 px-5 py-1.5 font-medium focus:outline-none focus-visible:ring-2 focus-visible:ring-neutral-500 active:from-neutral-700 active:to-neutral-800'
className='cursor-pointer rounded-md bg-linear-to-br from-neutral-600 to-neutral-700 px-5 py-1.5 font-medium focus:outline-hidden focus-visible:ring-2 focus-visible:ring-neutral-500 active:from-neutral-700 active:to-neutral-800'
>
Solve
</button>
Expand All @@ -113,10 +112,10 @@ const Loading = () => {
<span role='alert' className='text-xl font-medium text-neutral-900'>
Solving...
</span>
<div className='absolute left-1/2 top-1/2 -translate-y-6'>
<div className='relative inline-block animate-back-and-forth'>
<div className='absolute top-1/2 left-1/2 -translate-y-6'>
<div className='animate-back-and-forth relative inline-block'>
<div
className='absolute right-0 top-0 -z-10 h-[52px] w-[52px] rounded-full'
className='absolute top-0 right-0 -z-10 h-[52px] w-[52px] rounded-full'
style={{ backdropFilter: `url(#${magnifyingFilterId})` }}
/>
<img src={loadingSvgPath} alt='' />
Expand Down Expand Up @@ -180,7 +179,8 @@ const Solutions = ({
return createPortal(
<dialog
ref={dialogRef}
className='flex flex-col space-y-5 rounded-md border-neutral-900 bg-neutral-900 p-8 text-neutral-50 backdrop:bg-neutral-950 backdrop:bg-opacity-35'
className='m-auto flex flex-col space-y-5 rounded-md border-neutral-900 bg-neutral-900 p-8 text-neutral-50 backdrop:bg-neutral-950/35'
// eslint-disable-next-line typescript/no-misused-promises
onClose={revalidate}
>
<div className='flex items-center gap-5'>
Expand All @@ -195,7 +195,7 @@ const Solutions = ({
>
<button
type='submit'
className='ring-neutral-500 focus:outline-none focus-visible:ring-2'
className='cursor-pointer ring-neutral-500 focus:outline-hidden focus-visible:ring-2'
>
<CloseIcon />
</button>
Expand All @@ -215,18 +215,18 @@ const Solutions = ({
type='button'
onClick={decrementSolutionIndex}
aria-disabled={solutionIndex === 0}
className='h-6 w-6 ring-neutral-500 focus:outline-none focus-visible:ring-2 aria-disabled:opacity-40'
className='h-6 w-6 cursor-pointer ring-neutral-500 focus:outline-hidden focus-visible:ring-2 aria-disabled:opacity-40'
>
<ChevronLeftIcon />
</button>
<div className='whitespace-nowrap text-center'>
<div className='text-center whitespace-nowrap'>
{solutionIndex + 1} / {solutions.length}
</div>
<button
type='button'
onClick={incrementSolutionIndex}
aria-disabled={solutionIndex === solutions.length - 1}
className='h-6 w-6 ring-neutral-500 focus:outline-none focus-visible:ring-2 aria-disabled:opacity-40'
className='h-6 w-6 cursor-pointer ring-neutral-500 focus:outline-hidden focus-visible:ring-2 aria-disabled:opacity-40'
>
<ChevronRightIcon />
</button>
Expand Down Expand Up @@ -352,7 +352,7 @@ const BackgroundLogo = () => (
<img
src={logoSvgPath}
alt=''
className='absolute bottom-0 left-[calc(100%+5px-20vw)] w-1/5 min-w-48 max-w-72'
className='absolute bottom-0 left-[calc(100%+5px-20vw)] w-1/5 max-w-72 min-w-48'
/>
)

Expand All @@ -365,13 +365,13 @@ export const action = async ({ request }: ActionFunctionArgs) => {
const formData = await request.formData()
const submission = parseWithZod(formData, { schema: formSchema })
if (submission.status !== `success`) {
return json({ submission: submission.reply(), solutions: null })
return { submission: submission.reply(), solutions: null }
}

return json({
return {
submission: submission.reply(),
solutions: await trySolveCryptogram(submission.value.ciphertext),
})
}
}

const trySolveCryptogram = async (ciphertext: string): Promise<Solution[]> => {
Expand All @@ -396,7 +396,13 @@ type Solution = { plaintext: string; cipher: Record<string, string> }

const formSchema = z.object({
ciphertext: z.string({
required_error: `Missing a ciphertext!`,
error: issue => {
if (issue.input === undefined) {
return `Missing a ciphertext!`
}

return undefined
},
}),
})

Expand Down
File renamed without changes.
33 changes: 33 additions & 0 deletions app/services/cryptogram.server.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { fc, test } from '@fast-check/vitest'
import { expect } from 'vitest'
import solveCryptogram from './cryptogram.server.ts'
import { readDictionary } from './dictionary.server.ts'
import { parseWords } from './words.server.ts'

const dictionary = await readDictionary()

test.prop([fc.string({ unit: fc.constantFrom(...dictionary.alphabet, ` `) })])(
`solveCryptogram works`,
ciphertext => {
const solutions = solveCryptogram({
ciphertext,
dictionary,
maxSolutionCount: 5,
})

expect(solutions.size).toBeGreaterThanOrEqual(0)
expect(solutions.size).toBeLessThanOrEqual(5)
for (const [plaintext, cipher] of solutions) {
for (const word of parseWords(plaintext, dictionary.alphabet)) {
expect(dictionary.wordFrequencies.has(word)).toBe(true)
}

expect(plaintext).toHaveLength(ciphertext.length)
for (const [key, value] of cipher) {
expect(dictionary.alphabet).toContain(key)
expect(dictionary.alphabet).toContain(value)
}
expect([...new Set(cipher.values())]).toStrictEqual([...cipher.values()])
}
},
)
Original file line number Diff line number Diff line change
Expand Up @@ -120,13 +120,10 @@ const findWordCandidates = (
): Map<string, ReadonlySet<string>> =>
pipe(
words,
map(
word =>
[
word,
dictionary.patternWords.get(computePattern(word)) ?? new Set(),
] as const,
),
map(word => [
word,
dictionary.patternWords.get(computePattern(word)) ?? new Set<string>(),
]),
reduce(toMap()),
)

Expand Down
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
45 changes: 45 additions & 0 deletions app/styles/tailwind.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
@import 'tailwindcss';
@plugin "@tailwindcss/typography";
@plugin "@tailwindcss/forms";

@theme {
--font-*: initial;
--font-sans:
Saira, ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji',
'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji';

--animate-back-and-forth: back-and-forth 2s ease-in-out infinite;

@keyframes back-and-forth {
0%,
100% {
transform: translateX(-150%);
}
50% {
transform: translateX(50%);
}
}
}

@layer base {
html {
@apply font-sans text-base sm:text-lg;
}

html,
body {
@apply flex min-h-full flex-col bg-linear-to-br from-neutral-800 to-neutral-900 to-40% text-neutral-50;
}

body {
@apply flex-1;
}

* {
@apply uppercase!;
}

::-webkit-scrollbar {
-webkit-appearance: none;
}
}
68 changes: 34 additions & 34 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,52 +14,52 @@
"scripts": {
"format": "prettier --cache --write .",
"lint": "eslint --cache --cache-location node_modules/.cache/eslint/ --fix .",
"typecheck": "tsc --noEmit",
"typecheck": "react-router typegen && tsc --noEmit",
"test": "vitest",
"build": "remix build",
"dev": "remix dev --manual",
"start": "remix-serve ./build/index.js"
"build": "react-router build",
"dev": "react-router dev",
"start": "react-router-serve build/server/index.js"
},
"prettier": "@tomer/prettier-config",
"dependencies": {
"@conform-to/react": "^1.1.5",
"@conform-to/zod": "^1.1.5",
"@conform-to/react": "^1.8.2",
"@conform-to/zod": "^1.8.2",
"@epic-web/invariant": "^1.0.0",
"@remix-run/css-bundle": "^2.10.3",
"@remix-run/node": "^2.10.3",
"@remix-run/react": "^2.10.3",
"@remix-run/serve": "^2.10.3",
"isbot": "^5.1.13",
"lfi": "^3.2.0",
"memoize": "^10.0.0",
"@react-router/fs-routes": "^7.8.2",
"@react-router/node": "^7.8.2",
"@react-router/serve": "^7.8.2",
"isbot": "^5.1.30",
"lfi": "^4.1.1",
"memoize": "^10.1.0",
"parse-english": "^7.0.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"spin-delay": "^2.0.0",
"react": "^19.1.1",
"react-dom": "^19.1.1",
"react-router": "^7.8.2",
"spin-delay": "^2.0.1",
"unist-util-visit": "^5.0.0",
"zod": "^3.23.8"
"zod": "^4.1.5"
},
"devDependencies": {
"@fast-check/vitest": "^0.1.2",
"@flydotio/dockerfile": "^0.5.8",
"@remix-run/dev": "^2.10.3",
"@tailwindcss/forms": "^0.5.7",
"@tailwindcss/typography": "^0.5.13",
"@fast-check/vitest": "^0.2.2",
"@flydotio/dockerfile": "^0.7.10",
"@react-router/dev": "^7.8.2",
"@tailwindcss/forms": "^0.5.10",
"@tailwindcss/postcss": "^4.1.12",
"@tailwindcss/typography": "^0.5.16",
"@tailwindcss/vite": "^4.1.12",
"@tomer/eslint-config": "^4.1.1",
"@tomer/prettier-config": "^4.0.0",
"@types/node": "^22.0.0",
"@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0",
"autoprefixer": "^10.4.19",
"cssnano": "^7.0.4",
"@types/node": "^24.3.0",
"@types/react": "^19.1.12",
"@types/react-dom": "^19.1.9",
"eslint": "^9.34.0",
"got": "^14.4.2",
"postcss-import": "^16.1.0",
"got": "^14.4.8",
"prettier": "^3.6.2",
"prettier-plugin-tailwindcss": "^0.6.5",
"tailwindcss": "^3.4.7",
"ts-node": "^10.9.2",
"typescript": "^5.5.4",
"vitest": "^2.0.5"
"rollup-plugin-visualizer": "^6.0.3",
"tailwindcss": "^4.1.12",
"typescript": "^5.9.2",
"vite": "^7.1.3",
"vite-tsconfig-paths": "^5.1.4",
"vitest": "^3.2.4"
}
}
Loading
Loading