Skip to content
Open
113 changes: 113 additions & 0 deletions SETUP_GUIDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
# Monkeytype Local Setup Guide

## Prerequisites

- **Node.js 24.11.0** (or 22.21.0) - Install from https://nodejs.org/ or use nvm
- **PNPM 9.6.0** - Run: `npm install -g [email protected]`
- **Docker Desktop** - Install from https://www.docker.com/get-started/
- **Git** - Install from https://git-scm.com/

## Setup Steps

### Step 1: Clone the Repository

```bash
git clone https://github.com/monkeytypegame/monkeytype.git
cd monkeytype
```

### Step 2: Install Dependencies

```bash
pnpm install
```

_Note: If you get Node version errors, create `.npmrc` files with `engine-strict=false` in root, backend, and frontend directories._

### Step 3: Configure Backend

```bash
# Copy environment file
copy backend\example.env backend\.env

# The .env file already has MODE=dev set, which is what you need
```

### Step 4: Create Firebase Config (Optional)

Create `frontend/src/ts/constants/firebase-config.ts`:

```typescript
export const firebaseConfig = {
apiKey: "",
authDomain: "",
projectId: "",
storageBucket: "",
messagingSenderId: "",
appId: "",
};
```

_Leave empty for development without authentication features._

### Step 5: Start Databases

```bash
cd backend
npm run docker-db-only
```

_This starts MongoDB (port 27017) and Redis (port 6379) in Docker containers._

### Step 6: Start Backend Server

Open a new terminal:

```bash
cd backend
npm run dev
```

_Backend will run on http://localhost:5005_

### Step 7: Start Frontend

Open another new terminal:

```bash
npm run dev-fe
```

_Frontend will run on http://localhost:3000_

### Step 8: Open Application

Visit **http://localhost:3000** in your browser!

## Quick Start Commands

After initial setup, you only need:

1. `cd backend && npm run docker-db-only` (start databases)
2. `cd backend && npm run dev` (start backend)
3. `npm run dev-fe` (start frontend)

## Troubleshooting

**Node version error?**

- Use `nvm use 24.11.0` or create `.npmrc` files with `engine-strict=false`

**Backend won't connect to database?**

- Ensure Docker Desktop is running
- Check databases are running: `docker ps`

**Firebase error on frontend?**

- This is normal if you haven't set up Firebase
- You can still use all typing features without authentication

**Port already in use?**

- Stop other processes using ports 3000, 5005, 27017, or 6379
1 change: 1 addition & 0 deletions backend/.npmrc
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
engine-strict=false
1 change: 1 addition & 0 deletions frontend/.npmrc
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
engine-strict=false
14 changes: 14 additions & 0 deletions frontend/src/styles/test.scss
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
@import url("https://fonts.googleapis.com/css2?family=Faruma&display=swap");

.highlightContainer {
position: absolute;
overflow: hidden;
Expand Down Expand Up @@ -320,6 +322,18 @@
//flex-direction: row-reverse; // no need for hacking 😉, CSS fully support right-to-left languages
direction: rtl;
}

&.thaanaTest {
font-family: "Faruma", "MV Faseyha", sans-serif;

.word letter {
display: inline-block;
unicode-bidi: isolate;
vertical-align: baseline;
line-height: 1;
}
}

