Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
39 commits
Select commit Hold shift + click to select a range
a157da4
feat: Show word definitions after game completion (#99)
Hugo0 Feb 22, 2026
fc3f5bb
feat: Add Word Art setting — AI image toggle in settings
Hugo0 Feb 22, 2026
b54761a
fix: Wiktionary API uses language codes, not names
Hugo0 Feb 22, 2026
16c63b1
chore: Remove junk files accidentally committed
Hugo0 Feb 22, 2026
744efdd
Refactor stats modal to 3-tab layout and improve image generation
Hugo0 Feb 22, 2026
7b271e3
feat: Add shareable word subpages with SEO and community stats
Hugo0 Feb 22, 2026
d930e99
fix: Stats tab default, .env loading, and image loading flow
Hugo0 Feb 22, 2026
035ded1
fix: Image not loading (lazy attr on detached img) + add pregenerate …
Hugo0 Feb 22, 2026
b3164d9
fix: Allow image generation for past daily words on word subpages
Hugo0 Feb 22, 2026
ab923d1
fix: Word subpages only show past words, not today's
Hugo0 Feb 22, 2026
0646815
fix: Add --past flag to pregenerate script for historical images
Hugo0 Feb 22, 2026
965e527
feat: Production-ready word pages with persistent storage and code cl…
Hugo0 Feb 22, 2026
7bdbef6
fix: Address code review feedback from PR #121
Hugo0 Feb 22, 2026
6ab758e
fix: Disable image gen for old words, remove Wiktionary links from ga…
Hugo0 Feb 22, 2026
dc018be
fix: Word subpage Wiktionary links now use native language wiki (not …
Hugo0 Feb 22, 2026
f629793
fix: Remove Today's Word title, definition link goes to word subpage
Hugo0 Feb 22, 2026
4163327
fix: Correct mistranslated share buttons across 31 languages
Hugo0 Feb 22, 2026
e3381cc
fix: Consolidate settings menu - group haptic+sound and definitions+art
Hugo0 Feb 22, 2026
1153853
feat: Consolidate settings - combine haptic+sound and definitions+art
Hugo0 Feb 22, 2026
29113e8
feat: Switch image style to flat vector matching UI aesthetic
Hugo0 Feb 22, 2026
e2e6a8c
refactor: Consolidate definition fetching into backend API
Hugo0 Feb 22, 2026
0f9e223
fix: Address CodeRabbit review comments (round 2)
Hugo0 Feb 22, 2026
2da621a
fix: Address CodeRabbit summary review findings
Hugo0 Feb 22, 2026
9f3057f
fix: Add blocklist entries for reported bad words across languages
Hugo0 Feb 22, 2026
8974d30
fix: Remove blocklisted words from daily word lists
Hugo0 Feb 22, 2026
fdf6c80
fix: Address CodeRabbit review comments (round 3)
Hugo0 Feb 22, 2026
2456bf2
fix: Restore blocklisted words to main word lists
Hugo0 Feb 22, 2026
e64e945
fix: Freeze historical daily words to disk cache
Hugo0 Feb 22, 2026
c2cd09d
fix: Load definition and image async on word subpage
Hugo0 Feb 22, 2026
a17588c
fix: Server-render definition when cached, async-fetch only on miss
Hugo0 Feb 22, 2026
6ee6e27
feat: Index all historical word pages in sitemap
Hugo0 Feb 22, 2026
53297ce
feat: Add robots.txt, llms.txt, JSON-LD, and canonical URLs
Hugo0 Feb 22, 2026
1cb1a8c
feat: SEO improvements — hreflang, crawlable links, better titles
Hugo0 Feb 22, 2026
420f6a5
fix: Use abort(404) for all not-found responses
Hugo0 Feb 22, 2026
1b704db
fix: address CR findings + add proper /stats page
Hugo0 Feb 22, 2026
d607618
style: align stats page with design system
Hugo0 Feb 22, 2026
0c380ef
fix: address remaining CR comments + stats bar chart alignment
Hugo0 Feb 22, 2026
15dd017
fix: CR comments + stats page improvements
Hugo0 Feb 22, 2026
f944f80
fix: narrow exception handling in stats aggregation
Hugo0 Feb 22, 2026
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
12 changes: 12 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -144,3 +144,15 @@ test-results/

# FrequencyWords data (downloaded by scripts/improve_word_lists.py)
scripts/.freq_data/

# AI-generated word images (cached by word-image endpoint)
webapp/static/word-images/

# Cached word definitions (fetched from Wiktionary)
webapp/static/word-defs/

# Anonymous per-word game stats
webapp/static/word-stats/

# Frozen daily word history (ensures past words never change)
webapp/static/word-history/
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ Each language folder in `webapp/data/languages/` contains a `SOURCES.md` with de

- [ ] Word definitions — show the definition of the daily word after the game (e.g. via Wiktionary API)
- [ ] Native speaker review of daily word lists for remaining languages
- [ ] Consolidate per-language data files — there are currently 3 overlapping mechanisms controlling daily word selection: `_daily_words.txt` (curated subset), `_blocklist.txt` (exclusion list), and `_curated_schedule.txt` (day-by-day override), plus the fallback to `_5words.txt`. These could be unified into a single curated daily list per language.

## Credits

Expand Down
101 changes: 101 additions & 0 deletions frontend/src/__tests__/definitions.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { fetchDefinition } from '../definitions';

// Mock fetch globally
const mockFetch = vi.fn();
vi.stubGlobal('fetch', mockFetch);

beforeEach(() => {
mockFetch.mockReset();
});

describe('fetchDefinition', () => {
it('fetches definition from backend API', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
json: () =>
Promise.resolve({
definition: 'A large bird.',
part_of_speech: 'Noun',
source: 'english',
url: 'https://en.wiktionary.org/wiki/crane',
}),
});

const result = await fetchDefinition('crane', 'en');
expect(result.source).toBe('english');
expect(result.definition).toBe('A large bird.');
expect(result.partOfSpeech).toBe('Noun');
expect(result.word).toBe('crane');

// Should call our backend API
const url = mockFetch.mock.calls[0]?.[0] as string;
expect(url).toBe('/en/api/definition/crane');
});

it('returns native source when backend provides it', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
json: () =>
Promise.resolve({
definition: 'Ein großer Vogel.',
source: 'native',
url: 'https://de.wiktionary.org/wiki/Kran',
}),
});

