Compare commits

..

No commits in common. "7ebe53f0a46fa39410799f023b72b08cc7315ec1" and "93ddcc31f9ecdbd4354594146acb24e9fed19f17" have entirely different histories.

30 changed files with 15321 additions and 13163 deletions

1
.babelrc.js Normal file
View file

@ -0,0 +1 @@
module.exports = {};

5
.firebaserc Normal file
View file

@ -0,0 +1,5 @@
{
"projects": {
"default": "built-to-spell"
}
}

6
.gitignore vendored
View file

@ -1,8 +1,5 @@
dist dist
public
.cache .cache
.parcel-cache
worker
# Logs # Logs
logs logs
@ -70,6 +67,3 @@ node_modules/
# dotenv environment variables file # dotenv environment variables file
.env .env
# Workers
transpiled

View file

@ -1,3 +0,0 @@
{
"extends": "@parcel/config-default",
}

View file

@ -2,8 +2,7 @@
## What It Is ## What It Is
A browser-based game inspired by the New York Times Spelling Bee. Also, a A browser-based game inspired by the New York Times Spelling Bee.
weekend project to expand my CSS3 skill set (and my vocabulary).
## What It Isn't ## What It Isn't
@ -13,6 +12,4 @@ your friends when you beat their scores; they might be playing by different
rules. (If you're playing Built to Spell, of course, you're still automatically rules. (If you're playing Built to Spell, of course, you're still automatically
the coolest kid on the block regardless.) the coolest kid on the block regardless.)
## Play It ## [Play It](https://bts.brentschroeter.com/)
[https://bts.brentschroeter.com/](https://bts.brentschroeter.com/)

16
firebase.json Normal file
View file

@ -0,0 +1,16 @@
{
"hosting": {
"public": "dist",
"ignore": [
"firebase.json",
"**/.*",
"**/node_modules/**"
],
"rewrites": [
{
"source": "**",
"destination": "/index.html"
}
]
}
}

22224
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -2,10 +2,11 @@
"name": "built-to-spell", "name": "built-to-spell",
"version": "0.0.1", "version": "0.0.1",
"description": "NYT Spelling Bee simulator", "description": "NYT Spelling Bee simulator",
"main": "index.js",
"scripts": { "scripts": {
"lint": "eslint src", "lint": "eslint src",
"build": "parcel build --no-cache --dist-dir=public src/index.html", "deploy": "rm -r .cache ; parcel build -d dist src/index.html && firebase deploy --only=hosting",
"start": "rm -r .cache ; parcel serve --dist-dir public src/index.html" "start": "rm -r .cache ; parcel -d dist src/index.html"
}, },
"repository": { "repository": {
"type": "git", "type": "git",
@ -18,28 +19,20 @@
}, },
"homepage": "https://gitlab.com/brentschroeter/built-to-spell#readme", "homepage": "https://gitlab.com/brentschroeter/built-to-spell#readme",
"dependencies": { "dependencies": {
"react": "^18.2.0", "react": "^17.0.1",
"react-dom": "^18.2.0" "react-dom": "^17.0.1"
}, },
"devDependencies": { "devDependencies": {
"@babel/core": "^7.20.7", "@babel/core": "^7.12.10",
"@parcel/packager-raw-url": "^2.8.2", "@types/node": "^14.14.20",
"@parcel/transformer-typescript-tsc": "^2.8.2", "@types/react": "^17.0.0",
"@parcel/transformer-webmanifest": "^2.8.2", "@types/react-dom": "^17.0.0",
"@types/node": "^18.11.18", "@typescript-eslint/eslint-plugin": "^4.11.1",
"@types/react": "^18.0.26", "eslint": "^7.17.0",
"@types/react-dom": "^17.0.14", "eslint-config-airbnb": "^18.2.1",
"@typescript-eslint/eslint-plugin": "^5.47.1", "eslint-plugin-jsx-a11y": "^6.4.1",
"@typescript-eslint/parser": "^5.47.1", "eslint-plugin-react": "^7.22.0",
"eslint": "^8.30.0", "parcel-bundler": "^1.12.4",
"eslint_d": "^11.1.1", "typescript": "^4.1.3"
"eslint-config-airbnb": "^19.0.4",
"eslint-plugin-import": "^2.24.1",
"eslint-plugin-jsx-a11y": "^6.6.1",
"eslint-plugin-react": "^7.31.11",
"eslint-plugin-react-hooks": "^4.6.0",
"parcel": "^2.8.2",
"process": "^0.11.10",
"typescript": "^4.6.3"
} }
} }

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.5 KiB