&.withLigatures {
.word {
overflow-wrap: anywhere;
Expand Down
1 change: 1 addition & 0 deletions frontend/src/ts/constants/languages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -368,6 +368,7 @@ export const LanguageGroups: Record<string, Language[]> = {
"code_cuda",
],
viossa: ["viossa", "viossa_njutro"],
dhivehi: ["dhivehi"],
};

export type LanguageGroupName = keyof typeof LanguageGroups;
Expand Down
22 changes: 15 additions & 7 deletions frontend/src/ts/input/helpers/validation.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
import Config from "../../config";
import { isSpace } from "../../utils/strings";
import {
isSpace,
splitIntoCharacters,
isCharacterMatch,
} from "../../utils/strings";

/**
* Check if the input data is correct
Expand Down Expand Up @@ -29,17 +33,21 @@ export function isCharCorrect(options: {
return inputValue === targetWord;
}

const targetChar = targetWord[inputValue.length];
// Use splitIntoCharacters to properly handle combining characters (like Thaana fili)
const inputChars = splitIntoCharacters(inputValue + data);
const targetChars = splitIntoCharacters(targetWord);

// Get the character we just typed (last in inputChars after combining)
const typedCharIndex = inputChars.length - 1;
const typedChar = inputChars[typedCharIndex];
const targetChar = targetChars[typedCharIndex];

if (targetChar === undefined) {
return false;
}

if (data === targetChar) {
return true;
}

return false;
// Use isCharacterMatch to handle partial matches (e.g., Thaana consonant before vowel mark)
return isCharacterMatch(typedChar ?? "", targetChar);
}

/**
Expand Down
19 changes: 18 additions & 1 deletion frontend/src/ts/test/test-ui.ts
Original file line number Diff line number Diff line change
Expand Up @@ -445,6 +445,17 @@ function updateWordWrapperClasses(): void {
$("#resultReplay .words").removeClass("rightToLeftTest");
}

// Add special handling for Thaana (Dhivehi) script
if (Config.language.startsWith("dhivehi")) {
wordsEl.classList.add("thaanaTest");
$("#resultWordsHistory .words").addClass("thaanaTest");
$("#resultReplay .words").addClass("thaanaTest");
} else {
wordsEl.classList.remove("thaanaTest");
$("#resultWordsHistory .words").removeClass("thaanaTest");
$("#resultReplay .words").removeClass("thaanaTest");
}

const existing =
wordsEl?.className
.split(/\s+/)
Expand Down Expand Up @@ -747,8 +758,14 @@ export async function updateWordLetters({

const inputChars = Strings.splitIntoCharacters(input);
const currentWordChars = Strings.splitIntoCharacters(currentWord);

for (let i = 0; i < inputChars.length; i++) {
const charCorrect = currentWordChars[i] === inputChars[i];
const inputChar = inputChars[i] ?? "";
const targetChar = currentWordChars[i] ?? "";

const charCorrect =
inputChar === targetChar ||
Strings.isCharacterMatchForDisplay(inputChar, targetChar);

let currentLetter = currentWordChars[i] as string;
let tabChar = "";
Expand Down
12 changes: 9 additions & 3 deletions frontend/src/ts/test/words-generator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,8 @@ export async function punctuateWord(
currentLanguage === "arabic" ||
currentLanguage === "persian" ||
currentLanguage === "urdu" ||
currentLanguage === "kurdish"
currentLanguage === "kurdish" ||
currentLanguage === "dhivehi"
) {
word += "؟";
} else if (currentLanguage === "greek") {
Expand Down Expand Up @@ -216,7 +217,11 @@ export async function punctuateWord(
// However, a) it has fallen into disuse in contemporary times and
// b) there isn't a dedicated key on a keyboard to input it
word = ".";
} else if (currentLanguage === "arabic" || currentLanguage === "kurdish") {
} else if (
currentLanguage === "arabic" ||
currentLanguage === "kurdish" ||
currentLanguage === "dhivehi"
) {
word += "؛";
} else if (currentLanguage === "chinese") {
word += ";";
Expand All @@ -228,7 +233,8 @@ export async function punctuateWord(
currentLanguage === "arabic" ||
currentLanguage === "urdu" ||
currentLanguage === "persian" ||
currentLanguage === "kurdish"
currentLanguage === "kurdish" ||
currentLanguage === "dhivehi"
) {
word += "،";
} else if (currentLanguage === "japanese") {
Expand Down
84 changes: 74 additions & 10 deletions frontend/src/ts/utils/strings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -176,18 +176,82 @@ export function cleanTypographySymbols(textToClean: string): string {
}

/**
* Split a string into characters. This supports multi-byte characters outside of the [Basic Multilinugal Plane](https://en.wikipedia.org/wiki/Plane_(Unicode).
* Using `string.length` and `string[index]` does not work.
* @param s string to be tokenized into characters
* @returns array of characters
* Check if a character is a Thaana combining vowel mark (fili) - U+07A6 to U+07B0
*/
export function isThaanaCombiningMark(char: string): boolean {
const code = char.charCodeAt(0);
return code >= 0x07a6 && code <= 0x07b0;
}

/**
* Check if a character is a Thaana consonant - U+0780 to U+07A5
*/
export function isThaanaConsonant(char: string): boolean {
const code = char.charCodeAt(0);
return code >= 0x0780 && code <= 0x07a5;
}

/**
* Split a string into individual Unicode code points.
*/
export function splitIntoCharacters(s: string): string[] {
const result: string[] = [];
for (const t of s) {
result.push(t);
// eslint-disable-next-line @typescript-eslint/no-misused-spread -- Intentional for Thaana character separation
return [...s];
}

const punctuationEquivalents: Record<string, string> = {
"،": ",",
"؛": ";",
"؟": "?",
};

function arePunctuationEquivalent(char1: string, char2: string): boolean {
if (char1 === char2) return true;
if (punctuationEquivalents[char1] === char2) return true;
if (punctuationEquivalents[char2] === char1) return true;
return false;
}

/**
* Check if input matches target, including partial Thaana matches and punctuation equivalents.
*/
export function isCharacterMatch(
inputChar: string,
targetChar: string,
): boolean {
if (inputChar === targetChar) return true;

if (inputChar.length === 1 && targetChar.length === 1) {
if (arePunctuationEquivalent(inputChar, targetChar)) return true;
}

if (
inputChar.length === 1 &&
targetChar.length === 2 &&
isThaanaConsonant(inputChar) &&
targetChar.startsWith(inputChar) &&
isThaanaCombiningMark(targetChar.charAt(1))
) {
return true;
}

return false;
}

/**
* Check if input matches target for display (no partial Thaana matches).
*/
export function isCharacterMatchForDisplay(
inputChar: string,
targetChar: string,
): boolean {
if (inputChar === targetChar) return true;

if (inputChar.length === 1 && targetChar.length === 1) {
if (arePunctuationEquivalent(inputChar, targetChar)) return true;
}

return result;
return false;
}

/**
Expand Down Expand Up @@ -219,9 +283,9 @@ function hasRTLCharacters(word: string): boolean {
return false;
}

// This covers Arabic, Farsi, Urdu, and other RTL scripts
// This covers Arabic, Farsi, Urdu, Hebrew, Thaana (Dhivehi), and other RTL scripts
const rtlPattern =
/[\u0590-\u05FF\u0600-\u06FF\u0750-\u077F\u08A0-\u08FF\uFB50-\uFDFF\uFE70-\uFEFF]/;
/[\u0590-\u05FF\u0600-\u06FF\u0750-\u077F\u0780-\u07BF\u08A0-\u08FF\uFB50-\uFDFF\uFE70-\uFEFF]/;

return rtlPattern.test(word);
}
Expand Down
Loading
Loading