const result = await fetchDefinition('krane', 'de');
expect(result.source).toBe('native');
expect(result.definition).toBe('Ein großer Vogel.');
expect(mockFetch).toHaveBeenCalledTimes(1);
});

it('returns link fallback when API returns 404', async () => {
mockFetch.mockResolvedValueOnce({ ok: false, status: 404 });

const result = await fetchDefinition('xyz', 'de');
expect(result.source).toBe('link');
expect(result.definition).toBe('');
expect(result.url).toContain('en.wiktionary.org');
});

it('handles network errors gracefully', async () => {
mockFetch.mockRejectedValueOnce(new Error('Network error'));

const result = await fetchDefinition('test', 'de');
expect(result.source).toBe('link');
});

it('calls correct API URL for different languages', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
json: () =>
Promise.resolve({
definition: 'En fugl.',
source: 'native',
url: 'https://no.wiktionary.org/wiki/fugle',
}),
});

await fetchDefinition('fugle', 'nb');
const url = mockFetch.mock.calls[0]?.[0] as string;
// Frontend calls our API with the original lang code — backend handles mapping
expect(url).toBe('/nb/api/definition/fugle');
});

it('maps part_of_speech from backend response', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
json: () =>
Promise.resolve({
definition: 'To operate a crane.',
part_of_speech: 'Verb',
source: 'english',
url: 'https://en.wiktionary.org/wiki/crane',
}),
});

const result = await fetchDefinition('crane', 'en');
expect(result.partOfSpeech).toBe('Verb');
});
});
2 changes: 1 addition & 1 deletion frontend/src/analytics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@ interface InvalidWordParams {
}

interface SettingsChangeParams {
setting: 'dark_mode' | 'haptics' | 'sound';
setting: 'dark_mode' | 'haptics' | 'sound' | 'feedback' | 'word_info' | 'definitions';
value: boolean;
}

Expand Down
148 changes: 148 additions & 0 deletions frontend/src/definitions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
/**
* Word Definitions - fetched from our backend API
* The backend handles Wiktionary lookups and caching in one place.
*/
import type { WordDefinition } from './types';

function escapeHtml(str: string): string {
return str
.replace(/&/g, '&')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}