After

Width:  |  Height:  |  Size: 654 KiB

View file

@ -4,7 +4,6 @@ import React, {
} from 'react'; } from 'react';
import Honeycomb from './honeycomb'; import Honeycomb from './honeycomb';
import { isPangram } from './scoring';
import { StoreContext } from './store'; import { StoreContext } from './store';
const GameView: FunctionComponent = () => { const GameView: FunctionComponent = () => {
@ -47,7 +46,7 @@ const GameView: FunctionComponent = () => {
)} )}
{words.map((word) => ( {words.map((word) => (
<div <div
className={`m-1 ${isPangram(letters, word) ? 'text-yellow-400' : ''}`} className="m-1"
key={word} key={word}
> >
{word} {word}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.2 KiB

After

Width:  |  Height:  |  Size: 828 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

After

Width:  |  Height:  |  Size: 5 MiB

View file

@ -13,6 +13,6 @@
</head> </head>
<body class="bg-gray-50"> <body class="bg-gray-50">
<div id="app-root" class="w-screen h-screen"></div> <div id="app-root" class="w-screen h-screen"></div>
<script type="module" src="./index.tsx"></script> <script type="text/javascript" src="./index.tsx"></script>
</body> </body>
</html> </html>

View file

@ -2,15 +2,12 @@ import React from 'react';
import { render } from 'react-dom'; import { render } from 'react-dom';
import App from './app'; import App from './app';
import { NytProvider } from './nyt';
import { StoreProvider } from './store'; import { StoreProvider } from './store';
render( render(
( (
<StoreProvider> <StoreProvider>
<NytProvider> <App />
<App />
</NytProvider>
</StoreProvider> </StoreProvider>
), ),
document.getElementById('app-root'), document.getElementById('app-root'),

View file

@ -1,6 +1,6 @@
{ {
"icons": [ "icons": [
{"src": "./icon-192.png", "type": "image/png", "sizes": "192x192"}, {"src": "/icon-192.png", "type": "image/png", "sizes": "192x192"},
{"src": "./icon-512.png", "type": "image/png", "sizes": "512x512"} {"src": "/icon-512.png", "type": "image/png", "sizes": "512x512"}
] ]
} }

View file

@ -1,37 +0,0 @@
import React, {
createContext,
FC,
useEffect,
useState,
} from 'react';
interface GameData {
today: {
validLetters: string[];
};
}
export interface NytData {
letters: string[];
}
export const Context = createContext<NytData>({ letters: [] });
export const NytProvider: FC = ({ children }) => {
const [nytData, setNytData] = useState({ letters: [] } as NytData);
useEffect(() => {
fetch('/api/game-data/latest')
.then((res) => res.json() as Promise<GameData>)
.then((body) => {
setNytData({ letters: body.today.validLetters.map((x) => x.toLocaleUpperCase('en')) });
})
.catch(console.error);
});
return (
<Context.Provider value={nytData}>
{children}
</Context.Provider>
);
};

View file

@ -1,20 +0,0 @@
import twl06 = require('./twl06.json');
export const isPangram = (letters: string[], word: string): boolean => {
const chars = word.split('');
return letters.every((letter) => chars.includes(letter));
};
export const scoreWord = (letters: string[], words: string[], word: string): number => {
if (word.length < 4 || words.includes(word) || !word.split('').includes(letters[0])
|| !twl06.includes(word.toLowerCase())) {
return 0;
}
if (word.length === 4) {
return 1;
}
if (isPangram(letters, word)) {
return word.length + 7;
}
return word.length;
};

View file

@ -5,7 +5,6 @@ import React, {
useEffect, useEffect,
} from 'react'; } from 'react';
import { Context as NytContext } from './nyt';
import { StoreContext } from './store'; import { StoreContext } from './store';
const range7 = [0, 1, 2, 3, 4, 5, 6]; const range7 = [0, 1, 2, 3, 4, 5, 6];
@ -13,12 +12,8 @@ const range7 = [0, 1, 2, 3, 4, 5, 6];
const isValidLetter = (ch: string): boolean => ch.length === 1 && ch >= 'A' && ch <= 'Z'; const isValidLetter = (ch: string): boolean => ch.length === 1 && ch >= 'A' && ch <= 'Z';
const SetupView: FunctionComponent = () => { const SetupView: FunctionComponent = () => {
const { const { dispatch, state } = useContext(StoreContext);
dispatch, const { letters } = state;
state: { letters },
} = useContext(StoreContext);
const { letters: nytLetters } = useContext(NytContext);
useEffect(() => { useEffect(() => {
const firstInput = document.getElementById('setup-view-letter-selector-0'); const firstInput = document.getElementById('setup-view-letter-selector-0');
@ -87,30 +82,20 @@ const SetupView: FunctionComponent = () => {
/> />
))} ))}
</div> </div>
<button <div className="flex justify-center">
className={` <button
w-full my-6 px-4 py-2 rounded font-bold text-sm className={`
bg-yellow-400 hover:bg-yellow-500 transition-all my-6 px-4 py-2 rounded font-bold text-sm
${nytLetters.length === 7 ? 'bg-yellow-400 cursor-pointer' : 'bg-gray-400 cursor-default'} bg-yellow-400 hover:bg-yellow-500 transition-all
`} ${letters.length < 7 ? 'opacity-0 cursor-default' : ''}
disabled={nytLetters.length !== 7} `}
onClick={() => dispatch({ type: 'SET_LETTERS', payload: nytLetters })} disabled={letters.length !== 7}
type="button" onClick={() => dispatch({ type: 'SWITCH_VIEW', payload: 'GAME' })}
> type="button"
{nytLetters.length === 7 ? 'Populate from NYT' : 'Loading from NYT...'} >
</button> Play Built to Spell
<button </button>
className={` </div>
w-full my-6 px-4 py-2 rounded font-bold text-sm
bg-yellow-400 hover:bg-yellow-500 transition-all
${letters.length < 7 ? 'opacity-0 cursor-default' : 'cursor-pointer'}
`}
disabled={letters.length !== 7}
onClick={() => dispatch({ type: 'SWITCH_VIEW', payload: 'GAME' })}
type="button"
>
Play Built to Spell
</button>
</div> </div>
); );
}; };

View file

@ -7,7 +7,7 @@ import React, {
useReducer, useReducer,
} from 'react'; } from 'react';
import { scoreWord } from './scoring'; import twl06 = require('./twl06.json');
export interface State { export interface State {
currentWord: string; currentWord: string;
@ -69,13 +69,22 @@ const shuffle = (letters: string[]): string[] => {
return newLetters; return newLetters;
}; };
const canSubmitWord = (state: State): boolean => {
const { currentWord, words } = state;
return currentWord.length > 3 && !words.includes(currentWord)
&& currentWord.split('').includes(state.letters[0])
&& twl06.includes(currentWord.toLowerCase());
};
const scoreWord = (word: string): number => (word.length === 4 ? 1 : word.length);
const enterWordReducer: Reducer<State, EnterWordAction> = (state) => { const enterWordReducer: Reducer<State, EnterWordAction> = (state) => {
const score = scoreWord(state.letters, state.words, state.currentWord); const valid = canSubmitWord(state);
return { return {
...state, ...state,
currentWord: '', currentWord: '',
score: state.score + score, score: state.score + (valid ? scoreWord(state.currentWord) : 0),
words: score > 0 ? [...state.words, state.currentWord] : state.words, words: valid ? [...state.words, state.currentWord] : state.words,
}; };
}; };

View file

@ -33,7 +33,7 @@
"isolatedModules": true "isolatedModules": true
}, },
"include": [ "include": [
"src/", "src/**/*",
".eslintrc.js", ".eslintrc.js",
], ],
"exclude": [ "exclude": [

View file

View file

@ -1,170 +0,0 @@
module.exports = {
env: {
browser: true,
es2021: true,
},
parser: '@typescript-eslint/parser',
parserOptions: {
ecmaFeatures: { jsx: true },
ecmaVersion: 12,
project: ['./tsconfig.json'],
sourceType: 'module',
},
extends: [
'plugin:@typescript-eslint/recommended',
'plugin:@typescript-eslint/recommended-requiring-type-checking',
'plugin:jsx-a11y/recommended',
'plugin:react/recommended',
'airbnb',
],
plugins: [
'@typescript-eslint',
'import',
'jsx-a11y',
'react',
'react-hooks',
],
rules: {
'@typescript-eslint/brace-style': ['error'],
'@typescript-eslint/comma-dangle': ['error', 'always-multiline'],
'@typescript-eslint/consistent-type-definitions': ['error', 'interface'],
'@typescript-eslint/keyword-spacing': ['error'],
'@typescript-eslint/member-delimiter-style': ['error'],
'@typescript-eslint/method-signature-style': ['warn', 'method'],
'@typescript-eslint/naming-convention': [
'warn',
{
selector: 'default',
format: ['camelCase', 'PascalCase'],
leadingUnderscore: 'forbid',
trailingUnderscore: 'forbid',
filter: {
// skip names requiring quotes, e.g. HTTP headers
regex: '[- ]',
match: false,
},
},
{
selector: ['enumMember'],
format: ['UPPER_CASE'],
},
{
selector: ['property', 'parameter', 'parameterProperty'],
format: ['camelCase', 'PascalCase'],
leadingUnderscore: 'allow',
trailingUnderscore: 'forbid',
filter: {
// skip names requiring quotes, e.g. HTTP headers
regex: '[- ]',
match: false,
},
},
{
selector: 'typeLike',
format: ['PascalCase'],
},
],
'@typescript-eslint/no-duplicate-imports': ['error'],
'@typescript-eslint/no-explicit-any': 'off',
'@typescript-eslint/no-floating-promises': ['error'],
'@typescript-eslint/no-misused-promises': ['error'],
'@typescript-eslint/no-non-null-assertion': 'off',
'@typescript-eslint/no-shadow': ['error'],
'@typescript-eslint/no-unnecessary-condition': ['error', { allowConstantLoopConditions: true }],
'@typescript-eslint/no-unnecessary-boolean-literal-compare': ['error'],
'@typescript-eslint/no-unused-expressions': ['error'],
'@typescript-eslint/no-unused-vars': ['error'],
'@typescript-eslint/no-use-before-define': ['error'],
'@typescript-eslint/prefer-nullish-coalescing': ['warn'],
'@typescript-eslint/quotes': ['error', 'single', { avoidEscape: true }],
'@typescript-eslint/semi': ['error'],
'@typescript-eslint/space-before-function-paren': ['error', {
anonymous: 'never',
named: 'never',
asyncArrow: 'always',
}],
'@typescript-eslint/switch-exhaustiveness-check': ['error'],
'@typescript-eslint/type-annotation-spacing': ['warn', {
before: false,
after: true,
overrides: {
arrow: { before: true },
},
}],
'brace-style': 'off',
camelcase: 'off',
'class-methods-use-this': 'off',
'comma-dangle': 'off',
'keyword-spacing': 'off',
'no-duplicate-imports': 'off',
'no-shadow': 'off',
'no-unused-expressions': 'off',
'no-unused-vars': 'off',
'no-use-before-define': 'off',
quotes: 'off',
semi: 'off',
'space-before-function-paren': 'off',
'implicit-arrow-linebreak': 'off',
'import/extensions': [
'error',
'ignorePackages',
{
js: 'never',
jsx: 'never',
ts: 'never',
tsx: 'never',
},
],
indent: ['error', 2, {
CallExpression: { arguments: 'first' },
FunctionDeclaration: { parameters: 'first' },
FunctionExpression: { parameters: 'first' },
SwitchCase: 1,
VariableDeclarator: 'first',
offsetTernaryExpressions: true,
}],
'jsx-a11y/label-has-associated-control': ['warn', {
assert: 'either',
}],
'max-classes-per-file': 'off',
'max-len': ['warn', { code: 100, tabWidth: 2 }],
'no-console': 'off',
'no-underscore-dangle': 'off',
'prefer-arrow-callback': ['error'],
'react/function-component-definition': ['error', {
namedComponents: 'arrow-function',
unnamedComponents: 'arrow-function',
}],
'react/jsx-curly-newline': ['error', { multiline: 'forbid', singleline: 'forbid' }],
'react/jsx-filename-extension': ['error', { extensions: ['.jsx', '.tsx'] }],
'react/jsx-first-prop-new-line': ['error', 'multiline'],
'react/jsx-fragments': 'off',
'react/jsx-indent': ['error', 2, { checkAttributes: true, indentLogicalExpressions: true }],
'react/jsx-indent-props': ['error', 2],
'react/jsx-key': ['error', { checkFragmentShorthand: true }],
'react/jsx-max-props-per-line': ['error', { maximum: 3, when: 'always' }],
'react/jsx-no-target-blank': ['error'],
'react/jsx-wrap-multilines': ['error', {
declaration: 'parens-new-line',
assignment: 'parens-new-line',
return: 'parens-new-line',
arrow: 'parens-new-line',
condition: 'parens-new-line',
logical: 'parens-new-line',
prop: 'parens-new-line',
}],
'react/prop-types': 'off',
'react/no-deprecated': ['error'],
'react/no-multi-comp': ['error'],
'react/no-unused-prop-types': ['error'],
'react/prefer-stateless-function': ['error'],
'react/react-in-jsx-scope': 'off',
'react-hooks/rules-of-hooks': ['error'],
'react-hooks/exhaustive-deps': ['warn'],
},
settings: {
'import/resolver': {
node: { extensions: ['.js', '.jsx', '.ts', '.tsx'] },
},
},
};

View file

@ -1,7 +0,0 @@
{
"transform": {
"^.+\\.(t|j)sx?$": "ts-jest"
},
"testRegex": "/test/.*\\.test\\.ts$",
"collectCoverageFrom": ["src/**/*.{ts,js}"]
}

File diff suppressed because it is too large Load diff

View file

@ -1,36 +0,0 @@
{
"name": "worker",
"version": "1.0.0",
"description": "",
"main": "dist/worker.js",
"author": "",
"license": "MIT",
"scripts": {
"build": "webpack",
"format": "prettier --write '*.{json,js}' 'src/**/*.{js,ts}' 'test/**/*.{js,ts}'",
"lint": "eslint --max-warnings=0 src && prettier --check '*.{json,js}' 'src/**/*.{js,ts}' 'test/**/*.{js,ts}'",
"test": "jest --config jestconfig.json --verbose"
},
"dependencies": {
"@cloudflare/kv-asset-handler": "~0.1.2"
},
"devDependencies": {
"@cloudflare/workers-types": "^3.0.0",
"@types/jest": "^26.0.23",
"@types/service-worker-mock": "^2.0.1",
"@typescript-eslint/eslint-plugin": "^4.16.1",
"@typescript-eslint/parser": "^4.16.1",
"eslint": "^7.21.0",
"eslint-config-prettier": "^8.1.0",
"eslint-config-typescript": "^3.0.0",
"eslint_d": "^11.1.1",
"jest": "^27.0.1",
"prettier": "^2.3.0",
"service-worker-mock": "^2.0.5",
"ts-jest": "^27.0.1",
"ts-loader": "^9.2.8",
"typescript": "^4.6.3",
"webpack": "^5.70.0",
"webpack-cli": "^4.9.2"
}
}

View file

@ -1,5 +0,0 @@
export {};
declare global {
const NYT_GAME_DATA: KVNamespace;
}

View file

@ -1,102 +0,0 @@
import {
getAssetFromKV,
Options as GetAssetFromKVOptions,
} from '@cloudflare/kv-asset-handler';
/**
* The DEBUG flag will do two things that help during development:
* 1. we will skip caching on the edge, which makes it easier to
* debug.
* 2. we will return an error message on exception in your Response rather
* than the default 404.html page.
*/
const DEBUG = false;
addEventListener('fetch', (event) => {
try {
event.respondWith(handleFetch(event));
} catch (e) {
if (e instanceof Error && DEBUG) {
return event.respondWith(new Response(e.message || e.toString(), { status: 500 }));
}
event.respondWith(new Response('Internal Error', { status: 500 }));
}
});
addEventListener('scheduled', (event) => {
event.waitUntil(handleCron());
});
interface DailyGameData {
printDate: string;
centerLetter: string;
outerLetters: string[];
answers: string[];
}
interface GameData {
today: DailyGameData;
}
async function handleFetch(event: FetchEvent) {
const url = new URL(event.request.url);
const options: Partial<GetAssetFromKVOptions> = {};
try {
if (DEBUG) {
options.cacheControl = { bypassCache: true };
}
let response = new Response('', {});
if (url.pathname.toLocaleLowerCase('en') === '/api/game-data/latest') {
const gameData = await NYT_GAME_DATA.get('latest');
response = new Response(gameData || '', { status: gameData ? 200 : 500 });
response.headers.set('Content-Type', 'application/json');
} else {
const page = await getAssetFromKV(event, options);
response = new Response(page.body, page);
}
response.headers.set('X-XSS-Protection', '1; mode=block');
response.headers.set('X-Content-Type-Options', 'nosniff');
response.headers.set('X-Frame-Options', 'DENY');
response.headers.set('Referrer-Policy', 'unsafe-url');
response.headers.set('Feature-Policy', 'none');
return response;
} catch (e) {
// if an error is thrown try to serve the asset at 404.html
if (e instanceof Error && DEBUG) {
return new Response(e.message || e.toString(), { status: 500 });
}
try {
let notFoundResponse = await getAssetFromKV(event, {
mapRequestToAsset: (req) => new Request(`${new URL(req.url).origin}/404.html`, req),
});
return new Response(notFoundResponse.body, { ...notFoundResponse, status: 404 });
} catch (e) { /* no-op */ }
return new Response('Not found.', { status: 404 });
}
}
async function handleCron() {
console.log('Starting cron trigger...');
const resp = await fetch('https://www.nytimes.com/puzzles/spelling-bee');
console.log('Fetched from NYT.');
let text = await resp.text();
const startStr = '<script type="text/javascript">window.gameData = {';
const startIdx = text.indexOf(startStr);
if (startIdx === -1) {
throw new Error('Unable to locate gameData.');
}
text = text.slice(startIdx + startStr.length - 1);
const endIdx = text.indexOf('</script>');
if (endIdx === -1) {
throw new Error('Unable to locate end of gameData.');
}
text = text.slice(0, endIdx);
console.log('Found gameData.');
const gameData = JSON.parse(text) as GameData;
console.log('Parsed gameData.');
await Promise.all([
NYT_GAME_DATA.put('latest', text),
NYT_GAME_DATA.put(gameData.today.printDate, text),
]);
console.log('Saved gameData.');
}

View file

@ -1,21 +0,0 @@
{
"compilerOptions": {
"outDir": "./dist",
"module": "commonjs",
"target": "esnext",
"lib": ["esnext"],
"alwaysStrict": true,
"strict": true,
"preserveConstEnums": true,
"moduleResolution": "node",
"sourceMap": true,
"esModuleInterop": true,
"types": [
"@cloudflare/workers-types",
"@types/jest",
"@types/service-worker-mock"
]
},
"include": ["src"],
"exclude": ["node_modules", "dist", "test"]
}

View file

@ -1,22 +0,0 @@
const path = require('path');
module.exports = {
entry: path.join(__dirname, 'src/index.ts'),
output: {
filename: 'worker.js',
path: path.join(__dirname, 'dist'),
},
devtool: 'cheap-module-source-map',
mode: 'development',
resolve: {
extensions: ['.ts', '.tsx', '.js'],
},
module: {
rules: [
{
test: /\.tsx?$/,
loader: 'ts-loader',
},
],
},
}

View file

@ -1,21 +0,0 @@
name = "built-to-spell"
main = "./dist/worker.js"
route = 'bts.brentsch.com/*'
account_id = '7c81d50e4fc6ec51eecd802aacd6304b'
usage_model = 'bundled'
compatibility_flags = []
workers_dev = false
compatibility_date = "2022-03-27"
[triggers]
crons = ["0 * * * *"]
[build]
command = "npm --prefix=../ run build && npm run build"
[[kv_namespaces]]
binding = "NYT_GAME_DATA"
id = "ad58b382cc884b889f42ed94cf4dbb6b"
[site]
bucket = "../public"