Compare commits
10 commits
93ddcc31f9
...
7ebe53f0a4
Author | SHA1 | Date | |
---|---|---|---|
![]() |
7ebe53f0a4 | ||
![]() |
07fde82912 | ||
![]() |
314a89d185 | ||
![]() |
a9d9c1a3af | ||
![]() |
57c05a265a | ||
![]() |
18c6ab739e | ||
![]() |
b49ff9f0f4 | ||
![]() |
b73ff68d17 | ||
![]() |
5cb4eeae72 | ||
![]() |
9ff8e0ba71 |
30 changed files with 13711 additions and 15869 deletions
|
@ -1 +0,0 @@
|
|||
module.exports = {};
|
|
@ -1,5 +0,0 @@
|
|||
{
|
||||
"projects": {
|
||||
"default": "built-to-spell"
|
||||
}
|
||||
}
|
6
.gitignore
vendored
6
.gitignore
vendored
|
@ -1,5 +1,8 @@
|
|||
dist
|
||||
public
|
||||
.cache
|
||||
.parcel-cache
|
||||
worker
|
||||
|
||||
# Logs
|
||||
logs
|
||||
|
@ -67,3 +70,6 @@ node_modules/
|
|||
|
||||
# dotenv environment variables file
|
||||
.env
|
||||
|
||||
# Workers
|
||||
transpiled
|
||||
|
|
3
.parcelrc
Normal file
3
.parcelrc
Normal file
|
@ -0,0 +1,3 @@
|
|||
{
|
||||
"extends": "@parcel/config-default",
|
||||
}
|
|
@ -2,7 +2,8 @@
|
|||
|
||||
## What It Is
|
||||
|
||||
A browser-based game inspired by the New York Times Spelling Bee.
|
||||
A browser-based game inspired by the New York Times Spelling Bee. Also, a
|
||||
weekend project to expand my CSS3 skill set (and my vocabulary).
|
||||
|
||||
## What It Isn't
|
||||
|
||||
|
@ -12,4 +13,6 @@ 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
|
||||
the coolest kid on the block regardless.)
|
||||
|
||||
## [Play It](https://bts.brentschroeter.com/)
|
||||
## Play It
|
||||
|
||||
[https://bts.brentschroeter.com/](https://bts.brentschroeter.com/)
|
||||
|
|
|
@ -1,16 +0,0 @@
|
|||
{
|
||||
"hosting": {
|
||||
"public": "dist",
|
||||
"ignore": [
|
||||
"firebase.json",
|
||||
"**/.*",
|
||||
"**/node_modules/**"
|
||||
],
|
||||
"rewrites": [
|
||||
{
|
||||
"source": "**",
|
||||
"destination": "/index.html"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
23342
package-lock.json
generated
23342
package-lock.json
generated
File diff suppressed because it is too large
Load diff
39
package.json
39
package.json
|
@ -2,11 +2,10 @@
|
|||
"name": "built-to-spell",
|
||||
"version": "0.0.1",
|
||||
"description": "NYT Spelling Bee simulator",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"lint": "eslint src",
|
||||
"deploy": "rm -r .cache ; parcel build -d dist src/index.html && firebase deploy --only=hosting",
|
||||
"start": "rm -r .cache ; parcel -d dist src/index.html"
|
||||
"build": "parcel build --no-cache --dist-dir=public src/index.html",
|
||||
"start": "rm -r .cache ; parcel serve --dist-dir public src/index.html"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
|
@ -19,20 +18,28 @@
|
|||
},
|
||||
"homepage": "https://gitlab.com/brentschroeter/built-to-spell#readme",
|
||||
"dependencies": {
|
||||
"react": "^17.0.1",
|
||||
"react-dom": "^17.0.1"
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.12.10",
|
||||
"@types/node": "^14.14.20",
|
||||
"@types/react": "^17.0.0",
|
||||
"@types/react-dom": "^17.0.0",
|
||||
"@typescript-eslint/eslint-plugin": "^4.11.1",
|
||||
"eslint": "^7.17.0",
|
||||
"eslint-config-airbnb": "^18.2.1",
|
||||
"eslint-plugin-jsx-a11y": "^6.4.1",
|
||||
"eslint-plugin-react": "^7.22.0",
|
||||
"parcel-bundler": "^1.12.4",
|
||||
"typescript": "^4.1.3"
|
||||
"@babel/core": "^7.20.7",
|
||||
"@parcel/packager-raw-url": "^2.8.2",
|
||||
"@parcel/transformer-typescript-tsc": "^2.8.2",
|
||||
"@parcel/transformer-webmanifest": "^2.8.2",
|
||||
"@types/node": "^18.11.18",
|
||||
"@types/react": "^18.0.26",
|
||||
"@types/react-dom": "^17.0.14",
|
||||
"@typescript-eslint/eslint-plugin": "^5.47.1",
|
||||
"@typescript-eslint/parser": "^5.47.1",
|
||||
"eslint": "^8.30.0",
|
||||
"eslint_d": "^11.1.1",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
|
|
BIN
src/apple.png
BIN
src/apple.png
Binary file not shown.
Before Width: | Height: | Size: 654 KiB After Width: | Height: | Size: 6.5 KiB |
|
@ -4,6 +4,7 @@ import React, {
|
|||
} from 'react';
|
||||
|
||||
import Honeycomb from './honeycomb';
|
||||
import { isPangram } from './scoring';
|
||||
import { StoreContext } from './store';
|
||||
|
||||
const GameView: FunctionComponent = () => {
|
||||
|
@ -46,7 +47,7 @@ const GameView: FunctionComponent = () => {
|
|||
)}
|
||||
{words.map((word) => (
|
||||
<div
|
||||
className="m-1"
|
||||
className={`m-1 ${isPangram(letters, word) ? 'text-yellow-400' : ''}`}
|
||||
key={word}
|
||||
>
|
||||
{word}
|
||||
|
|
BIN
src/icon-192.png
BIN
src/icon-192.png
Binary file not shown.
Before Width: | Height: | Size: 828 KiB After Width: | Height: | Size: 9.2 KiB |
BIN
src/icon-512.png
BIN
src/icon-512.png
Binary file not shown.
Before Width: | Height: | Size: 5 MiB After Width: | Height: | Size: 27 KiB |
|
@ -13,6 +13,6 @@
|
|||
</head>
|
||||
<body class="bg-gray-50">
|
||||
<div id="app-root" class="w-screen h-screen"></div>
|
||||
<script type="text/javascript" src="./index.tsx"></script>
|
||||
<script type="module" src="./index.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
@ -2,12 +2,15 @@ import React from 'react';
|
|||
import { render } from 'react-dom';
|
||||
|
||||
import App from './app';
|
||||
import { NytProvider } from './nyt';
|
||||
import { StoreProvider } from './store';
|
||||
|
||||
render(
|
||||
(
|
||||
<StoreProvider>
|
||||
<NytProvider>
|
||||
<App />
|
||||
</NytProvider>
|
||||
</StoreProvider>
|
||||
),
|
||||
document.getElementById('app-root'),
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"icons": [
|
||||
{"src": "/icon-192.png", "type": "image/png", "sizes": "192x192"},
|
||||
{"src": "/icon-512.png", "type": "image/png", "sizes": "512x512"}
|
||||
{"src": "./icon-192.png", "type": "image/png", "sizes": "192x192"},
|
||||
{"src": "./icon-512.png", "type": "image/png", "sizes": "512x512"}
|
||||
]
|
||||
}
|
||||
|
|
37
src/nyt.tsx
Normal file
37
src/nyt.tsx
Normal file
|
@ -0,0 +1,37 @@
|
|||
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>
|
||||
);
|
||||
};
|
20
src/scoring.ts
Normal file
20
src/scoring.ts
Normal file
|
@ -0,0 +1,20 @@
|
|||
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;
|
||||
};
|
|
@ -5,6 +5,7 @@ import React, {
|
|||
useEffect,
|
||||
} from 'react';
|
||||
|
||||
import { Context as NytContext } from './nyt';
|
||||
import { StoreContext } from './store';
|
||||
|
||||
const range7 = [0, 1, 2, 3, 4, 5, 6];
|
||||
|
@ -12,8 +13,12 @@ const range7 = [0, 1, 2, 3, 4, 5, 6];
|
|||
const isValidLetter = (ch: string): boolean => ch.length === 1 && ch >= 'A' && ch <= 'Z';
|
||||
|
||||
const SetupView: FunctionComponent = () => {
|
||||
const { dispatch, state } = useContext(StoreContext);
|
||||
const { letters } = state;
|
||||
const {
|
||||
dispatch,
|
||||
state: { letters },
|
||||
} = useContext(StoreContext);
|
||||
|
||||
const { letters: nytLetters } = useContext(NytContext);
|
||||
|
||||
useEffect(() => {
|
||||
const firstInput = document.getElementById('setup-view-letter-selector-0');
|
||||
|
@ -82,12 +87,23 @@ const SetupView: FunctionComponent = () => {
|
|||
/>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex justify-center">
|
||||
<button
|
||||
className={`
|
||||
my-6 px-4 py-2 rounded font-bold text-sm
|
||||
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' : ''}
|
||||
${nytLetters.length === 7 ? 'bg-yellow-400 cursor-pointer' : 'bg-gray-400 cursor-default'}
|
||||
`}
|
||||
disabled={nytLetters.length !== 7}
|
||||
onClick={() => dispatch({ type: 'SET_LETTERS', payload: nytLetters })}
|
||||
type="button"
|
||||
>
|
||||
{nytLetters.length === 7 ? 'Populate from NYT' : 'Loading from NYT...'}
|
||||
</button>
|
||||
<button
|
||||
className={`
|
||||
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' })}
|
||||
|
@ -96,7 +112,6 @@ const SetupView: FunctionComponent = () => {
|
|||
Play Built to Spell
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
@ -7,7 +7,7 @@ import React, {
|
|||
useReducer,
|
||||
} from 'react';
|
||||
|
||||
import twl06 = require('./twl06.json');
|
||||
import { scoreWord } from './scoring';
|
||||
|
||||
export interface State {
|
||||
currentWord: string;
|
||||
|
@ -69,22 +69,13 @@ const shuffle = (letters: string[]): string[] => {
|
|||
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 valid = canSubmitWord(state);
|
||||
const score = scoreWord(state.letters, state.words, state.currentWord);
|
||||
return {
|
||||
...state,
|
||||
currentWord: '',
|
||||
score: state.score + (valid ? scoreWord(state.currentWord) : 0),
|
||||
words: valid ? [...state.words, state.currentWord] : state.words,
|
||||
score: state.score + score,
|
||||
words: score > 0 ? [...state.words, state.currentWord] : state.words,
|
||||
};
|
||||
};
|
||||
|
||||
|
|
|
@ -33,7 +33,7 @@
|
|||
"isolatedModules": true
|
||||
},
|
||||
"include": [
|
||||
"src/**/*",
|
||||
"src/",
|
||||
".eslintrc.js",
|
||||
],
|
||||
"exclude": [
|
||||
|
|
0
workers-site/.cargo-ok
Normal file
0
workers-site/.cargo-ok
Normal file
170
workers-site/.eslintrc.js
Normal file
170
workers-site/.eslintrc.js
Normal file
|
@ -0,0 +1,170 @@
|
|||
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'] },
|
||||
},
|
||||
},
|
||||
};
|
7
workers-site/jestconfig.json
Normal file
7
workers-site/jestconfig.json
Normal file
|
@ -0,0 +1,7 @@
|
|||
{
|
||||
"transform": {
|
||||
"^.+\\.(t|j)sx?$": "ts-jest"
|
||||
},
|
||||
"testRegex": "/test/.*\\.test\\.ts$",
|
||||
"collectCoverageFrom": ["src/**/*.{ts,js}"]
|
||||
}
|
5662
workers-site/package-lock.json
generated
Normal file
5662
workers-site/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
36
workers-site/package.json
Normal file
36
workers-site/package.json
Normal file
|
@ -0,0 +1,36 @@
|
|||
{
|
||||
"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"
|
||||
}
|
||||
}
|
5
workers-site/src/bindings.d.ts
vendored
Normal file
5
workers-site/src/bindings.d.ts
vendored
Normal file
|
@ -0,0 +1,5 @@
|
|||
export {};
|
||||
|
||||
declare global {
|
||||
const NYT_GAME_DATA: KVNamespace;
|
||||
}
|
102
workers-site/src/index.ts
Normal file
102
workers-site/src/index.ts
Normal file
|
@ -0,0 +1,102 @@
|
|||
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.');
|
||||
}
|
21
workers-site/tsconfig.json
Normal file
21
workers-site/tsconfig.json
Normal file
|
@ -0,0 +1,21 @@
|
|||
{
|
||||
"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"]
|
||||
}
|
22
workers-site/webpack.config.js
Normal file
22
workers-site/webpack.config.js
Normal file
|
@ -0,0 +1,22 @@
|
|||
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',
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
21
workers-site/wrangler.toml
Normal file
21
workers-site/wrangler.toml
Normal file
|
@ -0,0 +1,21 @@
|
|||
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"
|
Loading…
Add table
Reference in a new issue