/**
* Fetch a word definition from our backend API.
* The backend tries native Wiktionary first, then English Wiktionary,
* and caches results to disk.
*/
export async function fetchDefinition(word: string, lang: string): Promise<WordDefinition> {
try {
const response = await fetch(`/${lang}/api/definition/${encodeURIComponent(word)}`);
if (response.ok) {
const data = await response.json();
return {
word,
partOfSpeech: data.part_of_speech || undefined,
definition: data.definition || '',
source: data.source || 'english',
url: data.url || '',
};
}
} catch {
// Network error — fall through to link fallback
}

// Fallback: no definition available
return {
word,
definition: '',
source: 'link',
url: `https://en.wiktionary.org/wiki/${encodeURIComponent(word)}`,
};
}

/**
* Render the definition card into the stats modal.
* Called after game completion when definitions are enabled.
*/
export function renderDefinitionCard(
def: WordDefinition,
container: HTMLElement,
uiStrings: { definition?: string; look_up_on_wiktionary?: string },
wordPageUrl?: string
): void {
const definitionLabel = uiStrings.definition || 'Definition';

const safeWord = escapeHtml(def.word);
const safeDefLabel = escapeHtml(definitionLabel);

const isSafeUrl = (url: string) =>
url.startsWith('/') || url.startsWith('https://') || url.startsWith('http://');

const linkHtml =
wordPageUrl && isSafeUrl(wordPageUrl)
? `<a href="${escapeHtml(wordPageUrl)}"
class="flex-shrink-0 text-neutral-400 hover:text-blue-500 dark:text-neutral-500 dark:hover:text-blue-400">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 20 20" fill="currentColor">
<path d="M11 3a1 1 0 100 2h2.586l-6.293 6.293a1 1 0 101.414 1.414L15 6.414V9a1 1 0 102 0V4a1 1 0 00-1-1h-5z"/>
<path d="M5 5a2 2 0 00-2 2v8a2 2 0 002 2h8a2 2 0 002-2v-3a1 1 0 10-2 0v3H5V7h3a1 1 0 000-2H5z"/>
</svg>
</a>`
: '';

if (def.source === 'link') {
// No definition found — hide the card (word subpage has Wiktionary links)
container.style.display = 'none';
return;
} else {
const safePos = def.partOfSpeech ? escapeHtml(def.partOfSpeech) : '';
const posHtml = safePos
? `<span class="text-xs text-neutral-400 dark:text-neutral-500 italic">${safePos}</span>`
: '';
const safeDef = escapeHtml(def.definition);

container.innerHTML = `
<div class="flex items-start gap-2">
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2 mb-0.5">
<span class="text-xs font-semibold uppercase tracking-wide text-neutral-500 dark:text-neutral-400">${safeDefLabel}</span>
${posHtml}
</div>
<p class="text-sm text-neutral-800 dark:text-neutral-200"><strong class="uppercase">${safeWord}</strong> &mdash; ${safeDef}</p>
</div>
${linkHtml}
</div>`;
}

container.style.display = 'block';
}

/**
* Render the word image into the image container.
* The image loads directly via GET — if it 404s or fails, the container is hidden.
* If the image isn't cached, the server generates it (may take 15-20s).
*/
export function renderWordImage(word: string, lang: string, container: HTMLElement): void {
const url = `/${lang}/api/word-image/${encodeURIComponent(word)}`;
const img = document.createElement('img');
img.className = 'w-full max-h-48 object-contain rounded-lg';
img.alt = word;
img.onload = () => {
container.innerHTML = '';
container.appendChild(img);
container.style.display = 'block';
};
img.onerror = () => {
container.style.display = 'none';
};
// Set src last to ensure handlers are attached before load starts
// Do NOT use loading="lazy" — the img is detached from DOM during load
img.src = url;
}

/**
* Show loading state in the definition card
*/
export function showDefinitionLoading(container: HTMLElement): void {
container.innerHTML = `
<div class="animate-pulse flex gap-2">
<div class="flex-1 space-y-1.5">
<div class="h-3 bg-neutral-200 dark:bg-neutral-700 rounded w-20"></div>
<div class="h-4 bg-neutral-200 dark:bg-neutral-700 rounded w-full"></div>
</div>
</div>`;
container.style.display = 'block';
}

/**
* Show loading state for word image
*/
export function showImageLoading(container: HTMLElement): void {
container.innerHTML = `
<div class="animate-pulse">
<div class="h-48 bg-neutral-200 dark:bg-neutral-700 rounded-lg w-full"></div>
</div>`;
container.style.display = 'block';
}
Loading